/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package season import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/form" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/uuid" ) const UnsetColor = 0xd1d0df type AdminHandler struct { } func NewAdminHandler() *AdminHandler { return &AdminHandler{} } func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: serveSeasonIndex(w, r, user, company, conn) case http.MethodPost: addSeason(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "new": switch r.Method { case http.MethodGet: f := newSeasonForm(company) f.MustRender(w, r, user, company) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } case "order": switch r.Method { case http.MethodPost: orderSeasons(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodPost) } case "range": switch r.Method { case http.MethodGet: serveSeasonCalendar(w, r, user, company, conn) case http.MethodPut: updateSeasonCalendar(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: if !uuid.Valid(head) { http.NotFound(w, r) return } f := newSeasonForm(company) if err := f.FillFromDatabase(r.Context(), conn, head); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: f.MustRender(w, r, user, company) case http.MethodPut: editSeason(w, r, user, company, conn, f) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: http.NotFound(w, r) } } } } func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { seasons, err := collectSeasonEntries(r.Context(), company, conn) if err != nil { panic(err) } calendar, err := CollectSeasonCalendar(r.Context(), company, conn, GetCalendarYear(r.URL.Query()), time.January, 12) if err != nil { panic(err) } calendar.Form = newCalendarForm(r.Context(), company, conn) page := &seasonIndex{ Seasons: seasons, Calendar: calendar, } page.MustRender(w, r, user, company) } func GetCalendarYear(query url.Values) int { yearStr := strings.TrimSpace(query.Get("year")) if yearStr != "" { if year, err := strconv.Atoi(yearStr); err == nil { return year } } return time.Now().Year() } func GetCalendarMonth(query url.Values) time.Month { monthStr := strings.TrimSpace(query.Get("month")) if monthStr != "" { if month, err := strconv.Atoi(monthStr); err == nil { return time.Month(month) } } return time.Now().Month() } func collectSeasonEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*seasonEntry, error) { rows, err := conn.Query(ctx, ` select season.slug , '/admin/seasons/' || season.slug , season.name , to_color(color)::text , active from season where season.company_id = $1 order by position, name`, company.ID) if err != nil { return nil, err } defer rows.Close() var seasons []*seasonEntry for rows.Next() { entry := &seasonEntry{} if err = rows.Scan(&entry.Slug, &entry.URL, &entry.Name, &entry.Color, &entry.Active); err != nil { return nil, err } seasons = append(seasons, entry) } return seasons, nil } type seasonEntry struct { ID int Slug string URL string Name string Color string Active bool } var longMonthNames = []string{ locale.PgettextNoop("January", "month"), locale.PgettextNoop("February", "month"), locale.PgettextNoop("March", "month"), locale.PgettextNoop("April", "month"), locale.PgettextNoop("May", "month"), locale.PgettextNoop("June", "month"), locale.PgettextNoop("July", "month"), locale.PgettextNoop("August", "month"), locale.PgettextNoop("September", "month"), locale.PgettextNoop("October", "month"), locale.PgettextNoop("November", "month"), locale.PgettextNoop("December", "month"), } func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int, firstMonth time.Month, monthCount int) (*Calendar, error) { firstDay := time.Date(year, firstMonth, 1, 0, 0, 0, 0, time.UTC) lastDay := firstDay.AddDate(0, monthCount, 0).Add(-1 * time.Second) rows, err := conn.Query(ctx, ` select t.day::date , to_color(coalesce(color, $1)) as color from generate_series($2, $3, interval '1 day') as t(day) left join season_calendar on season_range @> t.day::date left join season on season.season_id = season_calendar.season_id and company_id = $4 `, UnsetColor, firstDay.Format(database.ISODateTimeFormat), lastDay.Format(database.ISODateTimeFormat), company.ID) if err != nil { return nil, err } var month *Month var week Week calendar := &Calendar{ Year: year, } weekday := int(time.Monday) for rows.Next() { day := &Day{} if err = rows.Scan(&day.Date, &day.Color); err != nil { return nil, err } dayMonth := day.Date.Month() if month == nil || month.Month != dayMonth { if month != nil { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { week = append(week, &Day{}) } month.Weeks = append(month.Weeks, week) calendar.Months = append(calendar.Months, month) } month = &Month{ Year: day.Date.Year(), Month: dayMonth, Name: longMonthNames[dayMonth-1], } week = Week{} weekday = int(time.Monday) dayWeekday := int(day.Date.Weekday()) for ; weekday != dayWeekday; weekday = (weekday + 1) % 7 { week = append(week, &Day{}) } } week = append(week, day) weekday = (weekday + 1) % 7 if weekday == int(time.Monday) { month.Weeks = append(month.Weeks, week) week = Week{} } } if month != nil { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { week = append(week, &Day{}) } month.Weeks = append(month.Weeks, week) calendar.Months = append(calendar.Months, month) } return calendar, nil } type Calendar struct { Year int Months []*Month Form *calendarForm } type Month struct { Year int Month time.Month Name string Weeks []Week } type Week []*Day type Day struct { Date time.Time Color string } type seasonIndex struct { Seasons []*seasonEntry Calendar *Calendar } func (page *seasonIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdminFiles(w, r, user, company, page, "season/index.gohtml", "season/calendar.gohtml") } func processSeasonForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm, act func(context.Context, *database.Tx) error) { if err := f.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil { panic(err) } else if !ok { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } f.MustRender(w, r, user, company) return } tx := conn.MustBegin(r.Context()) if err := act(r.Context(), tx); err == nil { tx.MustCommit(r.Context()) } else { if rErr := tx.Rollback(r.Context()); rErr != nil { err = rErr } panic(err) } httplib.Redirect(w, r, "/admin/seasons", http.StatusSeeOther) } func addSeason(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newSeasonForm(company) processSeasonForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { var err error if f.Slug, err = tx.AddSeason(ctx, company.ID, f.Name[f.DefaultLang].Val, f.Color.Val); err != nil { return err } return translateSeason(ctx, tx, company, f) }) } func translateSeason(ctx context.Context, tx *database.Tx, company *auth.Company, f *seasonForm) error { for lang := range company.Locales { l := lang.String() if l == f.DefaultLang { continue } if err := tx.TranslateSeason(ctx, f.Slug, lang, f.Name[l].Val); err != nil { return err } } return nil } func editSeason(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm) { processSeasonForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { if err := tx.EditSeason(ctx, f.Slug, f.Name[f.DefaultLang].Val, f.Color.Val, f.Active.Checked); err != nil { return err } return translateSeason(ctx, tx, company, f) }) } type seasonForm struct { DefaultLang string Slug string Active *form.Checkbox Name form.I18nInput Color *form.Input } func newSeasonForm(company *auth.Company) *seasonForm { return &seasonForm{ DefaultLang: company.DefaultLanguage.String(), Active: &form.Checkbox{ Name: "active", Checked: true, }, Name: form.NewI18nInput(company.Locales, "label"), Color: &form.Input{ Name: "season", }, } } func (f *seasonForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error { f.Slug = slug var name database.RecordArray row := conn.QueryRow(ctx, ` select season.name , to_color(color)::text , active , array_agg((lang_tag, i18n.name)) from season left join season_i18n as i18n using (season_id) where slug = $1 group by season.name , to_color(color)::text , active `, pgx.QueryResultFormats{pgx.BinaryFormatCode}, slug) if err := row.Scan(&f.Name[f.DefaultLang].Val, &f.Color.Val, &f.Active.Checked, &name); err != nil { return err } if err := f.Name.FillArray(name); err != nil { return err } return nil } func (f *seasonForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.Active.FillValue(r) f.Name.FillValue(r) f.Color.FillValue(r) return nil } func (f *seasonForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { v := form.NewValidator(l) v.CheckRequired(f.Name[f.DefaultLang], l.GettextNoop("Name can not be empty.")) if v.CheckRequired(f.Color, l.GettextNoop("Color can not be empty.")) { if _, err := v.CheckValidColor(ctx, conn, f.Color, l.Gettext("This color is not valid. It must be like #123abc.")); err != nil { return false, err } } return v.AllOK, nil } func (f *seasonForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdmin(w, r, user, company, "season/form.gohtml", f) } func orderSeasons(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } slugs := r.PostForm["slug"] if len(slugs) > 0 { for _, slug := range slugs { if !uuid.Valid(slug) { w.WriteHeader(http.StatusUnprocessableEntity) return } } if err := conn.OrderSeasons(r.Context(), slugs); err != nil { panic(err) } } serveSeasonIndex(w, r, user, company, conn) } func serveSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newCalendarForm(r.Context(), company, conn) f.MustRender(w, r, user, company, conn, GetCalendarYear(r.URL.Query())) } func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newCalendarForm(r.Context(), company, conn) if ok, err := form.Handle(f, w, r, user); err != nil { return } else if ok { var seasonRange pgtype.Daterange if err = seasonRange.Set(fmt.Sprintf("[%s,%s]", f.StartDate.Val, f.EndDate.Val)); err != nil { panic(err) } if f.SeasonID.Val == "" { // Nothing to do } else if f.SeasonID.Val == "0" { conn.MustExec(r.Context(), "select unset_season_range($1)", seasonRange) } else { conn.MustExec(r.Context(), "select set_season_range($1, $2)", f.SeasonID, seasonRange) } f.StartDate.Val = "" f.EndDate.Val = "" } f.MustRender(w, r, user, company, conn, GetCalendarYear(r.Form)) } type calendarForm struct { Seasons []*seasonEntry StartDate *form.Input EndDate *form.Input SeasonID *form.Input } func newCalendarForm(ctx context.Context, company *auth.Company, conn *database.Conn) *calendarForm { return &calendarForm{ Seasons: mustCollectCalendarSeasons(ctx, company, conn), StartDate: &form.Input{ Name: "start_date", }, EndDate: &form.Input{ Name: "end_date", }, SeasonID: &form.Input{ Name: "season_id", }, } } func mustCollectCalendarSeasons(ctx context.Context, company *auth.Company, conn *database.Conn) []*seasonEntry { rows, err := conn.Query(ctx, ` select 0 as season_id , $1 as name , to_color($2)::text , true , 0 as position union all select season_id , name , to_color(color)::text , active , position from season where company_id = $3 and active order by position, name`, locale.PgettextNoop("Unset", "action"), UnsetColor, company.ID) if err != nil { panic(err) } defer rows.Close() var seasons []*seasonEntry for rows.Next() { entry := &seasonEntry{} var sort int if err = rows.Scan(&entry.ID, &entry.Name, &entry.Color, &entry.Active, &sort); err != nil { panic(err) } seasons = append(seasons, entry) } return seasons } func (f *calendarForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.StartDate.FillValue(r) f.EndDate.FillValue(r) f.SeasonID.FillValue(r) return nil } func (f *calendarForm) Valid(l *locale.Locale) bool { v := form.NewValidator(l) if v.CheckRequired(f.StartDate, l.GettextNoop("Start date can not be empty.")) { v.CheckValidDate(f.StartDate, l.GettextNoop("Start date must be a valid date.")) } if v.CheckRequired(f.EndDate, l.GettextNoop("End date can not be empty.")) { v.CheckValidDate(f.EndDate, l.GettextNoop("End date must be a valid date.")) } return v.AllOK } func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) { calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year, time.January, 12) if err != nil { panic(err) } calendar.Form = f template.MustRenderAdminNoLayout(w, r, user, company, "season/calendar.gohtml", calendar) }