diff --git a/backend/internal/db/queries/events.sql b/backend/internal/db/queries/events.sql index dfa7ce6..4b20a12 100644 --- a/backend/internal/db/queries/events.sql +++ b/backend/internal/db/queries/events.sql @@ -8,6 +8,22 @@ SELECT id, tablo_id, title, event_date, start_time, end_time, description, locat FROM events WHERE id = $1 AND tablo_id = $2; +-- name: UpdateEvent :one +UPDATE events +SET title = $3, + event_date = $4, + start_time = $5, + end_time = $6, + description = $7, + location = $8, + updated_at = now() +WHERE id = $1 AND tablo_id = $2 +RETURNING id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at; + +-- name: DeleteEvent :exec +DELETE FROM events +WHERE id = $1 AND tablo_id = $2; + -- name: ListEventsByTabloRange :many SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at FROM events diff --git a/backend/internal/web/handlers_events.go b/backend/internal/web/handlers_events.go index bdae3ca..934056d 100644 --- a/backend/internal/web/handlers_events.go +++ b/backend/internal/web/handlers_events.go @@ -1,6 +1,7 @@ package web import ( + "errors" "log/slog" "net/http" "strings" @@ -10,7 +11,9 @@ import ( "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" ) @@ -107,6 +110,32 @@ func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, 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 { @@ -189,6 +218,84 @@ func EventCreateHandler(deps EventsDeps) http.HandlerFunc { } } +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")) diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 9a43db4..454f747 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -119,6 +119,10 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Get("/tablos/{id}/events/new", EventNewFormHandler(eventDeps)) r.Get("/tablos/{id}/events/cancel-new", EventCancelNewHandler(eventDeps)) r.Post("/tablos/{id}/events", EventCreateHandler(eventDeps)) + r.Get("/tablos/{id}/events/{event_id}/edit", EventEditFormHandler(eventDeps)) + r.Post("/tablos/{id}/events/{event_id}", EventUpdateHandler(eventDeps)) + r.Get("/tablos/{id}/events/{event_id}/delete-confirm", EventDeleteConfirmHandler(eventDeps)) + r.Post("/tablos/{id}/events/{event_id}/delete", EventDeleteHandler(eventDeps)) // Parametric task routes — must come after static task segments. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) diff --git a/backend/templates/events.templ b/backend/templates/events.templ index 294755d..8bbe7f5 100644 --- a/backend/templates/events.templ +++ b/backend/templates/events.templ @@ -87,7 +87,7 @@ templ EventDayCell(tabloID uuid.UUID, month string, day EventCalendarDay) {