/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package season import ( "context" "net/http" "time" "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" ) 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 "new": switch r.Method { case http.MethodGet: f := newSeasonForm() f.MustRender(w, r, user, company) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } 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) } default: if !uuid.Valid(head) { http.NotFound(w, r) return } f := newSeasonForm() if err := f.FillFromDatabase(r.Context(), conn, head); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } 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) } } } } 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, 2023) if err != nil { panic(err) } page := &seasonIndex{ Seasons: seasons, Calendar: calendar, } page.MustRender(w, r, user, company) } func collectSeasonEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*seasonEntry, error) { rows, err := conn.Query(ctx, ` select slug , name , to_color(color)::text , active from season where company_id = $1 order by 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.Name, &entry.Color, &entry.Active); err != nil { return nil, err } seasons = append(seasons, entry) } return seasons, nil } type seasonEntry struct { Slug 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) (seasonCalendar, error) { firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC) rows, err := conn.Query(ctx, ` select t.day::date , to_color(coalesce(color, 13750495)) as color from generate_series($1, $2, 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 = $3 `, firstDay, lastDay, company.ID) if err != nil { return nil, err } var month *seasonMonth var week seasonWeek var calendar seasonCalendar weekday := int(time.Monday) for rows.Next() { day := &seasonDay{} 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, &seasonDay{}) } month.Weeks = append(month.Weeks, week) calendar = append(calendar, month) } month = &seasonMonth{ Month: dayMonth, Name: longMonthNames[dayMonth-1], } week = seasonWeek{} weekday = int(time.Monday) dayWeekday := int(day.Date.Weekday()) for ; weekday != dayWeekday; weekday = (weekday + 1) % 7 { week = append(week, &seasonDay{}) } } week = append(week, day) weekday = (weekday + 1) % 7 if weekday == int(time.Monday) { month.Weeks = append(month.Weeks, week) week = seasonWeek{} } } if month != nil { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { week = append(week, &seasonDay{}) } month.Weeks = append(month.Weeks, week) calendar = append(calendar, month) } return calendar, nil } type seasonCalendar []*seasonMonth type seasonMonth struct { Month time.Month Name string Weeks []seasonWeek } type seasonWeek []*seasonDay type seasonDay struct { Date time.Time Color string } type seasonIndex struct { Seasons []*seasonEntry Calendar seasonCalendar } func (page *seasonIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdmin(w, r, user, company, "season/index.gohtml", page) } func processSeasonForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm, act func()) { 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 } act() 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() processSeasonForm(w, r, user, company, conn, f, func() { conn.MustExec(r.Context(), "select add_season($1, $2, $3)", company.ID, f.Name, f.Color) }) } 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() { conn.MustExec(r.Context(), "select edit_season($1, $2, $3, $4)", f.Slug, f.Name, f.Color, f.Active) }) } type seasonForm struct { Slug string Active *form.Checkbox Name *form.Input Color *form.Input } func newSeasonForm() *seasonForm { return &seasonForm{ Active: &form.Checkbox{ Name: "active", Checked: true, }, Name: &form.Input{ Name: "label", }, Color: &form.Input{ Name: "season", }, } } func (f *seasonForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error { f.Slug = slug row := conn.QueryRow(ctx, "select name, to_color(color)::text, active from season where slug = $1", slug) return row.Scan(&f.Name.Val, &f.Color.Val, &f.Active.Checked) } 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, 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) }