xtablo-source/backend/internal/web/handlers_events.go

360 lines
13 KiB
Go
Raw Normal View History

package web
import (
"errors"
"log/slog"
"net/http"
"strings"
"time"
"backend/internal/db/sqlc"
"backend/templates"
"github.com/google/uuid"
"github.com/go-chi/chi/v5"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type EventsDeps struct {
Queries *sqlc.Queries
}
func parseCalendarMonth(raw string, now time.Time) time.Time {
raw = strings.TrimSpace(raw)
if raw != "" {
if parsed, err := time.Parse("2006-01", raw); err == nil {
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local)
}
}
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
}
func pgDateFromTime(t time.Time) pgtype.Date {
return pgtype.Date{Time: time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), Valid: true}
}
func parseEventDate(raw string) (pgtype.Date, bool) {
parsed, err := time.Parse("2006-01-02", strings.TrimSpace(raw))
if err != nil {
return pgtype.Date{}, false
}
return pgDateFromTime(parsed), true
}
func parseEventTime(raw string) (pgtype.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return pgtype.Time{}, false
}
parsed, err := time.Parse("15:04", raw)
if err != nil {
return pgtype.Time{}, false
}
micros := int64(parsed.Hour())*int64(time.Hour/time.Microsecond) + int64(parsed.Minute())*int64(time.Minute/time.Microsecond)
return pgtype.Time{Microseconds: micros, Valid: true}, true
}
func buildEventsCalendar(month time.Time, events []sqlc.Event) templates.EventsCalendar {
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
monthEnd := monthStart.AddDate(0, 1, -1)
leading := (int(monthStart.Weekday()) + 6) % 7
gridStart := monthStart.AddDate(0, 0, -leading)
days := make([]templates.EventCalendarDay, 0, 42)
byDate := make(map[string][]sqlc.Event)
for _, event := range events {
key := templates.FormatEventDate(event.EventDate)
byDate[key] = append(byDate[key], event)
}
for i := 0; i < 42; i++ {
day := gridStart.AddDate(0, 0, i)
key := day.Format("2006-01-02")
dayEvents := byDate[key]
more := 0
if len(dayEvents) > 3 {
more = len(dayEvents) - 3
}
days = append(days, templates.EventCalendarDay{
Date: key,
DayNumber: day.Day(),
InMonth: !day.Before(monthStart) && !day.After(monthEnd),
Events: dayEvents,
MoreCount: more,
})
}
return templates.EventsCalendar{
Month: monthStart.Format("2006-01"),
MonthLabel: monthStart.Format("January 2006"),
PrevMonth: monthStart.AddDate(0, -1, 0).Format("2006-01"),
PrevMonthLabel: monthStart.AddDate(0, -1, 0).Format("January 2006"),
NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"),
NextMonthLabel: monthStart.AddDate(0, 1, 0).Format("January 2006"),
Days: days,
WeekdayLabs: templates.DefaultWeekdayLabels(),
}
}
func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.EventsCalendar, bool) {
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now())
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
monthEnd := monthStart.AddDate(0, 1, -1)
events, err := q.ListEventsByTabloRange(r.Context(), sqlc.ListEventsByTabloRangeParams{
TabloID: tablo.ID,
EventDate: pgDateFromTime(monthStart),
EventDate_2: pgDateFromTime(monthEnd),
})
if err != nil {
slog.Default().Error("events tab: ListEventsByTabloRange failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return templates.EventsCalendar{}, false
}
return buildEventsCalendar(month, events), true
}
func loadOwnedEvent(w http.ResponseWriter, r *http.Request, deps EventsDeps) (sqlc.Tablo, sqlc.Event, bool) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return sqlc.Tablo{}, sqlc.Event{}, false
}
eventID, err := uuid.Parse(chi.URLParam(r, "event_id"))
if err != nil {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Event{}, false
}
event, err := deps.Queries.GetEventByID(r.Context(), sqlc.GetEventByIDParams{
ID: eventID,
TabloID: tablo.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Event{}, false
}
slog.Default().Error("events: GetEventByID failed", "tablo_id", tablo.ID, "event_id", eventID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return sqlc.Tablo{}, sqlc.Event{}, false
}
return tablo, event, true
}
func renderEventsTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
calendar, ok := loadEventsCalendar(w, r, q, tablo)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#events-tab")
w.Header().Set("HX-Reswap", "outerHTML")
}
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
}
func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
calendar, ok := loadEventsCalendar(w, r, deps.Queries, tablo)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
return
}
eventsSidebarTablos, sidebarErr := deps.Queries.ListTablosByUser(r.Context(), user.ID)
if sidebarErr != nil {
slog.Default().Error("events: ListTablosByUser failed", "user_id", user.ID, "err", sidebarErr)
eventsSidebarTablos = []sqlc.Tablo{}
}
if eventsSidebarTablos == nil {
eventsSidebarTablos = []sqlc.Tablo{}
}
_ = templates.TabloDetailPage(user, csrf.Token(r), "", eventsSidebarTablos, tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, templates.DiscussionTabData{}, "events").Render(r.Context(), w)
}
}
func EventNewFormHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
form := templates.EventCreateForm{EventDate: strings.TrimSpace(r.URL.Query().Get("date"))}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventCreateFormFragment(tablo.ID, form, templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
}
}
func EventCancelNewHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(""))
}
}
func EventCreateHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
form, params, errs, valid := parseEventCreateForm(r, tablo.ID)
if !valid {
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#event-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EventCreateFormFragment(tablo.ID, form, errs, csrf.Token(r), month).Render(r.Context(), w)
return
}
if _, err := deps.Queries.CreateEvent(r.Context(), params); err != nil {
slog.Default().Error("events create: CreateEvent failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderEventsTab(w, r, deps.Queries, tablo)
}
}
func EventEditFormHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventEditFormFragment(tablo.ID, event, templates.EventFormFromEvent(event), templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
}
}
func EventUpdateHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
form, createParams, errs, valid := parseEventCreateForm(r, tablo.ID)
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
if !valid {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#event-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EventEditFormFragment(tablo.ID, event, form, errs, csrf.Token(r), month).Render(r.Context(), w)
return
}
if _, err := deps.Queries.UpdateEvent(r.Context(), sqlc.UpdateEventParams{
ID: event.ID,
TabloID: tablo.ID,
Title: createParams.Title,
EventDate: createParams.EventDate,
StartTime: createParams.StartTime,
EndTime: createParams.EndTime,
Description: createParams.Description,
Location: createParams.Location,
}); err != nil {
slog.Default().Error("events update: UpdateEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderEventsTab(w, r, deps.Queries, tablo)
}
}
func EventDeleteConfirmHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventDeleteConfirmFragment(tablo.ID, event, csrf.Token(r), month).Render(r.Context(), w)
}
}
func EventDeleteHandler(deps EventsDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, event, ok := loadOwnedEvent(w, r, deps)
if !ok {
return
}
if err := deps.Queries.DeleteEvent(r.Context(), sqlc.DeleteEventParams{
ID: event.ID,
TabloID: tablo.ID,
}); err != nil {
slog.Default().Error("events delete: DeleteEvent failed", "tablo_id", tablo.ID, "event_id", event.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderEventsTab(w, r, deps.Queries, tablo)
}
}
func parseEventCreateForm(r *http.Request, tabloID uuid.UUID) (templates.EventCreateForm, sqlc.CreateEventParams, templates.EventCreateErrors, bool) {
title := strings.TrimSpace(r.PostFormValue("title"))
eventDate := strings.TrimSpace(r.PostFormValue("event_date"))
startTime := strings.TrimSpace(r.PostFormValue("start_time"))
endTime := strings.TrimSpace(r.PostFormValue("end_time"))
location := strings.TrimSpace(r.PostFormValue("location"))
description := strings.TrimSpace(r.PostFormValue("description"))
form := templates.EventCreateForm{
Title: title,
EventDate: eventDate,
StartTime: startTime,
EndTime: endTime,
Location: location,
Description: description,
}
var errs templates.EventCreateErrors
if title == "" {
errs.Title = "Title is required."
} else if len(title) > 255 {
errs.Title = "Title must be 255 characters or fewer."
}
dateValue, ok := parseEventDate(eventDate)
if !ok {
errs.EventDate = "Date is required."
}
startValue, ok := parseEventTime(startTime)
if !ok {
errs.StartTime = "Start time is required."
}
var endValue pgtype.Time
if endTime != "" {
var endOK bool
endValue, endOK = parseEventTime(endTime)
if !endOK {
errs.EndTime = "End time must use HH:MM."
} else if startValue.Valid && endValue.Microseconds <= startValue.Microseconds {
errs.EndTime = "End time must be after the start time."
}
}
valid := errs.Title == "" && errs.EventDate == "" && errs.StartTime == "" && errs.EndTime == ""
params := sqlc.CreateEventParams{
TabloID: tabloID,
Title: title,
EventDate: dateValue,
StartTime: startValue,
EndTime: endValue,
Description: pgtype.Text{String: description, Valid: description != ""},
Location: pgtype.Text{String: location, Valid: location != ""},
}
return form, params, errs, valid
}