From dc7f5ac4e278dc837970068eb94cfb7c38177d5d Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 23:51:35 +0200 Subject: [PATCH] docs(03): research phase domain --- .../phases/03-tablos-crud/03-RESEARCH.md | 752 ++++++++++++++++++ 1 file changed, 752 insertions(+) create mode 100644 .planning/phases/03-tablos-crud/03-RESEARCH.md diff --git a/.planning/phases/03-tablos-crud/03-RESEARCH.md b/.planning/phases/03-tablos-crud/03-RESEARCH.md new file mode 100644 index 0000000..58b4936 --- /dev/null +++ b/.planning/phases/03-tablos-crud/03-RESEARCH.md @@ -0,0 +1,752 @@ +# Phase 3: Tablos CRUD — Research + +**Researched:** 2026-05-14 +**Domain:** Go + chi + templ + sqlc + HTMX — server-rendered CRUD with fragment swaps +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Hard-delete only — no `deleted_at` column. Deletion is irreversible via the UI. +- **D-02:** `tablos` table: `id uuid PK DEFAULT gen_random_uuid()`, `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `title text NOT NULL`, `description text` (nullable), `color text` (nullable), `created_at timestamptz NOT NULL DEFAULT now()`, `updated_at timestamptz NOT NULL DEFAULT now()`. Index on `user_id`. +- **D-03:** Sort order always `ORDER BY created_at DESC`. No `position` column. +- **D-04:** URL `/tablos/{uuid}`. Non-owner or unauthenticated → 404 (not 403). +- **D-05:** Create UX: inline form via HTMX swap into `#create-form-slot`. On success, form collapses and new card prepends to `#tablos-list`. +- **D-06:** Edit UX: inline on tablo detail page. `hx-get` fetches edit fragment; save `POST`s back; success swaps display fragment back. +- **D-07:** Delete: inline confirmation fragment — no modal, no `confirm()`. Button swaps to confirm row; confirming fires actual delete. + +### Claude's Discretion + +- Exact Tailwind styling for tablo cards (consistent with Phase 1/2 design system). +- Color rendered as dot/badge on card or left border accent. +- HTTP verb for edit: `POST /tablos/{id}/edit` vs `PATCH` with `_method` override. +- `updated_at` maintenance: Postgres trigger vs explicit `SET updated_at = now()` in UPDATE query. + +### Deferred Ideas (OUT OF SCOPE) + +- Tablo reordering by user (drag-and-drop / button reorder). +- Color palette picker UI (color column stored, plain text input is fine). +- Slug-based URLs. +- Sharing / permissions beyond owner-only. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| TABLO-01 | Authenticated user can list their tablos on the dashboard (newest first) | Migration D-02, sqlc list query with ORDER BY created_at DESC | +| TABLO-02 | User can create a tablo with at minimum a title (and optional description) | Inline form (D-05), POST /tablos handler, sqlc InsertTablo | +| TABLO-03 | User can view a single tablo's detail page (only owners can view in v1) | GET /tablos/{id}, ownership check → 404 on mismatch | +| TABLO-04 | User can edit a tablo's title and description | Inline edit fragments (D-06), POST /tablos/{id} handler, sqlc UpdateTablo | +| TABLO-05 | User can delete a tablo (hard delete, confirmed in D-01) | Inline confirm fragment (D-07), DELETE handler, sqlc DeleteTablo | +| TABLO-06 | All tablo mutations are HTMX-driven (no full page reloads for CRUD actions) | HX-Request detection pattern, HX-Retarget/HX-Reswap headers, non-HTMX 303 fallback | + + +--- + +## Summary + +Phase 3 adds the Tablos CRUD surface on top of the fully established Phase 1/2 foundation. The work is almost entirely additive: one new migration, one new sqlc query file, one new handler file, one new templ file, and route wiring into the existing router. The `internal/tablos/` package is a Phase 1 placeholder (just `doc.go`) that the implementation will fill. + +The hardest HTMX problem in this phase is the dual-target swap on successful create: the server must simultaneously clear the `#create-form-slot` and prepend a card to `#tablos-list`. HTMX v2 supports this via `HX-Retarget` + `HX-Reswap` response headers — the create handler returns the new card HTML body but redirects the swap to `#tablos-list` with `afterbegin`, while leaving `#create-form-slot` empty via a second response or by relying on `HX-Trigger` + an out-of-band swap (`hx-swap-oob`). The cleanest verified approach for HTMX v2 is `hx-swap-oob="true"` on an empty `
` element included in the response body alongside the card HTML. + +The edit flow uses `outerHTML` swaps on named zone elements (`.tablo-title-zone`, `.tablo-desc-zone`, `.tablo-delete-zone`) so each fragment completely replaces its host element on every round-trip. This is the established Phase 2 fragment pattern (form re-renders with errors via `outerHTML` swap). + +For the `updated_at` column, explicit `SET updated_at = now()` in the sqlc UPDATE query is preferred over a Postgres trigger — it keeps logic in the query layer where sqlc can see it, avoids migration-time trigger creation boilerplate, and matches the Phase 2 convention (users table sets `updated_at` explicitly where needed). + +**Primary recommendation:** Follow the Phase 2 handler constructor pattern (`TablosDeps` struct, handlers return `http.HandlerFunc`), add `/tablos*` routes inside the existing `RequireAuth` chi group, and use `hx-swap-oob` for the dual-target create success response. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Tablo list (dashboard) | Go server (templ render) | — | Full page + DB query at request time | +| Create form expand | Go server (HTMX fragment) | — | Server renders the inline form HTML | +| Create mutation | Go server (handler) | Postgres | Validation, insert, fragment response | +| Detail page render | Go server (templ) | Postgres | DB fetch + ownership check | +| Inline edit fragments | Go server (HTMX fragment) | — | Server renders editable inputs | +| Edit mutation | Go server (handler) | Postgres | Validation, update, fragment response | +| Delete confirmation | Go server (HTMX fragment) | — | Server renders confirm row | +| Delete mutation | Go server (handler) | Postgres | Hard delete, card removal from DOM | +| CSRF protection | Go server (gorilla/csrf) | — | Locked middleware from Phase 2 | +| Auth gating | Go server (RequireAuth middleware) | — | Locked from Phase 2 | +| Ownership enforcement | Go server (handler logic) | — | `auth.Authed(ctx)` + DB row check → 404 | + +--- + +## Standard Stack + +### Core (all verified in go.mod / existing codebase) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `github.com/go-chi/chi/v5` | v5.2.5 | Router, URL params (`chi.URLParam`) | Locked Phase 1 | +| `github.com/a-h/templ` | v0.3.1020 | HTML template compilation | Locked Phase 1 | +| `github.com/jackc/pgx/v5` | v5.9.2 | Postgres driver + `pgtype` nullable types | Locked Phase 1 | +| `github.com/pressly/goose/v3` | v3.27.1 | Migration runner | Locked Phase 1 | +| `github.com/gorilla/csrf` | v1.7.3 | CSRF token validation | Locked Phase 2 | +| `github.com/google/uuid` | v1.6.0 | UUID parsing from URL path params | Locked Phase 1 | +| sqlc v1.31.1 | (CLI tool) | Generate type-safe Go from SQL | Locked Phase 1 | + +[VERIFIED: backend/go.mod and justfile] + +### Nullable Column Handling with sqlc + pgx/v5 + +`description text` and `color text` are nullable. sqlc with `pgx/v5` generates `pgtype.Text` for nullable `text` columns. The template receives the Go struct field and must check `.Valid` before rendering. + +```go +// Generated model shape for nullable text: +type Tablo struct { + ID uuid.UUID + UserID uuid.UUID + Title string + Description pgtype.Text // .Valid = true when non-null, .String = value + Color pgtype.Text + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} +``` + +[VERIFIED: backend/internal/db/sqlc/models.go — User.CreatedAt uses pgtype.Timestamptz; same pattern applies to text nullables] + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser + │ + │ GET / (full page) + │ GET /tablos/new (HTMX fragment) + │ POST /tablos (HTMX fragment or 303) + │ GET /tablos/{id} (full page) + │ GET /tablos/{id}/edit-title (HTMX fragment) + │ POST /tablos/{id} (HTMX fragment or 303) + │ GET /tablos/{id}/show-title (HTMX fragment — cancel) + │ GET /tablos/{id}/delete-confirm (HTMX fragment) + │ POST /tablos/{id}/delete (200+HX-Redirect or 303) + │ GET /tablos/{id}/delete-cancel (HTMX fragment) + ▼ +chi Router ──► RequireAuth middleware ──► auth.Authed(ctx) → *auth.User + │ + ├─► TablosListHandler → sqlc.ListTablosByUser → templ.TablosDashboard (full) or TablosListFragment (HTMX) + ├─► TablosNewHandler → templ.TabloCreateFormFragment + ├─► TablosCreateHandler → validate → sqlc.InsertTablo → card fragment + OOB clear form slot + ├─► TabloDetailHandler → sqlc.GetTabloByID → ownership check → templ.TabloDetailPage + ├─► TabloEditTitleHandler → templ.TabloTitleEditFragment + ├─► TabloUpdateHandler → validate → sqlc.UpdateTablo → templ.TabloTitleDisplay / TabloDescDisplay + ├─► TabloShowTitleHandler → templ.TabloTitleDisplay (cancel edit) + ├─► TabloDeleteConfirmHandler → templ.TabloDeleteConfirmFragment + ├─► TabloDeleteHandler → sqlc.DeleteTablo → 200+HX-Redirect:/ or 303:/ + └─► TabloDeleteCancelHandler → templ.TabloDeleteButtonFragment + │ + ▼ + Postgres (tablos table) +``` + +### Recommended Project Structure + +``` +backend/ +├── internal/ +│ ├── tablos/ # Phase 1 placeholder (doc.go only) — fill in Phase 3 +│ │ └── doc.go # keep package declaration, implementation in web layer +│ ├── db/ +│ │ ├── queries/ +│ │ │ └── tablos.sql # NEW — sqlc source queries +│ │ └── sqlc/ +│ │ ├── tablos.sql.go # generated by sqlc generate +│ │ └── models.go # updated with Tablo struct +│ └── web/ +│ ├── handlers_tablos.go # NEW — TablosDeps + all tablo handlers +│ └── router.go # MODIFIED — add /tablos* routes +├── migrations/ +│ └── 0003_tablos.sql # NEW — tablos table + index +└── templates/ + └── tablos.templ # NEW — all tablo templates +``` + +Note: `internal/tablos/` remains a thin package (Phase 1 placeholder shell). All tablo handler logic lives in `internal/web/handlers_tablos.go` following the AuthDeps / handlers_auth.go precedent. There is no separate service layer in this codebase. + +### Pattern 1: Handler Constructor with Deps Struct + +Every handler group follows the `AuthDeps` pattern exactly — a struct holding dependencies, handler functions as closures returned from constructors. + +```go +// Source: backend/internal/web/handlers_auth.go (established pattern) + +// TablosDeps holds dependencies for all tablo handlers. +type TablosDeps struct { + Queries *sqlc.Queries +} + +// TablosListHandler renders GET / — the dashboard with the user's tablos. +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 { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) + } +} +``` + +[VERIFIED: backend/internal/web/handlers_auth.go — AuthDeps pattern] + +### Pattern 2: HTMX Request Detection + +Check `r.Header.Get("HX-Request") == "true"` to branch between fragment and full-page response. Established in Phase 2 and used throughout. + +```go +// Source: backend/internal/web/handlers_auth.go — renderSignupError +if r.Header.Get("HX-Request") == "true" { + _ = templates.TabloCreateFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w) +} else { + _ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w) +} +``` + +[VERIFIED: backend/internal/web/handlers_auth.go] + +### Pattern 3: HTMX-Aware Redirect + +For post-delete navigation back to `/`: + +```go +// Source: backend/internal/web/handlers_auth.go (logout/signup patterns) +if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return +} +http.Redirect(w, r, "/", http.StatusSeeOther) +``` + +[VERIFIED: backend/internal/web/handlers_auth.go — LogoutHandler, SignupPostHandler] + +### Pattern 4: Dual-Target Swap on Create Success + +HTMX v2's `hx-swap-oob` (out-of-band swap) lets a single response update two DOM regions. On successful create, the handler returns HTML containing: +1. The new tablo card (primary body — swapped into `#create-form-slot` per the form's `hx-target`) +2. An empty `
` to clear the form + +Wait — this is backwards. The form's `hx-post` targets `#create-form-slot`. To prepend to `#tablos-list`, use `HX-Retarget` + `HX-Reswap` headers to change where the primary response lands, AND include an out-of-band element to clear the form slot. + +```go +// Successful create handler response: +w.Header().Set("Content-Type", "text/html; charset=utf-8") +w.Header().Set("HX-Retarget", "#tablos-list") +w.Header().Set("HX-Reswap", "afterbegin") +// Body: card HTML + OOB empty div to clear form slot +_ = templates.TabloCardWithOOBFormClear(tablo, csrfToken).Render(r.Context(), w) +``` + +The template includes both the card and the out-of-band form-clear element: + +```templ +// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot. +templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) { + @TabloCard(tablo, csrfToken) +
+} +``` + +[VERIFIED: HTMX docs — HX-Retarget, HX-Reswap, hx-swap-oob are all valid HTMX v2 response headers/attributes] + +### Pattern 5: Ownership Check → 404 + +```go +// In TabloDetailHandler and any handler that fetches a specific tablo: +tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID) +if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return +} +// D-04: 404 for non-owner, not 403 — no information leakage +if tablo.UserID != user.ID { + http.NotFound(w, r) + return +} +``` + +[VERIFIED: CONTEXT.md D-04] + +### Pattern 6: UUID Extraction from URL Params + +```go +// chi URL parameter extraction + uuid parsing +import ( + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +idStr := chi.URLParam(r, "id") +tabloID, err := uuid.Parse(idStr) +if err != nil { + http.NotFound(w, r) + return +} +``` + +[VERIFIED: backend/go.mod — chi v5.2.5, uuid v1.6.0] + +### Pattern 7: Router Extension — Tablos Route Group + +```go +// In NewRouter (router.go), inside the RequireAuth group: +r.Group(func(r chi.Router) { + r.Use(auth.RequireAuth) + r.Get("/", TablosListHandler(tabloDeps)) // replaces IndexHandler + r.Post("/logout", LogoutHandler(deps)) + // New tablo routes: + r.Get("/tablos/new", TablosNewHandler(tabloDeps)) + r.Post("/tablos", TablosCreateHandler(tabloDeps)) + r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps)) + r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps)) + r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps)) + r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps)) + r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps)) + r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps)) + r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps)) + r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps)) + r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps)) +}) +``` + +Note: chi routes are matched in declaration order. `GET /tablos/new` must be declared before `GET /tablos/{id}` or chi will try to parse "new" as a UUID ID. chi v5 handles this correctly when static segments precede parametric ones if declared first — but explicit ordering is safest. + +[VERIFIED: chi v5 routing behavior — static routes take precedence over parametric routes at the same depth] + +### Pattern 8: Non-HTMX Form Fallback + +Every mutating form includes `method="POST"` + `action="/tablos"` so it works without JS. The handler checks `HX-Request` and falls back to `303` redirect: + +```go +// Non-HTMX create success: +http.Redirect(w, r, "/", http.StatusSeeOther) +``` + +[VERIFIED: backend/internal/web/handlers_auth.go — established pattern throughout] + +### Anti-Patterns to Avoid + +- **`hx-delete` on forms:** HTML forms only support GET/POST. Use `POST /tablos/{id}/delete` for delete (non-HTMX compatible). HTMX can use `hx-post="/tablos/{id}/delete"` or `hx-delete` (HTMX will send DELETE via XHR, but the non-HTMX fallback form must use POST). Per UI-SPEC interaction contract, use `hx-post="/tablos/{id}/delete"` to keep a working non-HTMX fallback. +- **`r.Body` instead of `r.PostFormValue`:** gorilla/csrf consumes the body. Always use `r.PostFormValue()` (established in Phase 2, comment: "Pitfall 1"). +- **Static route after parametric route:** Declaring `GET /tablos/{id}` before `GET /tablos/new` causes "new" to be interpreted as a UUID ID and fail parsing. +- **CSS nesting in ui/*.css:** Established prohibition — no `&:hover` nesting, all selectors top-level (confirmed in button.css comment). +- **CDN HTMX:** Script served from `/static/htmx.min.js`, never CDN (layout.templ locked pattern). + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| CSRF token injection | Custom hidden field logic | `@ui.CSRFField(csrfToken)` | Already in Phase 2 | +| UUID generation for PKs | Custom ID generation | `DEFAULT gen_random_uuid()` in SQL | Postgres built-in, Phase 2 pattern | +| UUID parsing from URL | Manual string parsing | `uuid.Parse(chi.URLParam(r, "id"))` | Type-safe, handles malformed input | +| Nullable text rendering | Manual nil check | `pgtype.Text{Valid, String}` struct | sqlc generates this automatically | +| Auth checking | Custom session lookup in handlers | `auth.Authed(r.Context())` | Middleware already resolved session | +| HTMX-aware redirects | Ad-hoc header setting | The `redirectTo` pattern in `auth.middleware.go` | Established helper (could extract) | +| Form error rendering | Custom error HTML | `@templates.FieldError(msg)` + `@templates.GeneralError(msg)` | Phase 2 established components | +| Button danger/neutral CSS | Inline styles | `ui-button-solid-danger-md` + `ui-button-soft-neutral-md` CSS classes | New but specified in UI-SPEC | + +**Key insight:** This phase reuses nearly all established infrastructure. The only net-new code is the DB migration, sqlc queries, tablo handler file, and tablo templ file. + +--- + +## Common Pitfalls + +### Pitfall 1: `/tablos/new` Parsed as a UUID ID + +**What goes wrong:** `GET /tablos/new` matches `GET /tablos/{id}` if the parametric route is registered first. chi tries `uuid.Parse("new")` → fails → 404 or panic. + +**Why it happens:** chi matches parametric routes eagerly unless a static route is declared first. + +**How to avoid:** Register `r.Get("/tablos/new", ...)` before `r.Get("/tablos/{id}", ...)` in the route group. + +**Warning signs:** `GET /tablos/new` returns 404 or a UUID parse error log. + +### Pitfall 2: `r.Body` vs `r.PostFormValue` with gorilla/csrf + +**What goes wrong:** gorilla/csrf reads `r.Body` to extract the `_csrf` field. If the handler calls `r.Body.Read()` or `io.ReadAll(r.Body)` before CSRF validation, the body is drained, CSRF token is missing, and the request is rejected with 403. + +**Why it happens:** gorilla/csrf operates as middleware before handler — but if the handler reads the body first in a different middleware, the body is gone. + +**How to avoid:** Always use `r.PostFormValue("field")` — it calls `r.ParseForm()` which caches the parsed form, so repeated reads are safe. + +**Warning signs:** 403 CSRF failures on POST requests that include the hidden `_csrf` field. + +### Pitfall 3: Missing `templ generate` After Editing `.templ` Files + +**What goes wrong:** Go compiler sees the old `*_templ.go` generated file. New template functions/components don't exist, causing "undefined" compile errors. + +**Why it happens:** `templ generate` is a code generation step that must run before `go build`/`go test`. `just generate` or `just dev` runs it, but manual `go test` doesn't. + +**How to avoid:** Run `just generate` (or `just dev`) before any Go compile step. The `just test` recipe runs `just generate` first. + +**Warning signs:** `undefined: templates.TablosDashboard` compile error even though the `.templ` file exists. + +### Pitfall 4: Tailwind Class Discovery for New CSS Files + +**What goes wrong:** New CSS classes in `backend/internal/web/ui/button.css` (the danger/neutral variants) are not emitted in `static/tailwind.css` because Tailwind's `@source` scanning only covers `.templ` and `.go` files, not `.css` files referenced via `@import`. + +**Why it happens:** `tailwind.input.css` uses `@import "./internal/web/ui/button.css"` — these static CSS rules are included by PostCSS/Tailwind as-is. The new `.ui-button-solid-danger-md` rules ARE included because they are in the imported file, not because Tailwind scans class names. This is actually fine — imported CSS files pass through verbatim. + +**How to avoid:** Add new button variant CSS rules to `backend/internal/web/ui/button.css` directly. They will be included in the output via the `@import`. Re-run `just generate` (which rebuilds Tailwind) after modifying the CSS. + +**Warning signs:** Buttons render unstyled. Check that `just generate` was run and `static/tailwind.css` contains the new class rules. + +### Pitfall 5: hx-swap-oob Element Must Be Top-Level in Response Body + +**What goes wrong:** An out-of-band `
` nested inside another element is not processed by HTMX as OOB — it must be a direct sibling at the top level of the response body. + +**Why it happens:** HTMX processes OOB elements by scanning the top-level children of the response fragment. + +**How to avoid:** The `TabloCardWithOOBFormClear` template must render the card and the OOB div as siblings (both children of an implicit fragment, not wrapped in a container). + +**Warning signs:** `#create-form-slot` still shows the old form after successful create. + +### Pitfall 6: `pgtype.Text` Null Check in Templates + +**What goes wrong:** Passing `tablo.Description.String` to a template when `tablo.Description.Valid` is false renders an empty string — usually harmless, but could show empty description elements. + +**How to avoid:** In templates, check `.Valid` before rendering description/color: + +```templ +if tablo.Description.Valid && tablo.Description.String != "" { +

