package web import ( "errors" "log/slog" "net/http" "regexp" "strings" "backend/internal/auth" "backend/internal/db/sqlc" "backend/templates" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/gorilla/csrf" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) // hexColorRE matches CSS hex colors: #RGB or #RRGGBB (case-insensitive). var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$`) // isValidCSSColor returns true when s is a valid hex color string. func isValidCSSColor(s string) bool { return hexColorRE.MatchString(s) } // TablosDeps holds dependencies for all tablo handlers. // Introduced in Plan 01 as a stub to allow handlers_tablos_test.go to compile. // Plan 02 adds the actual handler implementations. type TablosDeps struct { Queries *sqlc.Queries } // TablosListHandler handles GET / for authenticated users. // Fetches all tablos for the current user newest-first and renders TablosDashboard. // Returns the empty-state via TablosEmptyState when the user has no tablos (TABLO-01). func TablosListHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { _, user, _ := auth.Authed(r.Context()) tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID) if err != nil { slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if tablos == nil { tablos = []sqlc.Tablo{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) } } // TablosNewHandler handles GET /tablos/new. // Returns the create form fragment for HTMX insertion into #create-form-slot. // Works without HX-Request too (falls back to rendering the fragment as a full response). func TablosNewHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloCreateFormFragment( templates.TabloCreateForm{}, templates.TabloCreateErrors{}, csrf.Token(r), ).Render(r.Context(), w) } } // TablosCreateHandler handles POST /tablos. // Validates, inserts the tablo, and either: // - HTMX path: sets HX-Retarget + HX-Reswap and renders TabloCardWithOOBFormClear (200) // - Non-HTMX path: 303 redirect to / (TABLO-06 degrade-gracefully) // // Security invariants: // - Form values read via r.PostFormValue only (gorilla/csrf consumes r.Body — Pitfall 2) // - User extracted from RequireAuth-gated context (T-03-02-01) // - Title validates non-empty and <= 255 chars (T-03-02-04) // - Description and Color inserted as pgtype.Text{Valid: s != ""} (T-03-02-03) // - On validation error: 422 + fragment (HTMX) or 422 + full page (non-HTMX) func TablosCreateHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() _, user, _ := auth.Authed(ctx) // 1. Read form values — always r.PostFormValue, never r.Body (Pitfall 2). title := strings.TrimSpace(r.PostFormValue("title")) description := r.PostFormValue("description") color := strings.TrimSpace(r.PostFormValue("color")) var errs templates.TabloCreateErrors // 2. Validate title (UI-SPEC Validation copy). if title == "" { errs.Title = "Title is required." } else if len(title) > 255 { errs.Title = "Title must be 255 characters or fewer." } // Validate color: accept only hex #RGB or #RRGGBB format (WR-04). if color != "" && !isValidCSSColor(color) { errs.Color = "Color must be a valid hex color (e.g. #6366f1)." } if errs.Title != "" || errs.Color != "" { renderTabloCreateError(w, r, templates.TabloCreateForm{ Title: title, Description: description, Color: color, }, errs, http.StatusUnprocessableEntity, deps) return } // 3. Build insert params with nullable pgtype.Text for description and color. params := sqlc.InsertTabloParams{ UserID: user.ID, Title: title, Description: pgtype.Text{String: description, Valid: description != ""}, Color: pgtype.Text{String: color, Valid: color != ""}, } tablo, err := deps.Queries.InsertTablo(ctx, params) if err != nil { slog.Default().Error("tablos create: insert failed", "user_id", user.ID, "err", err) errs.General = "Something went wrong. Please try again." renderTabloCreateError(w, r, templates.TabloCreateForm{ Title: title, Description: description, Color: color, }, errs, http.StatusInternalServerError, deps) return } // 4. Success response. // HTMX: dual-target swap — prepend card to #tablos-list + OOB clear form slot. // Non-HTMX: 303 redirect to / (Pitfall 9 — NOT 302). if r.Header.Get("HX-Request") == "true" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Retarget", "#tablos-list") w.Header().Set("HX-Reswap", "afterbegin") _ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(ctx, w) return } http.Redirect(w, r, "/", http.StatusSeeOther) } } // loadOwnedTablo is a shared preamble for all /tablos/{id}* handlers. // It: // 1. Parses the {id} URL param as a UUID → 404 on parse failure (T-03-03-03). // 2. Fetches the tablo by ID → 404 on pgx.ErrNoRows, 500 on other errors. // 3. Checks ownership → 404 (not 403) when tablo.UserID != user.ID (T-03-03-01, D-04). // // Returns (tablo, user, true) on success. On failure the helper writes the // appropriate HTTP response and returns (zero, nil, false); callers must return // immediately when ok is false. func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) { _, user, _ := auth.Authed(r.Context()) // Step 1: parse the URL parameter. tabloID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { http.NotFound(w, r) return sqlc.Tablo{}, nil, false } // Step 2: fetch from DB — user_id filter pushes ownership into the query (WR-01). tablo, err := deps.Queries.GetTabloByID(r.Context(), sqlc.GetTabloByIDParams{ID: tabloID, UserID: user.ID}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.NotFound(w, r) return sqlc.Tablo{}, nil, false } slog.Default().Error("tablos: GetTabloByID failed", "id", tabloID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return sqlc.Tablo{}, nil, false } return tablo, user, true } // TabloDetailHandler handles GET /tablos/{id}. // Renders the tablo detail page for the authenticated owner; 404 for non-owner // and invalid UUIDs (TABLO-03, T-03-03-01, T-03-03-03). // Also fetches the tablo's tasks for the kanban board (Plan 02). func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, user, ok := loadOwnedTablo(w, r, deps) if !ok { return } // Fetch tasks for the kanban board. On error, log and use empty slice — // the tablo itself is valid so we still render the page (Plan 02, T-04-07). tasks, err := deps.Queries.ListTasksByTablo(r.Context(), tablo.ID) if err != nil { slog.Default().Error("tablos detail: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err) tasks = []sqlc.Task{} } if tasks == nil { tasks = []sqlc.Task{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(r.Context(), w) } } // TabloEditTitleHandler handles GET /tablos/{id}/edit-title. // Returns the title edit fragment for HTMX outerHTML swap. func TabloEditTitleHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloTitleEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w) } } // TabloShowTitleHandler handles GET /tablos/{id}/show-title. // Returns the title display fragment — used by the "Discard changes" path. func TabloShowTitleHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloTitleDisplay(tablo, csrf.Token(r)).Render(r.Context(), w) } } // TabloEditDescHandler handles GET /tablos/{id}/edit-desc. // Returns the description edit fragment for HTMX outerHTML swap. func TabloEditDescHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloDescEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w) } } // TabloShowDescHandler handles GET /tablos/{id}/show-desc. // Returns the description display fragment — used by the "Discard changes" path. func TabloShowDescHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloDescDisplay(tablo, csrf.Token(r)).Render(r.Context(), w) } } // TabloUpdateHandler handles POST /tablos/{id}. // Validates, updates the DB row (title + description), and returns the // matching display fragment based on the hidden _zone field. // // Security invariants: // - Form values read via r.PostFormValue only (gorilla/csrf body — Pitfall 2) // - Title validated non-empty and <= 255 chars (T-03-03-05) // - Description written as pgtype.Text{Valid: s != ""} (T-03-03-06) // - HTMX path: 200 + display fragment; non-HTMX path: 303 to /tablos/{id} func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Capture user from loadOwnedTablo for non-HTMX error path (CR-02). tablo, user, ok := loadOwnedTablo(w, r, deps) if !ok { return } ctx := r.Context() // Read form values — always r.PostFormValue (Pitfall 2). title := strings.TrimSpace(r.PostFormValue("title")) description := r.PostFormValue("description") zone := r.PostFormValue("_zone") // "title" or "desc" var errs templates.TabloUpdateErrors // Validate title. if title == "" { errs.Title = "Title is required." } else if len(title) > 255 { errs.Title = "Title must be 255 characters or fewer." } if errs.Title != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusUnprocessableEntity) if r.Header.Get("HX-Request") == "true" { _ = templates.TabloTitleEditFragment(tablo, errs, csrf.Token(r)).Render(ctx, w) return } // Non-HTMX: render full detail page with errors surfaced using the // authenticated user (not nil) to avoid broken layout (CR-02). // Fetch tasks for the kanban board; use empty slice on error. tasks, tasksErr := deps.Queries.ListTasksByTablo(ctx, tablo.ID) if tasksErr != nil { slog.Default().Error("tablos update: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", tasksErr) tasks = []sqlc.Task{} } if tasks == nil { tasks = []sqlc.Task{} } _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, "overview").Render(ctx, w) return } // Update the DB row — pass tablo.Color to preserve it across title/desc edits (CR-01). updated, err := deps.Queries.UpdateTablo(ctx, sqlc.UpdateTabloParams{ ID: tablo.ID, Title: title, Description: pgtype.Text{String: description, Valid: description != ""}, Color: tablo.Color, }) if err != nil { slog.Default().Error("tablos update: query failed", "id", tablo.ID, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Success: return appropriate display fragment or redirect. w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { // Render the display fragment matching the zone that was edited. if zone == "desc" { _ = templates.TabloDescDisplay(updated, csrf.Token(r)).Render(ctx, w) return } // Default to title display. _ = templates.TabloTitleDisplay(updated, csrf.Token(r)).Render(ctx, w) return } // Non-HTMX: 303 redirect to detail page so GET renders fresh state. http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) } } // TabloDeleteConfirmHandler handles GET /tablos/{id}/delete-confirm. // Returns the delete confirmation fragment for HTMX outerHTML swap. func TabloDeleteConfirmHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloDeleteConfirmFragment(tablo, csrf.Token(r)).Render(r.Context(), w) } } // TabloDeleteCancelHandler handles GET /tablos/{id}/delete-cancel. // Returns the delete button fragment, restoring the original button after // the user cancels the confirmation dialog. func TabloDeleteCancelHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = templates.TabloDeleteButtonFragment(tablo, csrf.Token(r)).Render(r.Context(), w) } } // TabloDeleteHandler handles POST /tablos/{id}/delete. // Hard-deletes the tablo and redirects to /: // - HTMX: 200 + HX-Redirect: / header (HTMX client-side navigation) // - Non-HTMX: 303 to / (Pitfall 9 — POST/Redirect/GET, NOT 302) // // Security: loadOwnedTablo enforces auth + ownership; CSRF middleware validates // the _csrf field before this handler runs (T-03-03-09). func TabloDeleteHandler(deps TablosDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tablo, _, ok := loadOwnedTablo(w, r, deps) if !ok { return } // DeleteTablo includes user_id in the WHERE clause for defense-in-depth (WR-02). if err := deps.Queries.DeleteTablo(r.Context(), sqlc.DeleteTabloParams{ID: tablo.ID, UserID: tablo.UserID}); err != nil { slog.Default().Error("tablos delete: query failed", "id", tablo.ID, "err", err) // On HTMX request, render a meaningful fragment instead of plain-text 500 // to avoid leaving the delete zone in a broken DOM state (CR-03). if r.Header.Get("HX-Request") == "true" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) _ = templates.TabloDeleteConfirmFragment(tablo, csrf.Token(r)).Render(r.Context(), w) return } http.Error(w, "internal server error", http.StatusInternalServerError) return } if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/") w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, "/", http.StatusSeeOther) } } // renderTabloCreateError writes a tablo create validation-error response. // For HTMX requests it renders only the form fragment; for plain requests it // renders the full dashboard (mirrors the auth handler pattern). func renderTabloCreateError(w http.ResponseWriter, r *http.Request, form templates.TabloCreateForm, errs templates.TabloCreateErrors, status int, deps TablosDeps) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if r.Header.Get("HX-Request") == "true" { _ = templates.TabloCreateFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w) return } // Non-HTMX: render full dashboard with errs embedded in the form. // Fetch the user's tablos so the list is still accurate on re-render. _, user, _ := auth.Authed(r.Context()) tablos, fetchErr := deps.Queries.ListTablosByUser(r.Context(), user.ID) if fetchErr != nil { slog.Default().Error("renderTabloCreateError: list fetch failed", "user_id", user.ID, "err", fetchErr) tablos = []sqlc.Tablo{} } // Render full page — form fragment is not embedded in the full page by default; // for the non-HTMX error case we redirect so the user sees their list intact // and can try again (simpler than threading form state through the full page). _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) }