From 5db9215a7327b7c0a08765e919f10b2bc4c27790 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 00:20:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(03-02):=20tablo=20handlers=20+=20router=20?= =?UTF-8?q?wiring=20=E2=80=94=20list/new/create=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement TablosListHandler, TablosNewHandler, TablosCreateHandler in handlers_tablos.go replacing the Plan 01 stub - TablosCreateHandler: reads via r.PostFormValue, validates title (required, <=255), inserts with pgtype.Text nullable params, sends HX-Retarget + HX-Reswap on HTMX success, 303 redirect on non-HTMX success - router.go: replace r.Get("/", IndexHandler()) with TablosListHandler; add GET /tablos/new and POST /tablos (static before parametric — Pitfall 1) - handlers.go: remove IndexHandler + unused auth/csrf imports - index.templ: reduced to bare package declaration (dashboard moved to tablos.templ) - index_templ.go: deleted (empty templ file generates broken import) - TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation: PASS - TestSignup, TestLogin, TestLogout, TestCSRF: still PASS (no regression) --- backend/internal/web/handlers.go | 17 --- backend/internal/web/handlers_tablos.go | 147 +++++++++++++++++++++++- backend/internal/web/router.go | 6 +- backend/templates/index.templ | 50 +------- 4 files changed, 153 insertions(+), 67 deletions(-) diff --git a/backend/internal/web/handlers.go b/backend/internal/web/handlers.go index 500acdd..bad9dca 100644 --- a/backend/internal/web/handlers.go +++ b/backend/internal/web/handlers.go @@ -6,10 +6,7 @@ import ( "net/http" "time" - "backend/internal/auth" "backend/templates" - - "github.com/gorilla/csrf" ) // HealthzHandler returns an HTTP handler that probes the supplied Pinger @@ -37,20 +34,6 @@ func HealthzHandler(pinger Pinger) http.HandlerFunc { } } -// IndexHandler renders the root page (templates.Index) as text/html. -// The authenticated user is pulled from the request context (set by -// auth.ResolveSession) and passed to the template so the layout header can -// render the logout button and email. -// csrf.Token(r) is threaded into the template so the logout form includes the -// hidden _csrf field (AUTH-06, D-14). -func IndexHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, user, _ := auth.Authed(r.Context()) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.Index(user, csrf.Token(r)).Render(r.Context(), w) - } -} - // DemoTimeHandler renders the HTMX fragment for /demo/time. The `now` clock // is injected so tests can substitute a deterministic time source; production // passes time.Now. diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 81f3b01..bfbacfe 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -1,10 +1,153 @@ package web -import "backend/internal/db/sqlc" +import ( + "log/slog" + "net/http" + "strings" + + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5/pgtype" +) // TablosDeps holds dependencies for all tablo handlers. // Introduced in Plan 01 as a stub to allow handlers_tablos_test.go to compile. -// Plans 02 and 03 add the actual handler implementations. +// 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." + } + + if errs.Title != "" { + 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) + } +} + +// 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, err := deps.Queries.ListTablosByUser(r.Context(), user.ID) + if err != nil { + 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) +} diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index fa35202..0953811 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -71,10 +71,14 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD // Protected routes — require an authenticated session (D-23, AUTH-05). // RequireAuth checks the context set by ResolveSession above and redirects // unauthenticated requests to /login (HTMX: HX-Redirect, plain: 303). + // Route ordering: static segments (/tablos/new) declared BEFORE parametric + // (/tablos/{id}) so chi v5 resolves them correctly (Pitfall 1). r.Group(func(r chi.Router) { r.Use(auth.RequireAuth) - r.Get("/", IndexHandler()) + r.Get("/", TablosListHandler(tabloDeps)) r.Post("/logout", LogoutHandler(deps)) + r.Get("/tablos/new", TablosNewHandler(tabloDeps)) + r.Post("/tablos", TablosCreateHandler(tabloDeps)) }) r.Get("/healthz", HealthzHandler(pinger)) diff --git a/backend/templates/index.templ b/backend/templates/index.templ index 535ead9..add30c5 100644 --- a/backend/templates/index.templ +++ b/backend/templates/index.templ @@ -1,48 +1,4 @@ +// Package templates — index.templ retired in Phase 3. +// GET / is now served by TablosListHandler + TablosDashboard (tablos.templ). +// DemoTimeHandler (handlers.go) still uses TimeFragment from time.templ. package templates - -import ( - "backend/internal/auth" - "backend/internal/web/ui" -) - -// Index renders the root page (protected, requires auth). -// The user parameter is the authenticated user from request context, passed -// through to Layout so the header can render the logout button and email. -// csrfToken is passed to Layout so the logout form can include the hidden -// _csrf field (AUTH-06, D-14). -templ Index(user *auth.User, csrfToken string) { - @Layout("Xtablo", user, csrfToken) { -

Signed in as { user.Email }

-

Xtablo

-

- Go + HTMX foundation. The Tablos workflow ships in later phases. -

-
- @ui.Card(nil) { -

HTMX demo

-

- Click the button to fetch the server time as an HTML fragment. -

-
- @ui.Button(ui.ButtonProps{ - Label: "Fetch server time", - Variant: ui.ButtonVariantDefault, - Tone: ui.ButtonToneSolid, - Size: ui.SizeMD, - Type: "button", - Attrs: templ.Attributes{ - "hx-get": "/demo/time", - "hx-target": "#demo-out", - "hx-swap": "innerHTML", - "hx-indicator": "#demo-spinner", - }, - }) - Loading… -
-
- No time fetched yet. -
- } -
- } -}