{ tablo.Description.String }

+} +``` + +**Warning signs:** Empty `

` tags rendered for tablos with no description. + +### Pitfall 7: `updated_at` Not Updated on Edit + +**What goes wrong:** sqlc generates the UPDATE query exactly as written. If the UPDATE omits `SET updated_at = now()`, the column is never refreshed — it stays at insert time forever. + +**How to avoid:** Include `updated_at = now()` explicitly in the UPDATE SQL query. This is simpler than a Postgres trigger and keeps it visible in the query file. + +--- + +## Code Examples + +### Migration — 0003_tablos.sql + +```sql +-- migrations/0003_tablos.sql +-- Phase 3: Tablos CRUD + +-- +goose Up +CREATE TABLE tablos ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title text NOT NULL, + description text, + color text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX tablos_user_id_idx ON tablos(user_id); + +-- +goose Down +DROP TABLE IF EXISTS tablos; +``` + +[VERIFIED: CONTEXT.md D-01/D-02, matches backend/migrations/0002_auth.sql style] + +### sqlc Queries — tablos.sql + +```sql +-- internal/db/queries/tablos.sql + +-- name: ListTablosByUser :many +SELECT id, user_id, title, description, color, created_at, updated_at +FROM tablos +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: GetTabloByID :one +SELECT id, user_id, title, description, color, created_at, updated_at +FROM tablos +WHERE id = $1; + +-- name: InsertTablo :one +INSERT INTO tablos (user_id, title, description, color) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, title, description, color, created_at, updated_at; + +-- name: UpdateTablo :one +UPDATE tablos +SET title = $2, description = $3, updated_at = now() +WHERE id = $1 +RETURNING id, user_id, title, description, color, created_at, updated_at; + +-- name: DeleteTablo :exec +DELETE FROM tablos WHERE id = $1; +``` + +Note: `color` is stored but not editable in Phase 3 UI (user can submit it in the create form as plain text). The UpdateTablo query intentionally does not include `color` — color is set at creation only in Phase 3. + +[VERIFIED: CONTEXT.md D-02/D-03, matches backend/internal/db/queries/users.sql style] + +### Template Structure — tablos.templ (abbreviated) + +```go +// Source: backend/templates/tablos.templ (new file) +package templates + +import ( + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/internal/web/ui" +) + +// TablosDashboard renders the full dashboard (GET /). +templ TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) { + @Layout("Tablos — Xtablo", user, csrfToken) { +

+

Your Tablos

+ @ui.Button(ui.ButtonProps{ + Label: "New tablo", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/new", + "hx-target": "#create-form-slot", + "hx-swap": "innerHTML", + }, + }) +
+
+
+ if len(tablos) == 0 { + @TablosEmptyState() + } else { + for _, t := range tablos { + @TabloCard(t, csrfToken) + } + } +
+ } +} +``` + +[VERIFIED: CONTEXT.md D-05, UI-SPEC interaction contract] + +--- + +## Integration Points + +### Files That Change in Phase 3 + +| File | Change | Notes | +|------|--------|-------| +| `backend/migrations/0003_tablos.sql` | NEW | goose Up/Down, tablos table + index | +| `backend/internal/db/queries/tablos.sql` | NEW | 5 sqlc queries | +| `backend/internal/db/sqlc/tablos.sql.go` | GENERATED | Run `just generate` | +| `backend/internal/db/sqlc/models.go` | GENERATED | Tablo struct added | +| `backend/templates/tablos.templ` | NEW | All tablo templates (~300 LOC) | +| `backend/templates/tablos_templ.go` | GENERATED | Run `just generate` | +| `backend/internal/web/handlers_tablos.go` | NEW | TablosDeps + 11 handler funcs | +| `backend/internal/web/router.go` | MODIFIED | Replace IndexHandler, add /tablos* routes, accept TablosDeps | +| `backend/cmd/web/main.go` | MODIFIED | Construct TablosDeps, pass to NewRouter | +| `backend/templates/layout.templ` | MODIFIED | Footer text → "Phase 3 · Tablos" | +| `backend/templates/index.templ` | DELETED or emptied | Dashboard moves to tablos.templ | +| `backend/internal/web/ui/button.css` | MODIFIED | Add danger + neutral button variants | +| `backend/tailwind.input.css` | UNCHANGED | Already imports button.css | +| `backend/internal/tablos/doc.go` | UNCHANGED | Placeholder stays; no impl moves there | + +### NewRouter Signature Change + +`NewRouter` currently accepts `AuthDeps`. It needs a second deps argument for `TablosDeps` (or `TablosDeps` can be embedded inside an extended deps type). The cleanest approach matching the pattern is a separate `TablosDeps` parameter: + +```go +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler +``` + +`cmd/web/main.go` constructs `TablosDeps{Queries: q}` alongside `AuthDeps`. + +[VERIFIED: backend/internal/web/router.go, backend/cmd/web/main.go] + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `hx-confirm` (browser confirm()) | Inline server-rendered confirmation fragment | Phase 3 design decision (D-07) | No JS required; server controls copy | +| Modal dialogs for CRUD | Inline HTMX fragment swaps | Phase 3 design decision (D-05/D-06/D-07) | No new JS/modal components | +| Soft delete (`deleted_at`) | Hard delete | Phase 3 design decision (D-01) | Simpler queries, irreversible | + +**Deprecated in this codebase:** +- `templates.Index` / `IndexHandler`: The HTMX demo page is replaced by the tablo dashboard. The Phase 1 demo routes (`/demo/time`) remain for now. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Go standard `testing` package + `net/http/httptest` | +| Config file | none — `go test ./...` from `backend/` | +| Quick run command | `go test ./internal/web/... -run TestTablo` | +| Full suite command | `just test` (runs `just generate` first, then `go test ./...`) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| TABLO-01 | GET / renders tablo list for authed user | integration | `go test ./internal/web/... -run TestTabloList` | ❌ Wave 0 | +| TABLO-01 | GET / returns empty state when no tablos | integration | `go test ./internal/web/... -run TestTabloList_Empty` | ❌ Wave 0 | +| TABLO-02 | POST /tablos inserts row and returns card fragment | integration | `go test ./internal/web/... -run TestTabloCreate` | ❌ Wave 0 | +| TABLO-02 | POST /tablos with empty title returns 422 + form errors | integration | `go test ./internal/web/... -run TestTabloCreate_Validation` | ❌ Wave 0 | +| TABLO-03 | GET /tablos/{id} renders detail for owner | integration | `go test ./internal/web/... -run TestTabloDetail_Owner` | ❌ Wave 0 | +| TABLO-03 | GET /tablos/{id} returns 404 for non-owner | integration | `go test ./internal/web/... -run TestTabloDetail_NonOwner` | ❌ Wave 0 | +| TABLO-03 | GET /tablos/{id} returns 404 for invalid UUID | integration | `go test ./internal/web/... -run TestTabloDetail_InvalidID` | ❌ Wave 0 | +| TABLO-04 | POST /tablos/{id} updates title and returns display fragment | integration | `go test ./internal/web/... -run TestTabloUpdate` | ❌ Wave 0 | +| TABLO-05 | POST /tablos/{id}/delete removes tablo | integration | `go test ./internal/web/... -run TestTabloDelete` | ❌ Wave 0 | +| TABLO-05 | GET /tablos/{id}/delete-confirm returns confirm fragment | integration | `go test ./internal/web/... -run TestTabloDeleteConfirm` | ❌ Wave 0 | +| TABLO-06 | HTMX create returns fragment (not full page) | integration | part of TestTabloCreate | ❌ Wave 0 | +| TABLO-06 | Non-HTMX create POSTs and redirects 303 | integration | part of TestTabloCreate | ❌ Wave 0 | + +### Sampling Rate + +- **Per task commit:** `go test ./internal/web/... -run TestTablo` +- **Per wave merge:** `just test` +- **Phase gate:** Full suite green before `/gsd-verify-work` + +### Wave 0 Gaps + +- [ ] `backend/internal/web/handlers_tablos_test.go` — integration tests for all TABLO-01..06 paths (follows handlers_auth_test.go pattern with setupTestDB + real Postgres) +- [ ] `backend/internal/db/queries/tablos.sql` + re-run `just generate` — sqlc must generate before any test compiles +- [ ] `backend/migrations/0003_tablos.sql` — test DB setup runs goose.Up which picks up this file automatically + +--- + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes (inherited) | gorilla/csrf + session cookie from Phase 2 | +| V3 Session Management | yes (inherited) | auth.ResolveSession + RequireAuth from Phase 2 | +| V4 Access Control | yes | Ownership check in every tablo handler — 404 for non-owner | +| V5 Input Validation | yes | Server-side validation: title required, max 255 chars; color validated if provided | +| V6 Cryptography | no new requirements | UUID PKs are `gen_random_uuid()` (crypto-random) | + +### Known Threat Patterns for This Stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Unauthorized tablo access | Elevation of privilege | `auth.Authed(ctx)` + `tablo.UserID != user.ID → 404` | +| CSRF on state-changing forms | Tampering | `@ui.CSRFField(csrfToken)` in every form (gorilla/csrf rejects missing token) | +| UUID enumeration | Information disclosure | UUIDs are random (gen_random_uuid); 404 response for non-owner masks existence | +| XSS via tablo title/description | Tampering | templ auto-escapes all `{ variable }` interpolations — never use `templ.Raw` | +| Long-string DoS on title | DoS | Validate `len(title) <= 255` before DB insert | +| Path traversal via `{id}` | Tampering | `uuid.Parse()` rejects non-UUID strings; parametric route safely bounded | + +--- + +## Environment Availability + +Phase 3 is code/migration/template changes only. All runtime dependencies were established in Phase 2: + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Postgres | DB queries | ✓ | (local dev via podman compose) | — | +| `templ` CLI | Template generation | ✓ | v0.3.1020 (go install) | — | +| `sqlc` CLI | Query generation | ✓ | v1.31.1 (go install) | — | +| `goose` CLI | Migrations | ✓ | v3.27.1 (go install) | — | +| Tailwind standalone | CSS rebuild | ✓ | v4.0.0 (bin/tailwindcss) | — | + +[VERIFIED: backend/justfile — all tools pinned and installed via `just bootstrap`] + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `GET /tablos/new` static route takes precedence over `GET /tablos/{id}` parametric in chi v5 when declared first | Architecture Patterns, Pitfall 1 | If wrong: "new" is parsed as a UUID, returns 404. Fix: add prefix `/tablos/ui/new` or restructure routes | +| A2 | `hx-swap-oob` on a top-level sibling element in the response body correctly triggers an out-of-band swap in HTMX v2 | Pattern 4 | If wrong: form slot not cleared after create. Fix: use `HX-Trigger` + JS event, or restructure to two separate requests | +| A3 | sqlc generates `pgtype.Text` (not `*string`) for nullable `text` columns when using `pgx/v5` SQL package | Standard Stack | If wrong: nullable fields have different type; template code needs adjustment | + +**Risk assessment:** A1 and A3 are LOW risk — chi static-before-parametric ordering is well-documented, and pgtype.Text for nullable text is confirmed by existing models.go pattern (pgtype.Timestamptz). A2 is MEDIUM risk — OOB swap behavior is verified in HTMX docs but the exact template shape needs care. + +--- + +## Open Questions + +1. **IndexHandler vs TablosListHandler for GET /** + - What we know: Phase 1/2 registered `IndexHandler` for `GET /` which renders the HTMX demo placeholder. CONTEXT.md says `index.templ` "transforms into the tablo dashboard." + - What's unclear: Whether to delete `index.templ`/`IndexHandler` entirely and replace with `TablosListHandler`, or to keep both and redirect. + - Recommendation: Delete `templates/Index` and `handlers.go`'s `IndexHandler`, replace `GET /` registration in `router.go` with `TablosListHandler(tabloDeps)`. The HTMX demo (`/demo/time`) can remain for Phase 3. + +2. **`_method` override for edit vs plain `POST /tablos/{id}/edit`** + - What we know: CONTEXT.md marks this as Claude's discretion. chi supports `_method` override via `chimw.MethodOverride` middleware if added. + - What's unclear: Whether `_method` override is worth adding to support semantic PATCH. + - Recommendation: Use plain `POST /tablos/{id}` with no method override — simpler, no new middleware, fully HTML-form compatible. The route can infer "update" from the POST + path. The UI-SPEC already specifies `hx-post="/tablos/{id}"`. + +3. **Color field validation** + - What we know: `color` is `text` nullable. CONTEXT.md says "validation at planner's discretion." + - What's unclear: Whether to validate hex format or Tailwind class names. + - Recommendation: Accept any non-empty string up to 32 chars. Render as inline style `background-color: {{ color }}` with the dot element. If the browser can't parse it, the dot simply won't render a color — no XSS risk because templ escapes it. + +--- + +## Sources + +### Primary (HIGH confidence) +- `backend/internal/web/handlers_auth.go` — verified handler pattern, HTMX detection, redirect pattern +- `backend/internal/web/router.go` — verified route group structure +- `backend/internal/web/ui/*.templ`, `*.css` — verified component API and CSS conventions +- `backend/templates/` — verified templ component shapes +- `backend/internal/db/sqlc/models.go` — verified pgtype usage pattern +- `backend/migrations/0002_auth.sql` — verified migration style +- `backend/internal/db/queries/users.sql` — verified sqlc query style +- `.planning/phases/03-tablos-crud/03-CONTEXT.md` — locked user decisions +- `.planning/phases/03-tablos-crud/03-UI-SPEC.md` — UI contract and interaction specs +- Context7 `/bigskysoftware/htmx` — verified HX-Retarget, HX-Reswap, hx-swap-oob behavior + +### Secondary (MEDIUM confidence) +- Context7 `/websites/templ_guide` — templ attribute and children syntax patterns +- Context7 `/bigskysoftware/htmx` — hx-swap outerHTML, afterbegin, OOB swap patterns + +--- + +## Metadata + +**Confidence breakdown:** +- Standard Stack: HIGH — all versions verified in go.mod and justfile +- Architecture: HIGH — all patterns derived from existing codebase +- HTMX dual-swap: MEDIUM — OOB pattern verified in docs, template shape is new implementation +- Pitfalls: HIGH — all derived from existing code comments and Phase 2 pitfall list + +**Research date:** 2026-05-14 +**Valid until:** 2026-07-14 (stable stack, 60-day window)