docs(03): plan phase 3 — Tablos CRUD (3 plans, 3 waves)

Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration
+ sqlc + test scaffold + button CSS), list+create (dashboard, inline
form, OOB swap), and detail+edit+delete (ownership 404, inline edit
fragments, inline confirm delete). Includes Nyquist VALIDATION.md and
PATTERNS.md with real analog excerpts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-15 00:08:08 +02:00
parent dc7f5ac4e2
commit f53b54637b
No known key found for this signature in database
8 changed files with 1777 additions and 9 deletions

View file

@ -82,6 +82,12 @@ Plans:
**User-in-loop:** Approve the `tablos` table schema (ownership model, soft-delete vs hard-delete, slug strategy).
**Plans:** 3 plans
Plans:
- [ ] 03-01-PLAN.md — Wave 0: migration 0003_tablos + sqlc queries + handlers_tablos_test.go RED scaffold + button.css danger/neutral variants
- [ ] 03-02-PLAN.md — Vertical slice 1: dashboard list + inline-form create (HTMX OOB swap; TABLO-01, TABLO-02, TABLO-06)
- [ ] 03-03-PLAN.md — Vertical slice 2: detail + inline edit (title/description) + inline-confirmation delete (TABLO-03, TABLO-04, TABLO-05, TABLO-06)
### Phase 4: Tasks (Kanban)
**Goal:** Inside a tablo, a user can run a kanban board — create, edit, move, reorder, and delete tasks across columns.
**Mode:** mvp
@ -154,3 +160,4 @@ Plans:
---
*Roadmap created: 2026-05-14*
*Phase 3 plans added: 2026-05-15*

View file

@ -3,13 +3,13 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: in_progress
last_updated: "2026-05-14T21:36:00.262Z"
last_updated: "2026-05-15T00:00:00.000Z"
progress:
total_phases: 7
completed_phases: 2
total_plans: 11
total_plans: 14
completed_plans: 11
percent: 100
percent: 79
---
# STATE
@ -31,7 +31,7 @@ See: `.planning/PROJECT.md` (updated 2026-05-14)
|---|-------|--------|
| 1 | Foundation | ✓ Complete |
| 2 | Authentication | ✓ Complete — VERIFIED PASS (2026-05-14) |
| 3 | Tablos CRUD | ○ Pending |
| 3 | Tablos CRUD | ◆ Ready to execute — 3 plans (3 waves) |
| 4 | Tasks (Kanban) | ○ Pending |
| 5 | Files | ○ Pending |
| 6 | Background Worker | ○ Pending |
@ -39,7 +39,7 @@ See: `.planning/PROJECT.md` (updated 2026-05-14)
## Active Phase
**Phase 2: Authentication** — Verified PASS. All 7/7 plans complete, all AUTH-01..07 requirements satisfied. Phase 3: Tablos CRUD is next.
**Phase 3: Tablos CRUD** — Planned. 3 plans in 3 sequential waves. Ready to execute. Requirements: TABLO-01..06 covered. Planned 2026-05-15.
## Verification Record

View file

@ -0,0 +1,313 @@
---
phase: 03-tablos-crud
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/migrations/0003_tablos.sql
- backend/internal/db/queries/tablos.sql
- backend/internal/db/sqlc/tablos.sql.go
- backend/internal/db/sqlc/models.go
- backend/internal/web/handlers_tablos_test.go
- backend/internal/web/ui/button.css
autonomous: true
requirements:
- TABLO-01
- TABLO-02
- TABLO-03
- TABLO-04
- TABLO-05
- TABLO-06
tags:
- go
- postgres
- sqlc
- goose
- integration-tests
must_haves:
truths:
- "Migration 0003_tablos creates tablos table with all columns from D-02"
- "sqlc generates Tablo struct with pgtype.Text for nullable description and color"
- "handlers_tablos_test.go compiles and runs (all tests fail/skip because handlers do not exist yet)"
- "Button CSS gains .ui-button-solid-danger-md and .ui-button-soft-neutral-md rules"
artifacts:
- path: "backend/migrations/0003_tablos.sql"
provides: "tablos table + tablos_user_id_idx"
contains: "CREATE TABLE tablos"
- path: "backend/internal/db/queries/tablos.sql"
provides: "5 sqlc queries: ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo"
contains: "name: ListTablosByUser"
- path: "backend/internal/db/sqlc/tablos.sql.go"
provides: "Generated sqlc query bindings"
contains: "ListTablosByUser"
- path: "backend/internal/web/handlers_tablos_test.go"
provides: "Integration test scaffold for all TABLO-01..06 paths"
contains: "func TestTabloList"
- path: "backend/internal/web/ui/button.css"
provides: "Danger and neutral-soft button variants"
contains: "ui-button-solid-danger-md"
key_links:
- from: "backend/migrations/0003_tablos.sql"
to: "users(id)"
via: "REFERENCES users(id) ON DELETE CASCADE"
pattern: "REFERENCES users\\(id\\) ON DELETE CASCADE"
- from: "backend/internal/db/queries/tablos.sql"
to: "backend/internal/db/sqlc/tablos.sql.go"
via: "sqlc generate"
pattern: "ListTablosByUser"
---
<objective>
Lay the data + test foundation for Phase 3 Tablos CRUD: the goose migration, sqlc query file, generated bindings, and the Wave 0 integration test scaffold that downstream plans will turn green. Add the two new button CSS variants so subsequent template work can render the delete/cancel buttons without inline styles.
Purpose: Establish the schema, type-safe DB layer, and failing-test baseline before any handler code is written. Plans 02 and 03 turn this red into green.
Output:
- 0003_tablos.sql migration
- tablos.sql query file + generated tablos.sql.go + extended models.go
- handlers_tablos_test.go covering all TABLO-01..06 paths (RED, compiles, fails because handlers/routes do not yet exist)
- button.css with danger + neutral-soft variants
</objective>
## Phase Goal
The ROADMAP `**Goal:**` line is task-shaped ("A logged-in user can list, create, view, edit, and delete..."), not a user story. Recording derived user story for MVP_MODE; if mismatch matters, run `/gsd mvp-phase 03` to formalise.
**As a** signed-in Xtablo user, **I want to** create, view, edit, and delete my own tablos through HTMX-driven flows, **so that** I can organise my work without leaving the dashboard or seeing full-page reloads.
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-tablos-crud/03-CONTEXT.md
@.planning/phases/03-tablos-crud/03-RESEARCH.md
@.planning/phases/03-tablos-crud/03-PATTERNS.md
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
@.planning/phases/03-tablos-crud/03-VALIDATION.md
@backend/migrations/0002_auth.sql
@backend/internal/db/queries/users.sql
@backend/internal/db/sqlc/models.go
@backend/internal/web/handlers_auth_test.go
@backend/internal/web/testdb_test.go
@backend/internal/web/ui/button.css
@backend/internal/web/ui/variants.go
<interfaces>
<!-- Contracts that downstream plans (02, 03) will implement against. -->
Expected sqlc-generated Tablo struct (in backend/internal/db/sqlc/models.go):
- ID uuid.UUID
- UserID uuid.UUID
- Title string
- Description pgtype.Text (Valid + String)
- Color pgtype.Text
- CreatedAt pgtype.Timestamptz
- UpdatedAt pgtype.Timestamptz
Expected sqlc-generated Queries methods (backend/internal/db/sqlc/tablos.sql.go):
- ListTablosByUser(ctx, userID uuid.UUID) ([]Tablo, error)
- GetTabloByID(ctx, id uuid.UUID) (Tablo, error)
- InsertTablo(ctx, InsertTabloParams) (Tablo, error) -- params: UserID, Title, Description (pgtype.Text), Color (pgtype.Text)
- UpdateTablo(ctx, UpdateTabloParams) (Tablo, error) -- params: ID, Title, Description; sets updated_at = now()
- DeleteTablo(ctx, id uuid.UUID) error
Existing reusable infra (do NOT reimplement):
- backend/internal/web/testdb_test.go: setupTestDB(t) — boots a per-test schema using goose.Up against `backend/migrations`, returns *pgxpool.Pool. Phase 3 migrations are picked up automatically by goose.
- backend/internal/auth: RequireAuth middleware, Authed(ctx), Store
- backend/internal/web/handlers_auth_test.go: pattern for newAuthedRequest helper, cookie+session+csrf wiring
- backend/internal/web/ui/variants.go already declares ButtonVariantDanger, ButtonVariantNeutral, ButtonToneSoft — only the CSS classes are missing.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Migration 0003_tablos.sql + sqlc query file + regenerate sqlc</name>
<files>backend/migrations/0003_tablos.sql, backend/internal/db/queries/tablos.sql, backend/internal/db/sqlc/tablos.sql.go, backend/internal/db/sqlc/models.go</files>
<read_first>
backend/migrations/0002_auth.sql
backend/internal/db/queries/users.sql
backend/internal/db/sqlc/models.go
backend/internal/db/sqlc/users.sql.go
backend/sqlc.yaml
backend/justfile
.planning/phases/03-tablos-crud/03-CONTEXT.md (D-01, D-02, D-03)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Migration + sqlc Queries sections)
.planning/phases/03-tablos-crud/03-PATTERNS.md (analog migration + queries blocks)
</read_first>
<behavior>
- Migration up creates tablos table with columns: id uuid PK DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, title text NOT NULL, description text NULL, color text NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now().
- Index tablos_user_id_idx on tablos(user_id).
- Migration down: DROP TABLE IF EXISTS tablos.
- sqlc generates Tablo struct matching the interfaces block above (pgtype.Text for description/color, pgtype.Timestamptz for created_at/updated_at).
- UpdateTablo SQL must SET updated_at = now() explicitly (Pitfall 7).
</behavior>
<action>
Create backend/migrations/0003_tablos.sql following the 0002_auth.sql header style ("-- migrations/0003_tablos.sql\n-- Phase 3: Tablos CRUD") with goose Up/Down sections. Schema per D-02 (CONTEXT.md): id uuid PK 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); Down drops tablos.
Create backend/internal/db/queries/tablos.sql with exactly five named queries matching the RESEARCH.md "sqlc Queries" code block:
- ListTablosByUser :many (WHERE user_id = $1 ORDER BY created_at DESC)
- GetTabloByID :one (WHERE id = $1)
- InsertTablo :one (INSERT user_id,title,description,color RETURNING all columns)
- UpdateTablo :one (SET title=$2, description=$3, updated_at=now() WHERE id=$1 RETURNING all columns) — do NOT include color (per RESEARCH "color not editable in Phase 3")
- DeleteTablo :exec (DELETE FROM tablos WHERE id = $1)
Use explicit column lists in SELECTs (no SELECT *), positional $N parameters, and the same -- name: header style as users.sql.
Run `just generate` (or `just sqlc` if the justfile separates them) to produce backend/internal/db/sqlc/tablos.sql.go and update backend/internal/db/sqlc/models.go. Do not hand-edit the generated files.
</action>
<verify>
<automated>cd backend && just generate && grep -q "CREATE TABLE tablos" migrations/0003_tablos.sql && grep -q "tablos_user_id_idx" migrations/0003_tablos.sql && grep -q "name: ListTablosByUser" internal/db/queries/tablos.sql && grep -q "name: UpdateTablo" internal/db/queries/tablos.sql && grep -q "updated_at = now()" internal/db/queries/tablos.sql && grep -q "func (q \\*Queries) ListTablosByUser" internal/db/sqlc/tablos.sql.go && grep -q "type Tablo struct" internal/db/sqlc/models.go && go build ./...</automated>
</verify>
<acceptance_criteria>
- File backend/migrations/0003_tablos.sql exists and contains "CREATE TABLE tablos", "REFERENCES users(id) ON DELETE CASCADE", "tablos_user_id_idx", "-- +goose Up", "-- +goose Down".
- File backend/internal/db/queries/tablos.sql exists and contains exactly the 5 query names (ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo).
- tablos.sql contains "ORDER BY created_at DESC" (D-03) and "updated_at = now()" (Pitfall 7) in UpdateTablo.
- tablos.sql UpdateTablo does NOT include color (Phase 3 scope per RESEARCH §sqlc Queries note).
- backend/internal/db/sqlc/tablos.sql.go exists with exported methods ListTablosByUser, GetTabloByID, InsertTablo, UpdateTablo, DeleteTablo on *Queries.
- backend/internal/db/sqlc/models.go contains `type Tablo struct` with Description and Color fields typed as pgtype.Text.
- `go build ./...` from backend/ exits 0.
</acceptance_criteria>
<done>Migration committed, query file committed, sqlc-generated files committed, build green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: handlers_tablos_test.go — RED integration test scaffold for TABLO-01..06</name>
<files>backend/internal/web/handlers_tablos_test.go</files>
<read_first>
backend/internal/web/handlers_auth_test.go
backend/internal/web/testdb_test.go
backend/internal/web/router.go
backend/internal/web/csrf_test.go
backend/internal/db/sqlc/tablos.sql.go (from Task 1)
backend/internal/db/sqlc/models.go (Tablo struct shape)
.planning/phases/03-tablos-crud/03-VALIDATION.md (per-task verification map)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Phase Requirements → Test Map)
</read_first>
<behavior>
File must compile and `go test` must execute every named test. All tests are expected to FAIL or panic for now (handlers/routes do not exist) — that is the RED state Plans 02 and 03 will fix. Each test must exist as a real Go function (no t.Skip stubs) so the failing assertions document the contract.
Tests required (function names exact, per 03-VALIDATION.md):
- TestTabloList — authed GET / shows current user's tablos, newest-first; only the calling user's rows present.
- TestTabloList_Empty — authed GET / with no tablos renders empty-state copy "No tablos yet" + "Create your first tablo to get started.".
- TestTabloCreate — authed POST /tablos with HX-Request:true and valid title/description/color: returns 200, body contains the new title; HX-Retarget header == "#tablos-list", HX-Reswap header == "afterbegin"; response body contains an OOB element `id="create-form-slot"` with hx-swap-oob="true"; DB row exists with user_id = caller. Sub-asserts cover TABLO-06: non-HTMX POST returns 303 to "/".
- TestTabloCreate_Validation — POST /tablos with empty title returns 422; response body contains "Title is required.".
- TestTabloDetail_Owner — GET /tablos/{id} as owner returns 200 with title rendered.
- TestTabloDetail_NonOwner — GET /tablos/{id} as a different authed user returns 404 (not 403).
- TestTabloDetail_InvalidID — GET /tablos/not-a-uuid returns 404.
- TestTabloUpdate — authed POST /tablos/{id} with new title/description: returns 200, body contains updated title; DB row title/updated_at refreshed.
- TestTabloDeleteConfirm — GET /tablos/{id}/delete-confirm returns 200 with body containing "Delete tablo?" and "Yes, delete".
- TestTabloDelete — authed POST /tablos/{id}/delete: DB row deleted; HTMX request gets 200 + HX-Redirect:/ header (or HX-Retarget #tablos-list on dashboard delete); non-HTMX gets 303 to /.
</behavior>
<action>
Create backend/internal/web/handlers_tablos_test.go in package web. Reuse setupTestDB (from testdb_test.go) and the CSRF/cookie helpers exactly as handlers_auth_test.go does — use a duplicated helper if needed (per STATE.md decision: "setupTestDB duplicated into web package test file"). Drive requests through NewRouter so the full middleware chain (RequestID, ResolveSession, csrf.Protect, RequireAuth) runs.
For each test:
1. Call setupTestDB(t) to get *pgxpool.Pool. Build *sqlc.Queries.
2. Insert two test users via Queries.InsertUser (reusing handlers_auth_test.go pattern).
3. Build TablosDeps{Queries: q} and pass into web.NewRouter using the NEW signature: NewRouter(pinger, "./static", AuthDeps{...}, web.TablosDeps{Queries: q}, csrfKey, "test", "localhost"). Plans 02/03 must add TablosDeps to web package; if symbol does not yet exist when test compiles, expect a compile error — that is part of the RED state. To keep the test FILE compilable now while still being RED at runtime, declare a local stub: `var _ = web.TablosDeps{}` referencing the type Plan 02 introduces. If that prevents compilation, gate the test scaffold with the same TablosDeps stub introduced in Plan 02 Task 0 (interface skeleton).
4. Acquire a session by POSTing /signup with HX-Request:false (same flow as handlers_auth_test.go), capture cookie + CSRF token via subsequent GET /.
5. Issue the test's HTTP request via httptest.NewRecorder + router.ServeHTTP.
6. Assert status, headers (HX-Retarget, HX-Reswap, HX-Redirect), and body substrings per the behavior block.
7. Verify DB state by calling Queries.GetTabloByID / ListTablosByUser as appropriate.
Use t.Run subtests where one Test* name covers multiple assertions (e.g. TestTabloCreate covers HTMX-path + non-HTMX-path).
DO NOT implement handlers in this task — only the test scaffold. Tests are expected to fail until Plans 02 and 03 land.
</action>
<verify>
<automated>cd backend && just generate && go test ./internal/web/... -run TestTablo -count=1 2>&1 | tee /tmp/test_out.log; grep -E "TestTabloList|TestTabloCreate|TestTabloDetail|TestTabloUpdate|TestTabloDelete" /tmp/test_out.log | grep -cE "(FAIL|RUN)" | xargs -I{} test {} -ge 10 && echo "scaffold present"</automated>
</verify>
<acceptance_criteria>
- File backend/internal/web/handlers_tablos_test.go exists in package web.
- grep -c '^func Test' backend/internal/web/handlers_tablos_test.go returns >= 10 (covers the 10 named tests above).
- Each of these substrings appears in the file: "TestTabloList(", "TestTabloList_Empty(", "TestTabloCreate(", "TestTabloCreate_Validation(", "TestTabloDetail_Owner(", "TestTabloDetail_NonOwner(", "TestTabloDetail_InvalidID(", "TestTabloUpdate(", "TestTabloDeleteConfirm(", "TestTabloDelete(".
- File references HX-Retarget and HX-Reswap as expected response headers in TestTabloCreate.
- File references http.StatusSeeOther (303) and HX-Redirect for the post-delete navigation assertion.
- File contains an ownership-cross-user assertion (creates user A's tablo, requests it as user B, expects 404) in TestTabloDetail_NonOwner.
- Test file's intended state is RED — Plan 01 acceptance is that the file compiles after Plan 02 introduces the TablosDeps stub. To unblock Plan 01 alone, if web.TablosDeps does not yet exist, this task additionally creates a one-line stub `type TablosDeps struct { Queries *sqlc.Queries }` at the top of handlers_tablos.go (new empty file) so the test file compiles.
- `go vet ./internal/web/...` exits 0.
</acceptance_criteria>
<done>Test scaffold compiles; running `go test ./internal/web/... -run TestTablo` lists all 10 tests as FAIL (no implementation yet).</done>
</task>
<task type="auto">
<name>Task 3: Add ui-button-solid-danger-md and ui-button-soft-neutral-md CSS rules</name>
<files>backend/internal/web/ui/button.css</files>
<read_first>
backend/internal/web/ui/button.css
backend/internal/web/ui/variants.go
.planning/phases/03-tablos-crud/03-UI-SPEC.md (New Button Variants Required + Color sections)
.planning/phases/03-tablos-crud/03-PATTERNS.md (button.css analog block)
</read_first>
<action>
Append two new top-level rule sets to backend/internal/web/ui/button.css following the existing convention (NO CSS nesting — all pseudo-class selectors as separate top-level rules; class name format `.ui-button-{tone}-{variant}-{size}`).
Class `.ui-button-solid-danger-md`: background-color #b91c1c (red-700), color #ffffff, padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 600, display inline-flex, align-items center, min-height 44px (WCAG 2.5.5 per UI-SPEC). Hover rule: background-color #991b1b. Focus-visible rule: outline 2px solid #b91c1c, outline-offset 2px.
Class `.ui-button-soft-neutral-md`: background-color #f1f5f9 (slate-100), color #334155 (slate-700), padding 0.5rem 1rem, border-radius 0.375rem, font-size 1rem, font-weight 400, border 1px solid #e2e8f0, display inline-flex, align-items center, min-height 44px. Hover rule: background-color #e2e8f0. Focus-visible rule: outline 2px solid #64748b, outline-offset 2px.
Run `just generate` to rebuild static/tailwind.css (Pitfall 4: imported CSS passes through).
</action>
<verify>
<automated>cd backend && grep -c "^\\.ui-button-solid-danger-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -c "^\\.ui-button-soft-neutral-md" internal/web/ui/button.css | xargs -I{} test {} -ge 1 && grep -q "#b91c1c" internal/web/ui/button.css && grep -q "#f1f5f9" internal/web/ui/button.css && grep -q "min-height: 44px" internal/web/ui/button.css && just generate && grep -q "ui-button-solid-danger-md" static/tailwind.css</automated>
</verify>
<acceptance_criteria>
- button.css contains top-level selectors `.ui-button-solid-danger-md`, `.ui-button-solid-danger-md:hover`, `.ui-button-solid-danger-md:focus-visible`, `.ui-button-soft-neutral-md`, `.ui-button-soft-neutral-md:hover`, `.ui-button-soft-neutral-md:focus-visible` (each as its own top-level rule — no `&` nesting).
- Hex codes #b91c1c, #991b1b, #f1f5f9, #e2e8f0, #334155, #64748b present.
- `min-height: 44px` appears for both new classes.
- After `just generate`, backend/static/tailwind.css contains the strings `ui-button-solid-danger-md` and `ui-button-soft-neutral-md`.
- `go build ./...` from backend/ exits 0 (sanity — no Go changes here but verifying nothing breaks).
</acceptance_criteria>
<done>CSS classes present, Tailwind regenerated, no other files modified.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → web server | Untrusted form input crosses into POST /tablos handlers (Plans 02/03); Phase 1 sets the test surface but introduces no new boundary. |
| web server → Postgres | SQL parameters from sqlc bindings are parameterised ($N) — no string concat. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Information disclosure | tablos table FK to users | mitigate | ON DELETE CASCADE on user_id ensures orphan-free deletes; tablos_user_id_idx supports per-user query bounded by user_id (no cross-tenant scans). |
| T-03-02 | Tampering | sqlc query layer | mitigate | All five queries use positional $N parameters (no string interpolation); sqlc generates type-safe Go bindings consumed by Plans 02/03. |
| T-03-03 | DoS | tablos.title column | accept (mitigated by Plans 02/03) | Schema accepts arbitrary-length text; per-request length validation (<=255) happens in Plans 02/03 handlers. Documented here so reviewers know length-cap is layered above the DB. |
| T-03-04 | Repudiation | tablos audit trail | accept | No audit log table in v1 per ROADMAP scope; created_at/updated_at provide minimum forensic signal. |
| T-03-05 | Tampering | Integration test fixtures | mitigate | handlers_tablos_test.go uses setupTestDB per-test schema isolation (D-09 carryover from Phase 2) — no test pollutes another's data. |
</threat_model>
<verification>
- `cd backend && just generate` runs without error.
- `cd backend && go build ./...` exits 0.
- `cd backend && go test ./internal/web/... -run TestTablo` lists all 10 TABLO tests, all FAIL (RED state expected).
- `grep -c "^func Test" backend/internal/web/handlers_tablos_test.go` >= 10.
- `psql` against the local compose DB shows tablos table with the 7 columns from D-02 after `just migrate up`.
- New button CSS classes appear in backend/static/tailwind.css.
</verification>
<success_criteria>
1. backend/migrations/0003_tablos.sql exists and applies cleanly to a fresh DB via goose.
2. backend/internal/db/queries/tablos.sql defines the 5 queries; sqlc regeneration succeeds.
3. backend/internal/db/sqlc/tablos.sql.go and updated models.go are committed (generated, not hand-written).
4. backend/internal/web/handlers_tablos_test.go compiles, declares all 10 named test functions, and currently fails at runtime (handlers missing — Plans 02/03 turn it green).
5. backend/internal/web/ui/button.css declares the two new variants; Tailwind rebuild emits them into static/tailwind.css.
6. No regression: existing `just test` suite (Phase 1/2 tests) still passes.
</success_criteria>
<output>
After completion, create `.planning/phases/03-tablos-crud/03-01-SUMMARY.md` covering: migration shape, query list, generated bindings overview, button CSS additions, and a count of failing TABLO tests (expected: 10/10 fail before Plans 02/03).
</output>

View file

@ -0,0 +1,324 @@
---
phase: 03-tablos-crud
plan: 02
type: execute
wave: 2
depends_on: [01]
files_modified:
- backend/internal/web/handlers_tablos.go
- backend/templates/tablos.templ
- backend/templates/tablos_templ.go
- backend/templates/layout.templ
- backend/templates/layout_templ.go
- backend/templates/index.templ
- backend/templates/index_templ.go
- backend/internal/web/handlers.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
autonomous: true
requirements:
- TABLO-01
- TABLO-02
- TABLO-06
tags:
- go
- htmx
- templ
- chi
- csrf
must_haves:
truths:
- "Signed-in user visiting GET / sees a dashboard titled 'Your Tablos' listing only their tablos newest-first"
- "Empty state renders 'No tablos yet' + 'Create your first tablo to get started.' when user has zero tablos"
- "Clicking 'New tablo' fetches an inline form into #create-form-slot via hx-get /tablos/new"
- "Submitting a valid POST /tablos via HTMX inserts the row, returns 200 with HX-Retarget: #tablos-list + HX-Reswap: afterbegin, prepends a new TabloCard, and clears #create-form-slot via OOB swap"
- "Submitting POST /tablos without HTMX inserts the row and 303-redirects to /"
- "Empty-title POST returns 422 with 'Title is required.' field error"
artifacts:
- path: "backend/internal/web/handlers_tablos.go"
provides: "TablosDeps, TablosListHandler, TablosNewHandler, TablosCreateHandler"
contains: "TablosCreateHandler"
- path: "backend/templates/tablos.templ"
provides: "TablosDashboard, TablosEmptyState, TabloCard, TabloCreateFormFragment, TabloCardWithOOBFormClear"
contains: "TabloCardWithOOBFormClear"
- path: "backend/internal/web/router.go"
provides: "GET / → TablosListHandler, GET /tablos/new, POST /tablos route registration"
contains: "TablosListHandler"
- path: "backend/cmd/web/main.go"
provides: "Construct TablosDeps and pass to NewRouter"
contains: "TablosDeps{Queries: q}"
key_links:
- from: "backend/internal/web/router.go"
to: "TablosCreateHandler"
via: "r.Post(\"/tablos\", ...)"
pattern: "r\\.Post\\(\"/tablos\""
- from: "backend/templates/tablos.templ"
to: "ui.CSRFField"
via: "@ui.CSRFField(csrfToken) inside create form"
pattern: "ui\\.CSRFField"
- from: "TablosCreateHandler"
to: "Queries.InsertTablo"
via: "sqlc binding"
pattern: "InsertTablo"
---
<objective>
First vertical slice of Phase 3: a signed-in user can SEE their tablos on the dashboard and CREATE a new tablo end-to-end via HTMX, with a graceful non-JS POST fallback. Turns TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation green (TABLO-01, TABLO-02, TABLO-06 partially).
Purpose: Deliver real user-visible value as the first slice — the dashboard renders, the create flow works, and the user can see a brand-new tablo appear without a full reload.
Output:
- handlers_tablos.go with TablosDeps + List/New/Create handlers
- tablos.templ with dashboard page, empty state, card, create form, OOB-clear template
- router.go updated: replace IndexHandler with TablosListHandler; mount /tablos/new and /tablos
- main.go constructs TablosDeps
- layout.templ footer "Phase 3 · Tablos"
- index.templ + IndexHandler removed (or emptied) — dashboard now lives in tablos.templ
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/03-tablos-crud/03-CONTEXT.md
@.planning/phases/03-tablos-crud/03-RESEARCH.md
@.planning/phases/03-tablos-crud/03-PATTERNS.md
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
@.planning/phases/03-tablos-crud/03-01-SUMMARY.md
@backend/internal/web/handlers_auth.go
@backend/internal/web/router.go
@backend/cmd/web/main.go
@backend/templates/auth_signup.templ
@backend/templates/index.templ
@backend/templates/layout.templ
@backend/internal/web/ui/button.templ
@backend/internal/web/ui/card.templ
@backend/internal/web/ui/variants.go
@backend/internal/db/sqlc/tablos.sql.go
<interfaces>
<!-- Contracts this plan establishes for Plan 03 -->
TablosDeps (declared in backend/internal/web/handlers_tablos.go):
- Queries *sqlc.Queries
Handler constructors (signature pattern matches AuthDeps):
- TablosListHandler(deps TablosDeps) http.HandlerFunc → GET /
- TablosNewHandler(deps TablosDeps) http.HandlerFunc → GET /tablos/new (returns TabloCreateFormFragment)
- TablosCreateHandler(deps TablosDeps) http.HandlerFunc → POST /tablos
NewRouter new signature (replaces existing):
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
Templates (callable by Plan 03):
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) — full page
- TablosEmptyState() — empty list state
- TabloCard(t sqlc.Tablo, csrfToken string) — single card on dashboard; wraps in ui.Card with class "tablo-delete-zone" on delete button container
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) — inline create form
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — card + OOB div clearing #create-form-slot
Form types (declared in backend/templates/tablos.templ or a sibling tablos_forms.go file, mirroring auth_forms.go):
- TabloCreateForm { Title, Description, Color string }
- TabloCreateErrors { Title, General string }
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: tablos.templ — dashboard, empty state, card, create form, OOB-clear (+ generate)</name>
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go, backend/templates/layout.templ, backend/templates/layout_templ.go</files>
<read_first>
backend/templates/auth_signup.templ
backend/templates/auth_forms.go
backend/templates/auth_form_errors.templ
backend/templates/index.templ
backend/templates/layout.templ
backend/internal/web/ui/card.templ
backend/internal/web/ui/button.templ
backend/internal/web/ui/variants.go
backend/internal/web/ui/csrf_field.templ
backend/internal/db/sqlc/models.go (Tablo struct)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Component Inventory, Interaction Contracts §1+§2, Copywriting Contract)
.planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog block + OOB swap template shape)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 4 dual-target swap, Pitfall 5 top-level OOB)
</read_first>
<behavior>
- TablosDashboard renders inside @Layout("Tablos — Xtablo", user, csrfToken).
- Page heading "Your Tablos" using `text-[28px] font-semibold leading-tight` per UI-SPEC Typography.
- "New tablo" button (ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD, Type "button") fires hx-get="/tablos/new", hx-target="#create-form-slot", hx-swap="innerHTML".
- `<div id="create-form-slot"></div>` rendered above `<div id="tablos-list">`.
- When tablos slice is empty → @TablosEmptyState() rendered inside #tablos-list.
- Empty state copy exactly: heading "No tablos yet", body "Create your first tablo to get started.", CTA button "New tablo" with aria-label "Create your first tablo" and same hx-get="/tablos/new" attrs.
- TabloCard renders inside @ui.Card with: title (Heading style `text-xl font-semibold leading-snug`), optional description paragraph only when `tablo.Description.Valid && tablo.Description.String != ""` (Pitfall 6), optional color dot (only when `tablo.Color.Valid && tablo.Color.String != ""`) using `inline-block w-2.5 h-2.5 rounded-full` with inline `style="background-color: {tablo.Color.String}"`, link "View" to `/tablos/{id}`, and a `.tablo-delete-zone` wrapper containing the Delete button. (Plan 03 wires delete-confirm; Plan 02 just needs the zone div + a Delete button stub.)
- TabloCreateFormFragment renders a `<form id="create-form" method="POST" action="/tablos" hx-post="/tablos" hx-target="#create-form-slot" hx-swap="innerHTML">` with @ui.CSRFField(csrfToken), @GeneralError(errs.General), labelled Title input (required), Description textarea (3 rows, optional), Color text input (placeholder "#6366f1 or indigo", optional), submit button "Create tablo", cancel link/button that fires `hx-get="/tablos/new/cancel"` OR simpler: clears the slot via hx-get to an endpoint that returns empty — for Phase 02 use a "Cancel" button with `hx-on:click` not allowed (no JS); use a plain anchor `<a href="/">Cancel</a>` that reloads / and resets the slot.
- Field error rendering uses @FieldError(errs.Title) directly under the Title input.
- TabloCardWithOOBFormClear renders @TabloCard(...) as the primary card AND a top-level sibling `<div id="create-form-slot" hx-swap-oob="true"></div>` (must be siblings, not nested per Pitfall 5).
- layout.templ footer changes from "Phase 2 · Authentication" to "Phase 3 · Tablos".
</behavior>
<action>
Create backend/templates/tablos.templ (package templates) and a sibling backend/templates/tablos_forms.go declaring `type TabloCreateForm struct { Title, Description, Color string }` and `type TabloCreateErrors struct { Title, General string }` (mirrors auth_forms.go).
Implement these templ components per the behavior block:
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo)
- TablosEmptyState()
- TabloCard(t sqlc.Tablo, csrfToken string) — include `<div class="tablo-delete-zone">` wrapper around the Delete button. The Delete button for now uses `hx-get="/tablos/{id}/delete-confirm"`, `hx-target="closest .tablo-delete-zone"`, `hx-swap="outerHTML"`, Variant ButtonVariantDanger, Tone ButtonToneSoft, Size SizeMD, Type "button". Plan 03 implements /delete-confirm.
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string)
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — emit @TabloCard then a `<div id="create-form-slot" hx-swap-oob="true"></div>` as TOP-LEVEL siblings inside the template body (no wrapping container).
Imports in tablos.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". Use `{ tablo.Title }`, `{ tablo.Description.String }`, etc. — templ auto-escapes (V5 XSS).
Modify backend/templates/layout.templ footer text from "Phase 2 · Authentication" → "Phase 3 · Tablos". No other layout edits.
Run `just generate` (or `templ generate`) to produce tablos_templ.go and the updated layout_templ.go. Pitfall 3.
</action>
<verify>
<automated>cd backend && just generate && grep -q "templ TablosDashboard" templates/tablos.templ && grep -q "templ TablosEmptyState" templates/tablos.templ && grep -q "templ TabloCard" templates/tablos.templ && grep -q "templ TabloCreateFormFragment" templates/tablos.templ && grep -q "templ TabloCardWithOOBFormClear" templates/tablos.templ && grep -q "id=\"create-form-slot\" hx-swap-oob=\"true\"" templates/tablos.templ && grep -q "Phase 3 · Tablos" templates/layout.templ && grep -q "Create your first tablo to get started." templates/tablos.templ && grep -q "No tablos yet" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "type TabloCreateForm" templates/tablos_forms.go && go build ./...</automated>
</verify>
<acceptance_criteria>
- File backend/templates/tablos.templ exists with `package templates` and imports "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui".
- File contains the strings: `templ TablosDashboard`, `templ TablosEmptyState`, `templ TabloCard`, `templ TabloCreateFormFragment`, `templ TabloCardWithOOBFormClear`.
- File contains exact copy strings (UI-SPEC Copywriting Contract): "Your Tablos", "New tablo", "Create a tablo", "Create tablo", "No tablos yet", "Create your first tablo to get started.".
- File contains `id="create-form-slot"` and `id="tablos-list"`.
- TabloCardWithOOBFormClear emits `<div id="create-form-slot" hx-swap-oob="true">` as a top-level sibling (grep shows it OUTSIDE the @TabloCard call — verify by reading the templ component body).
- File contains `class="tablo-delete-zone"` wrapper around the Delete button.
- tablos.templ guards description rendering with `if tablo.Description.Valid && tablo.Description.String != ""` and color dot with `if tablo.Color.Valid && tablo.Color.String != ""`.
- tablos_forms.go declares `type TabloCreateForm struct` with Title, Description, Color string fields and `type TabloCreateErrors struct` with Title, General string fields.
- templates/layout.templ now contains "Phase 3 · Tablos" and does NOT contain "Phase 2 · Authentication".
- `just generate` produced tablos_templ.go and refreshed layout_templ.go.
- `go build ./...` exits 0.
</acceptance_criteria>
<done>Templates compile, helper form/errors types declared, layout footer updated.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: handlers_tablos.go — TablosDeps + List/New/Create handlers + router/main wiring</name>
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/router.go, backend/internal/web/handlers.go, backend/templates/index.templ, backend/templates/index_templ.go, backend/cmd/web/main.go</files>
<read_first>
backend/internal/web/handlers_auth.go
backend/internal/web/handlers.go
backend/internal/web/router.go
backend/internal/web/middleware.go
backend/cmd/web/main.go
backend/internal/db/sqlc/tablos.sql.go
backend/internal/db/sqlc/models.go
backend/internal/auth/middleware.go (Authed)
backend/internal/web/handlers_tablos_test.go (RED tests from Plan 01)
.planning/phases/03-tablos-crud/03-PATTERNS.md (handlers_tablos.go analog block + router/main modify blocks)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 1-4, 7, 8 + Pitfalls 1, 2, 5, 6)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §1 + §2 + Validation copy)
</read_first>
<behavior>
- TablosDeps struct holds *sqlc.Queries.
- TablosListHandler: GET / — extracts user via auth.Authed(ctx); calls Queries.ListTablosByUser(ctx, user.ID); renders TablosDashboard with the slice (empty slice OK, template handles empty state); Content-Type: text/html; charset=utf-8.
- TablosNewHandler: GET /tablos/new — renders TabloCreateFormFragment with zero-value form/errs; HTMX-only intent but works without HX-Request too.
- TablosCreateHandler: POST /tablos — reads title/description/color via r.PostFormValue (NOT r.Body — Pitfall 2); validates: title required → "Title is required.", title len > 255 → "Title must be 255 characters or fewer." (UI-SPEC copy). On validation error: status 422, render TabloCreateFormFragment with field errors (HTMX path) or full TablosDashboard with errs (non-HTMX path). Build InsertTabloParams: Title=trimmed title, Description=pgtype.Text{Valid: description != "", String: description}, Color=pgtype.Text{Valid: color != "", String: color}, UserID=user.ID. On success + HX-Request: set HX-Retarget: #tablos-list, HX-Reswap: afterbegin, render TabloCardWithOOBFormClear. On success + non-HTMX: 303 to /.
- All three handlers extract *auth.User via `_, user, _ := auth.Authed(r.Context())`.
- Router signature changes to NewRouter(pinger, staticDir, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string).
- Inside the RequireAuth chi group: GET / → TablosListHandler(tabloDeps), GET /tablos/new → TablosNewHandler(tabloDeps), POST /tablos → TablosCreateHandler(tabloDeps). Static segments declared before parametric (Pitfall 1) — Plan 03 will add /tablos/{id} routes AFTER /tablos/new.
- cmd/web/main.go constructs `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter.
- Delete the old IndexHandler from handlers.go (and templates.Index in index.templ → emptied or file deleted). DemoTimeHandler stays. If deleting index.templ causes any remaining references to break, leave the file but reduce it to a no-op package declaration; remove IndexHandler from handlers.go either way.
- Stub for Plan 03: also register placeholder routes for endpoints the test scaffold exercises to avoid 404 noise during partial green: leave undefined; Plan 03 implements them. Tests for those endpoints stay RED until Plan 03.
</behavior>
<action>
1) Replace the Plan 01 stub `type TablosDeps struct { Queries *sqlc.Queries }` (or create) at the top of backend/internal/web/handlers_tablos.go in package web. Add full handler implementations following handlers_auth.go style exactly (constructor returns http.HandlerFunc closing over deps).
2) Implement TablosListHandler, TablosNewHandler, TablosCreateHandler per the behavior block. For nullable insert params use `pgtype.Text{String: s, Valid: s != ""}` (import "github.com/jackc/pgx/v5/pgtype"). For trim/validate, use `strings.TrimSpace`. For error responses use `w.WriteHeader(http.StatusUnprocessableEntity)` + form fragment (HX) or full dashboard (non-HX). For HTMX success use w.Header().Set("HX-Retarget", "#tablos-list") and w.Header().Set("HX-Reswap", "afterbegin"). Status 200 for HTMX success; 303 to "/" otherwise (http.StatusSeeOther — Pitfall 9 from Phase 2 carries over).
3) Edit backend/internal/web/router.go: change NewRouter signature to add `tabloDeps TablosDeps` as the parameter immediately after `deps AuthDeps`. Inside the existing protected group, replace `r.Get("/", IndexHandler())` with `r.Get("/", TablosListHandler(tabloDeps))` and add (in this order — static-before-parametric, Pitfall 1):
- r.Get("/tablos/new", TablosNewHandler(tabloDeps))
- r.Post("/tablos", TablosCreateHandler(tabloDeps))
Do NOT yet add /tablos/{id} routes — Plan 03 owns those.
4) Edit backend/cmd/web/main.go: after the existing `deps := web.AuthDeps{...}` line, add `tabloDeps := web.TablosDeps{Queries: q}` and pass it as the new arg to NewRouter (positional, between deps and csrfKey).
5) Remove backend/internal/web/handlers.go IndexHandler. Remove backend/templates/index.templ (delete or reduce to bare `package templates`). Delete backend/templates/index_templ.go if `just generate` does not regenerate it (templ should remove it on next run if the .templ is gone; if leftover, delete manually).
6) Run `just generate` then `just test`. TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation must turn green; tests for endpoints not yet implemented (Plan 03) remain red.
</action>
<verify>
<automated>cd backend && just generate && grep -q "type TablosDeps struct" internal/web/handlers_tablos.go && grep -q "func TablosListHandler" internal/web/handlers_tablos.go && grep -q "func TablosNewHandler" internal/web/handlers_tablos.go && grep -q "func TablosCreateHandler" internal/web/handlers_tablos.go && grep -q "HX-Retarget" internal/web/handlers_tablos.go && grep -q "HX-Reswap" internal/web/handlers_tablos.go && grep -q "pgtype.Text" internal/web/handlers_tablos.go && grep -q "r.PostFormValue(\"title\")" internal/web/handlers_tablos.go && grep -q "tabloDeps TablosDeps" internal/web/router.go && grep -q "r.Get(\"/tablos/new\"" internal/web/router.go && grep -q "r.Post(\"/tablos\"" internal/web/router.go && grep -q "TablosDeps{Queries: q}" cmd/web/main.go && ! grep -q "IndexHandler" internal/web/handlers.go && go build ./... && go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation" -count=1</automated>
</verify>
<acceptance_criteria>
- backend/internal/web/handlers_tablos.go declares: `type TablosDeps struct { Queries *sqlc.Queries }`, `func TablosListHandler(deps TablosDeps) http.HandlerFunc`, `func TablosNewHandler(deps TablosDeps) http.HandlerFunc`, `func TablosCreateHandler(deps TablosDeps) http.HandlerFunc`.
- handlers_tablos.go uses `r.PostFormValue("title")`, `r.PostFormValue("description")`, `r.PostFormValue("color")` (no r.Body reads).
- handlers_tablos.go contains literal "Title is required." and "Title must be 255 characters or fewer." strings.
- handlers_tablos.go sets HX-Retarget and HX-Reswap headers in the create-success path.
- handlers_tablos.go uses pgtype.Text{...Valid: s != ""...} for nullable Description and Color inserts.
- router.go signature reads `func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler`.
- router.go protected group contains `r.Get("/", TablosListHandler(tabloDeps))` (no IndexHandler reference), `r.Get("/tablos/new", TablosNewHandler(tabloDeps))` (declared BEFORE any parametric /tablos/{id} route which is added in Plan 03), `r.Post("/tablos", TablosCreateHandler(tabloDeps))`.
- cmd/web/main.go contains `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter.
- backend/internal/web/handlers.go no longer contains the symbol `IndexHandler` (`grep -c IndexHandler handlers.go` == 0).
- `go build ./...` exits 0.
- `go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation"` exits 0 (all four pass).
- Phase 1/2 tests still pass: `go test ./internal/web/... -run "TestSignup|TestLogin|TestLogout|TestCSRF"` exits 0.
</acceptance_criteria>
<done>Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Human verify list + create flow</name>
<what-built>
Dashboard at GET / showing user's tablos newest-first or empty state. "New tablo" button expands inline form via HTMX; submitting creates a row, clears the form slot, and prepends the new card without a full reload. Non-JS fallback works via plain form POST.
</what-built>
<how-to-verify>
1. cd backend && just dev (web server on local port).
2. Open http://localhost:PORT/login, log in with an existing test account (or sign up).
3. On the dashboard, confirm heading "Your Tablos" and either tablo cards or empty-state text "No tablos yet" + "Create your first tablo to get started.".
4. Click "New tablo" — inline form appears above the list, no full page reload (check DevTools Network panel: only XHR to /tablos/new).
5. Submit form with title "My first tablo" + description "Hello" + color "#6366f1": form collapses, new card appears at top of list, no full reload.
6. Submit form with empty title: form stays open, "Title is required." appears under the field.
7. Open a private window with no JS — submit the form: page does a real POST and redirects to /; new tablo appears in list.
8. View page source: verify CSRF token is rendered in the form's hidden _csrf field.
</how-to-verify>
<resume-signal>Type "approved" if the dashboard + create flow works as described, or describe issues.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → POST /tablos | Untrusted form input (title, description, color) crosses into the handler. |
| Browser → GET /tablos/new | Authed GET; no body input. |
| Handler → Postgres | Parameterised via sqlc-generated InsertTabloParams. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-02-01 | Spoofing | session cookie | mitigate (inherited) | RequireAuth middleware from Phase 2 wraps the entire tablos group — unauthed requests redirect to /login. |
| T-03-02-02 | Tampering | CSRF on POST /tablos | mitigate | gorilla/csrf middleware from Phase 2 validates _csrf field; @ui.CSRFField(csrfToken) embedded in TabloCreateFormFragment. Missing token → 403 before handler runs. |
| T-03-02-03 | Tampering | r.Body drained by gorilla/csrf | mitigate | Use r.PostFormValue exclusively (Pitfall 2). |
| T-03-02-04 | DoS | over-long title | mitigate | TablosCreateHandler validates len(strings.TrimSpace(title)) > 0 AND <= 255; >255 returns 422 before DB insert. |
| T-03-02-05 | XSS via title/description/color | Tampering | mitigate | templ auto-escapes all `{ variable }` interpolations; color rendered into inline style attribute is templ-escaped (inline style accepts limited chars, but templ's escaping prevents attribute breakout). No templ.Raw anywhere. |
| T-03-02-06 | Tampering via OOB swap | hx-swap-oob top-level requirement | mitigate | TabloCardWithOOBFormClear emits the OOB div as a top-level sibling (Pitfall 5); template structure asserted in Task 1 acceptance criteria. |
| T-03-02-07 | Elevation of privilege | Listing other users' tablos | mitigate | ListTablosByUser query is scoped by $1 = user.ID from auth.Authed(ctx); no path parameters, no way to widen scope. |
</threat_model>
<verification>
- TestTabloList, TestTabloList_Empty, TestTabloCreate (incl. HTMX + non-HTMX sub-asserts), TestTabloCreate_Validation all pass.
- Full Phase 1/2 test suite still passes (no regression in TestSignup/TestLogin/TestLogout/TestCSRF).
- Manual browser verification of create flow per Task 3 checkpoint.
- `grep -c IndexHandler backend/internal/web/handlers.go` returns 0.
</verification>
<success_criteria>
1. Dashboard at GET / renders user's tablos newest-first (TABLO-01).
2. Empty state appears when user has zero tablos with exact UI-SPEC copy.
3. Creating a tablo via HTMX prepends a new card and clears the form slot in one round trip (TABLO-02 + TABLO-06).
4. Empty-title validation surfaces "Title is required." inline.
5. Non-HTMX POST /tablos redirects 303 to / (TABLO-06 degrade-gracefully).
6. CSRF token present in the create form (AUTH-06 inherited).
7. Plan 03 has the wiring it needs: TablosDeps available, NewRouter signature stable, TabloCard renders a `.tablo-delete-zone` placeholder.
</success_criteria>
<output>
After completion, create `.planning/phases/03-tablos-crud/03-02-SUMMARY.md` documenting: TablosDeps shape, list of new handlers/templates, NewRouter signature change, which tests turned green (4 of 10), and which remain red for Plan 03 (6 of 10).
</output>

View file

@ -0,0 +1,337 @@
---
phase: 03-tablos-crud
plan: 03
type: execute
wave: 3
depends_on: [01, 02]
files_modified:
- backend/internal/web/handlers_tablos.go
- backend/templates/tablos.templ
- backend/templates/tablos_templ.go
- backend/internal/web/router.go
autonomous: false
requirements:
- TABLO-03
- TABLO-04
- TABLO-05
- TABLO-06
tags:
- go
- htmx
- templ
- chi
- csrf
- ownership
must_haves:
truths:
- "Owner can GET /tablos/{id} and see the detail page with title + description rendered"
- "Non-owner GET /tablos/{id} returns 404 (not 403)"
- "Malformed UUID at /tablos/{id} returns 404"
- "Owner can click title/description, swap to edit input, save via POST /tablos/{id}, see updated display fragment"
- "Owner can click Delete on dashboard card or detail page, see inline confirmation, confirm to remove the tablo, or cancel to restore the original button"
- "All mutating routes accept and validate CSRF and degrade gracefully for non-HTMX clients (303 redirects)"
artifacts:
- path: "backend/internal/web/handlers_tablos.go"
provides: "TabloDetailHandler, TabloEditTitleHandler, TabloEditDescHandler, TabloShowTitleHandler, TabloShowDescHandler, TabloUpdateHandler, TabloDeleteConfirmHandler, TabloDeleteCancelHandler, TabloDeleteHandler"
contains: "TabloDeleteHandler"
- path: "backend/templates/tablos.templ"
provides: "TabloDetailPage, TabloTitleDisplay, TabloTitleEditFragment, TabloDescDisplay, TabloDescEditFragment, TabloDeleteButtonFragment, TabloDeleteConfirmFragment, TabloNotFoundPage"
contains: "TabloDeleteConfirmFragment"
- path: "backend/internal/web/router.go"
provides: "All remaining /tablos/{id}* route registrations in correct order"
contains: "TabloDeleteHandler"
key_links:
- from: "TabloDetailHandler"
to: "ownership check"
via: "tablo.UserID != user.ID → http.NotFound"
pattern: "http\\.NotFound"
- from: "TabloUpdateHandler"
to: "Queries.UpdateTablo"
via: "sqlc binding with updated_at = now()"
pattern: "UpdateTablo"
- from: "TabloDeleteHandler"
to: "Queries.DeleteTablo"
via: "sqlc binding"
pattern: "DeleteTablo"
---
<objective>
Second vertical slice of Phase 3: detail page, inline edit (title + description), and inline-confirmation delete. Turns the remaining six TABLO tests green (TestTabloDetail_Owner, TestTabloDetail_NonOwner, TestTabloDetail_InvalidID, TestTabloUpdate, TestTabloDeleteConfirm, TestTabloDelete) and closes TABLO-03, TABLO-04, TABLO-05, TABLO-06.
Purpose: Complete the Tablos CRUD vertical so the user story shipped in Plan 02 (create + list) extends to read, update, and delete — all HTMX-driven with degradation to plain POST.
Output:
- Detail page handler + ownership-enforced 404 path
- Inline-edit GET/POST handlers for title and description (with discard-changes path)
- Delete confirmation + delete handlers + cancel handler
- All routes wired into the existing RequireAuth chi group in correct static-before-parametric order
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/03-tablos-crud/03-CONTEXT.md
@.planning/phases/03-tablos-crud/03-RESEARCH.md
@.planning/phases/03-tablos-crud/03-PATTERNS.md
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
@.planning/phases/03-tablos-crud/03-01-SUMMARY.md
@.planning/phases/03-tablos-crud/03-02-SUMMARY.md
@backend/internal/web/handlers_tablos.go
@backend/internal/web/router.go
@backend/templates/tablos.templ
@backend/internal/db/sqlc/tablos.sql.go
@backend/internal/auth/middleware.go
<interfaces>
<!-- Handler & template contracts introduced by this plan -->
Handler constructors (all return http.HandlerFunc; signature pattern matches Plan 02):
- TabloDetailHandler(deps TablosDeps) → GET /tablos/{id}
- TabloEditTitleHandler(deps TablosDeps) → GET /tablos/{id}/edit-title
- TabloShowTitleHandler(deps TablosDeps) → GET /tablos/{id}/show-title
- TabloEditDescHandler(deps TablosDeps) → GET /tablos/{id}/edit-desc
- TabloShowDescHandler(deps TablosDeps) → GET /tablos/{id}/show-desc
- TabloUpdateHandler(deps TablosDeps) → POST /tablos/{id}
- TabloDeleteConfirmHandler(deps TablosDeps) → GET /tablos/{id}/delete-confirm
- TabloDeleteCancelHandler(deps TablosDeps) → GET /tablos/{id}/delete-cancel
- TabloDeleteHandler(deps TablosDeps) → POST /tablos/{id}/delete
Templates added to tablos.templ:
- TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo)
- TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string)
- TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
- TabloDescDisplay(tablo sqlc.Tablo, csrfToken string)
- TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
- TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) -- the .tablo-delete-zone with the Delete button (same shape as in TabloCard, factored out)
- TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string)
- TabloNotFoundPage(user *auth.User, csrfToken string) -- 404 body with copy from UI-SPEC
Form types (extend tablos_forms.go):
- TabloUpdateErrors { Title, Description, General string }
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: tablos.templ — detail page, edit fragments, delete fragments, 404 page (+ generate)</name>
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go</files>
<read_first>
backend/templates/tablos.templ (current state from Plan 02)
backend/templates/tablos_forms.go
backend/templates/auth_form_errors.templ
backend/templates/auth_signup.templ
backend/internal/web/ui/button.templ
backend/internal/web/ui/variants.go
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contracts §3-§5, Component Inventory, Copywriting, HTMX Attribute Reference)
.planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog blocks)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 2 fragment dispatch, Pattern 5 ownership 404)
</read_first>
<behavior>
- TabloDetailPage wraps @Layout("Tablos — Xtablo", user, csrfToken) and inside the main container renders, in order: a back link to /, then a `.tablo-title-zone` div containing @TabloTitleDisplay, then a `.tablo-desc-zone` div containing @TabloDescDisplay, then a `.tablo-delete-zone` div containing @TabloDeleteButtonFragment.
- TabloTitleDisplay renders the title as an `<h1 class="text-xl font-semibold leading-snug tablo-title-zone">` (the zone class on the SAME element so outerHTML swap replaces it cleanly) with hx-get="/tablos/{id}/edit-title", hx-target="closest .tablo-title-zone", hx-swap="outerHTML", role="button", aria-label="Edit title". Wraps with class "tablo-title-zone" — every Display/Edit fragment for the title shares this class so outerHTML round-trips work.
- TabloTitleEditFragment renders a `<form class="tablo-title-zone" hx-post="/tablos/{id}" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" method="POST" action="/tablos/{id}">` with hidden _method=PATCH (semantic clarity per UI-SPEC §4) NOT consumed by chi but harmless, @ui.CSRFField, a labelled title input prefilled with tablo.Title, a hidden description field carrying current description value, @FieldError(errs.Title), submit button "Save changes", discard button with hx-get="/tablos/{id}/show-title" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" Variant Neutral Tone Soft.
- TabloDescDisplay analogous to TabloTitleDisplay but element is a `<div class="tablo-desc-zone">` containing a `<p>` or empty-state hint "Add a description"; clicking fires hx-get="/tablos/{id}/edit-desc" hx-target="closest .tablo-desc-zone" hx-swap="outerHTML".
- TabloDescEditFragment analogous to TabloTitleEditFragment but for the description textarea (3 rows) + hidden title field carrying current title; submit "Save changes" + discard "Discard changes" with hx-get="/tablos/{id}/show-desc".
- TabloDeleteButtonFragment renders a `<div class="tablo-delete-zone">` wrapping a Delete button (Variant Danger Tone Soft Size MD) with hx-get="/tablos/{id}/delete-confirm", hx-target="closest .tablo-delete-zone", hx-swap="outerHTML", aria-label="Delete tablo".
- TabloDeleteConfirmFragment renders a `<div class="tablo-delete-zone">` containing copy: "Delete tablo?" heading, "This cannot be undone." body, a "Yes, delete" submit button (Variant Danger Tone Solid Size MD, aria-label "Confirm delete tablo") inside a `<form method="POST" action="/tablos/{id}/delete" hx-post="/tablos/{id}/delete" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML">` with @ui.CSRFField, and a "Keep tablo" cancel button (Variant Neutral Tone Soft, aria-label "Keep tablo") with hx-get="/tablos/{id}/delete-cancel" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML".
- TabloNotFoundPage wraps @Layout("Not found", user, csrfToken) and renders heading "Not found" + body "This tablo doesn't exist or you don't have access." (per UI-SPEC Copywriting). user may be nil if called from a path before RequireAuth runs — accept nil and forward to Layout.
- tablos_forms.go gains `type TabloUpdateErrors struct { Title, Description, General string }`.
- Re-use the dashboard's TabloCard delete-zone shape so TabloDeleteButtonFragment is the canonical implementation; update TabloCard from Plan 02 (if necessary) to call `@TabloDeleteButtonFragment(t, csrfToken)` instead of inlining the delete button — this guarantees a single source of truth for the zone HTML.
</behavior>
<action>
Edit backend/templates/tablos.templ to add the eight new templ components listed above. Place them after the Plan 02 components. Use exact copywriting from UI-SPEC. Use ui.Button with Variant/Tone/Size enums (ButtonVariantDanger, ButtonVariantNeutral already exist; ButtonToneSoft exists per variants.go).
Append `type TabloUpdateErrors struct { Title, Description, General string }` to backend/templates/tablos_forms.go.
If TabloCard in Plan 02 inlined the delete button HTML, refactor it to call `@TabloDeleteButtonFragment(t, csrfToken)` — DO NOT duplicate the zone markup.
Use class attribute `tablo-title-zone`, `tablo-desc-zone`, and `tablo-delete-zone` ON the outermost element of each respective Display/Edit fragment so HTMX's `hx-target="closest .tablo-X-zone"` + `hx-swap="outerHTML"` round-trips replace the zone wholesale.
Pitfall 5 reminder: no nested OOB swaps required in this plan, but the zone elements must NOT be wrapped in extra containers that the swap target would miss.
Run `just generate` to produce backend/templates/tablos_templ.go.
</action>
<verify>
<automated>cd backend && just generate && grep -q "templ TabloDetailPage" templates/tablos.templ && grep -q "templ TabloTitleDisplay" templates/tablos.templ && grep -q "templ TabloTitleEditFragment" templates/tablos.templ && grep -q "templ TabloDescDisplay" templates/tablos.templ && grep -q "templ TabloDescEditFragment" templates/tablos.templ && grep -q "templ TabloDeleteButtonFragment" templates/tablos.templ && grep -q "templ TabloDeleteConfirmFragment" templates/tablos.templ && grep -q "templ TabloNotFoundPage" templates/tablos.templ && grep -q "Delete tablo?" templates/tablos.templ && grep -q "This cannot be undone." templates/tablos.templ && grep -q "Yes, delete" templates/tablos.templ && grep -q "Keep tablo" templates/tablos.templ && grep -q "Save changes" templates/tablos.templ && grep -q "Discard changes" templates/tablos.templ && grep -q "tablo-title-zone" templates/tablos.templ && grep -q "tablo-desc-zone" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "This tablo doesn't exist or you don't have access." templates/tablos.templ && grep -q "type TabloUpdateErrors" templates/tablos_forms.go && go build ./...</automated>
</verify>
<acceptance_criteria>
- tablos.templ contains all 8 new templ component declarations exactly as named in the interfaces block.
- File contains exact UI-SPEC copy: "Delete tablo?", "This cannot be undone.", "Yes, delete", "Keep tablo", "Save changes", "Discard changes", "Not found", "This tablo doesn't exist or you don't have access.".
- Each of `tablo-title-zone`, `tablo-desc-zone`, `tablo-delete-zone` appears as a CSS class on at least TWO different templ components (Display + Edit, or Button + Confirm) — verified by `grep -c "class=\"tablo-title-zone\"" templates/tablos.templ` >= 2 (same for desc and delete zones).
- aria-label values "Edit title", "Confirm delete tablo", "Keep tablo", "Delete tablo" all present.
- hx-target uses `closest .tablo-{title,desc,delete}-zone` in every relevant fragment (not absolute selectors) so the same template works from card or detail-page contexts.
- tablos_forms.go declares `type TabloUpdateErrors struct` with Title, Description, General string fields.
- TabloCard delegates delete-button rendering to TabloDeleteButtonFragment (no inline duplication of the delete-zone HTML).
- `just generate` produced an updated tablos_templ.go.
- `go build ./...` exits 0.
</acceptance_criteria>
<done>All Phase 3 templates declared, generated, and compile.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring</name>
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/router.go</files>
<read_first>
backend/internal/web/handlers_tablos.go (current state from Plan 02)
backend/internal/web/router.go (current state from Plan 02)
backend/internal/web/handlers_auth.go (HTMX-aware redirect, fragment dispatch)
backend/internal/auth/middleware.go (Authed, redirectTo)
backend/internal/db/sqlc/tablos.sql.go (UpdateTablo signature)
backend/internal/web/handlers_tablos_test.go (RED tests to turn green)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 3, 5, 6, 7, 8 + Pitfalls 1, 6, 7)
.planning/phases/03-tablos-crud/03-PATTERNS.md (UUID extraction, ownership check, HTMX redirect)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §3-§5)
</read_first>
<behavior>
Common pattern shared by every /tablos/{id}* handler:
1. `_, user, _ := auth.Authed(r.Context())`
2. Parse id: `tabloID, err := uuid.Parse(chi.URLParam(r, "id"))`. err != nil → http.NotFound(w, r); return.
3. Fetch: `tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)`. errors.Is(err, pgx.ErrNoRows) → http.NotFound. Other errors → 500.
4. Ownership: `if tablo.UserID != user.ID { http.NotFound(w, r); return }` (D-04: 404 not 403).
TabloDetailHandler (GET /tablos/{id}):
- After common steps, render TabloDetailPage(user, csrf.Token(r), tablo). Content-Type text/html; charset=utf-8.
TabloEditTitleHandler / TabloEditDescHandler (GET /tablos/{id}/edit-title|edit-desc):
- After common steps, render TabloTitleEditFragment / TabloDescEditFragment with empty TabloUpdateErrors.
TabloShowTitleHandler / TabloShowDescHandler (GET /tablos/{id}/show-title|show-desc):
- After common steps, render TabloTitleDisplay / TabloDescDisplay (used by discard-changes path).
TabloUpdateHandler (POST /tablos/{id}):
- After common steps, read r.PostFormValue("title") and r.PostFormValue("description"). Trim title.
- Validate: title required → "Title is required."; len(title) > 255 → "Title must be 255 characters or fewer.".
- On validation error: status 422 + render TabloTitleEditFragment (if r.PostFormValue("title") was the changed field) OR more simply re-render the detail page with errs surfaced. Pragmatic choice: render TabloTitleEditFragment with errs when HX-Request, else 422 + TabloDetailPage with errs.General. The test TestTabloUpdate only asserts happy path + DB update, so keep error path consistent with handlers_auth.go style.
- On success: call deps.Queries.UpdateTablo(ctx, UpdateTabloParams{ID: tabloID, Title: title, Description: pgtype.Text{Valid: description != "", String: description}}). Render TabloTitleDisplay (if title changed) OR TabloDescDisplay (if description changed) based on which field came through the form; simpler: detect which field was non-empty in the request and render the matching display fragment. Cleaner: since the title edit form submits BOTH title and description (description as hidden field) and vice versa, the handler always updates both and the caller's hx-target tells HTMX which zone to swap. Choose the cleaner approach — both fields always present; render the TabloTitleDisplay when r.PostFormValue("_zone") == "title" else TabloDescDisplay. To avoid the _zone hidden field complexity, render only TabloTitleDisplay for now and let TabloDescDisplay come on description-edit through a separate handler — but spec says one POST endpoint. Final decision: pass a `_zone` hidden input ("title" or "desc") in each edit fragment; handler reads it and renders the matching display. Non-HTMX path → 303 to /tablos/{id}.
- On success (HX-Request): 200 + display fragment in body. Non-HTMX: 303 to /tablos/{id}.
TabloDeleteConfirmHandler (GET /tablos/{id}/delete-confirm):
- After common steps, render TabloDeleteConfirmFragment(tablo, csrfToken).
TabloDeleteCancelHandler (GET /tablos/{id}/delete-cancel):
- After common steps, render TabloDeleteButtonFragment(tablo, csrfToken).
TabloDeleteHandler (POST /tablos/{id}/delete):
- After common steps, call deps.Queries.DeleteTablo(ctx, tabloID).
- HTMX path: 200 + HX-Redirect: / header. Non-HTMX path: 303 to /.
- Detail-page delete (HX-Request from detail page): same HX-Redirect: / behavior — the test TestTabloDelete checks for either deletion + redirect; document and pick one.
Router wiring (extend the protected group from Plan 02; MUST be added AFTER `r.Get("/tablos/new", ...)` and `r.Post("/tablos", ...)`):
- 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))
Pitfall 1: static `/tablos/new` already declared before parametric `/tablos/{id}` per Plan 02 ordering; verify by reading router.go before editing.
</behavior>
<action>
Append nine handler constructors to backend/internal/web/handlers_tablos.go per the behavior block. Each constructor returns http.HandlerFunc closing over deps. Factor the common preamble (Authed extract, uuid.Parse, GetTabloByID, ownership check) into a private helper `loadOwnedTablo(w, r, deps) (sqlc.Tablo, *auth.User, bool)` — returns false when any check fails (the helper has already written the 404/500 response). All nine specific-tablo handlers call this helper first.
Use imports: "github.com/go-chi/chi/v5", "github.com/google/uuid", "errors", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v5/pgtype". csrf token via `csrf.Token(r)`. PostFormValue for title/description (Pitfall 2). pgtype.Text{Valid: s != "", String: s} for nullable description.
For TabloUpdateHandler: emit `<input type="hidden" name="_zone" value="title">` or `..."desc"` from the respective edit fragment template (Task 1 must already do this — if not, fix Task 1's templates to add the hidden _zone field). Handler reads `zone := r.PostFormValue("_zone")` and renders TabloTitleDisplay if zone == "title", TabloDescDisplay if zone == "desc", else default to TabloTitleDisplay. Non-HTMX path: 303 redirect to /tablos/{id} (full page reload renders the new state).
For TabloDeleteHandler HTMX response: `w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK)`. Non-HTMX: `http.Redirect(w, r, "/", http.StatusSeeOther)`.
Edit backend/internal/web/router.go: append the nine new routes inside the protected group AFTER the existing `r.Post("/tablos", ...)` line and BEFORE the closing `})`. Order: `r.Get("/tablos/{id}", ...)`, `r.Post("/tablos/{id}", ...)`, then the six sub-route GETs, then `r.Post("/tablos/{id}/delete", ...)`.
After edits run `just test`. All 10 TABLO tests must be green. Phase 1/2 tests must remain green.
</action>
<verify>
<automated>cd backend && just generate && grep -q "func TabloDetailHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloUpdateHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteConfirmHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteCancelHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteHandler" internal/web/handlers_tablos.go && grep -q "uuid.Parse(chi.URLParam" internal/web/handlers_tablos.go && grep -q "tablo.UserID != user.ID" internal/web/handlers_tablos.go && grep -q "http.NotFound" internal/web/handlers_tablos.go && grep -q "HX-Redirect" internal/web/handlers_tablos.go && grep -q "deps.Queries.UpdateTablo" internal/web/handlers_tablos.go && grep -q "deps.Queries.DeleteTablo" internal/web/handlers_tablos.go && grep -q "r.Get(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Get(\"/tablos/{id}/edit-title\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}/delete\"" internal/web/router.go && awk '/r.Get\("\/tablos\/new"/ {newln=NR} /r.Get\("\/tablos\/\{id\}"/ {idln=NR} END {exit !(newln>0 && idln>0 && newln<idln)}' internal/web/router.go && go build ./... && go test ./internal/web/... -run TestTablo -count=1</automated>
</verify>
<acceptance_criteria>
- handlers_tablos.go declares all nine new handler functions (signatures match the interfaces block).
- handlers_tablos.go contains a single ownership-and-load helper used by all nine new handlers (grep "loadOwnedTablo" or equivalent appears >=2 occurrences: the declaration + at least one caller).
- Every specific-tablo handler returns 404 on (a) uuid.Parse error, (b) pgx.ErrNoRows, (c) UserID mismatch — verified by passing TestTabloDetail_NonOwner and TestTabloDetail_InvalidID.
- TabloUpdateHandler calls deps.Queries.UpdateTablo with the parsed ID and trimmed title; pgtype.Text used for description.
- TabloDeleteHandler calls deps.Queries.DeleteTablo and sets HX-Redirect: / on HTMX path, 303 to / on non-HTMX path.
- router.go contains the nine new routes inside the existing RequireAuth group. The line declaring `r.Get("/tablos/new", ...)` appears BEFORE the line declaring `r.Get("/tablos/{id}", ...)` (Pitfall 1) — verified by the awk check in the verify block.
- `go build ./...` exits 0.
- `go test ./internal/web/... -run TestTablo -count=1` exits 0 — all 10 named TABLO tests pass.
- Phase 1/2 regression: `go test ./internal/web/...` exits 0 across all tests (no broken auth/signup/csrf tests).
</acceptance_criteria>
<done>Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Human verify detail + edit + delete + ownership 404</name>
<what-built>
Tablo detail page with inline edit for title and description, and inline-confirmation delete. Non-owner and bad-UUID requests return 404. HTMX paths swap fragments without full reloads; non-HTMX paths POST and redirect cleanly.
</what-built>
<how-to-verify>
1. cd backend && just dev.
2. Log in as user A. Create two tablos.
3. Click "View" on a tablo card → detail page renders with title and description.
4. Click the title → input appears in place; change text; click "Save changes" — display swaps back to the new title without full reload. Click "Discard changes" instead to confirm it restores the original.
5. Repeat for description.
6. From the detail page, click "Delete" → inline confirmation "Delete tablo? This cannot be undone." [Yes, delete] [Keep tablo] appears in place.
7. Click "Keep tablo" → confirmation collapses, Delete button restored.
8. Click "Delete" → "Yes, delete" → page navigates to / (HX-Redirect); tablo is gone from list.
9. Repeat delete from dashboard card — confirmation appears inside the card's delete-zone, "Yes, delete" removes the card from the list.
10. Ownership test: open a private window, sign up as user B. Try GET /tablos/{user-A-tablo-uuid} → 404 page with copy "This tablo doesn't exist or you don't have access.".
11. Bad UUID test: GET /tablos/not-a-uuid → 404.
12. CSRF test: view-source the detail page; confirm `_csrf` hidden input is rendered in every form (edit-title form, edit-desc form, delete-confirm form).
13. Non-JS fallback: disable JavaScript; from detail page submit edit form via the actual Submit button — page reloads to /tablos/{id} with updated content. Submit delete via the actual button — redirects to /.
</how-to-verify>
<resume-signal>Type "approved" if all 13 checks pass, or describe issues.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → /tablos/{id}* | URL parameter (UUID) is untrusted; must be parsed and ownership-checked on every request. |
| Browser → POST /tablos/{id} | Untrusted form input (title, description, _zone, _csrf). |
| Browser → POST /tablos/{id}/delete | Authenticated state-changing request guarded by CSRF token. |
| Handler → Postgres | Parameterised via sqlc UpdateTabloParams / DeleteTablo($1). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-03-01 | Elevation of privilege | Cross-tenant tablo access | mitigate | loadOwnedTablo helper enforces tablo.UserID == user.ID; non-owner gets http.NotFound (D-04: 404 not 403 to avoid existence leak). Verified by TestTabloDetail_NonOwner. |
| T-03-03-02 | Information disclosure | UUID enumeration | mitigate | UUIDs are gen_random_uuid (crypto-random) + 404 response for non-owner masks existence. |
| T-03-03-03 | Tampering | UUID injection / path traversal via {id} | mitigate | uuid.Parse rejects non-UUID strings → http.NotFound before any DB query. Verified by TestTabloDetail_InvalidID. |
| T-03-03-04 | Tampering | CSRF on POST /tablos/{id} and /tablos/{id}/delete | mitigate | gorilla/csrf middleware validates _csrf field; @ui.CSRFField rendered inside every form template (edit-title, edit-desc, delete-confirm). |
| T-03-03-05 | DoS | over-long title on edit | mitigate | TabloUpdateHandler validates len(title) <= 255 with explicit 422 + field error before DB write. |
| T-03-03-06 | XSS | reflected user content in title/description on detail page and edit fragments | mitigate | templ auto-escapes `{ tablo.Title }`, `{ tablo.Description.String }`. No templ.Raw. Inline color style attribute also escaped. |
| T-03-03-07 | Repudiation | edit/delete not logged | accept | v1 scope — created_at/updated_at on the row; no audit table planned for v1. |
| T-03-03-08 | Tampering | _zone hidden field abuse | accept | _zone selects which display fragment to render; misuse only affects the response body shape, not DB state — DB UPDATE always uses authenticated user's row regardless of _zone value. |
| T-03-03-09 | Spoofing | Delete bypass via direct POST | mitigate | RequireAuth middleware + loadOwnedTablo helper run on /tablos/{id}/delete; unauthed → /login, non-owner → 404. CSRF middleware also blocks cross-origin POSTs. |
</threat_model>
<verification>
- All 10 TABLO tests pass: `go test ./internal/web/... -run TestTablo -count=1` exits 0.
- Phase 1/2 regression suite still green: `go test ./internal/web/...` exits 0.
- Manual browser verification (Task 3 checkpoint) passes all 13 sub-checks, including ownership 404 + bad-UUID 404 + non-JS fallbacks.
- Static-before-parametric route ordering verified in router.go (Pitfall 1).
- `grep -c "http.NotFound" backend/internal/web/handlers_tablos.go` >= 3 (uuid parse failure + pgx.ErrNoRows + ownership mismatch).
</verification>
<success_criteria>
1. TABLO-03: GET /tablos/{id} renders detail page for owner; non-owner and malformed UUID get 404.
2. TABLO-04: Inline edit of title and description updates the row, refreshes the affected fragment, and sets updated_at = now() (Pitfall 7).
3. TABLO-05: Inline confirmation gates delete; confirming hard-deletes the row and redirects to /; cancel restores the original button.
4. TABLO-06: Every mutating route works under both HTMX and non-HTMX clients (303 fallback verified manually).
5. All ten TABLO tests in handlers_tablos_test.go are green.
6. No Phase 1/2 regressions.
</success_criteria>
<output>
After completion, create `.planning/phases/03-tablos-crud/03-03-SUMMARY.md` documenting: nine new handler functions and the loadOwnedTablo helper, eight new templ components, nine new router lines (in declared order), final TABLO test status (10/10 green), and notes on the _zone hidden-field approach used for the unified POST /tablos/{id} update endpoint.
</output>

View file

@ -0,0 +1,702 @@
# Phase 3: Tablos CRUD - Pattern Map
**Mapped:** 2026-05-14
**Files analyzed:** 9 new/modified files
**Analogs found:** 9 / 9
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/migrations/0003_tablos.sql` | migration | batch | `backend/migrations/0002_auth.sql` | exact |
| `backend/internal/db/queries/tablos.sql` | query | CRUD | `backend/internal/db/queries/users.sql` | role-match |
| `backend/internal/web/handlers_tablos.go` | handler | request-response | `backend/internal/web/handlers_auth.go` | exact |
| `backend/templates/tablos.templ` | component | request-response | `backend/templates/auth_signup.templ` + `backend/templates/index.templ` | exact |
| `backend/internal/web/router.go` | config | request-response | `backend/internal/web/router.go` (self — modify) | self |
| `backend/cmd/web/main.go` | config | request-response | `backend/cmd/web/main.go` (self — modify) | self |
| `backend/templates/layout.templ` | component | request-response | `backend/templates/layout.templ` (self — modify footer) | self |
| `backend/templates/index.templ` | component | request-response | `backend/templates/index.templ` (self — delete or empty) | self |
| `backend/internal/web/ui/button.css` | config | — | `backend/internal/web/ui/button.css` (self — add variants) | self |
---
## Pattern Assignments
### `backend/migrations/0003_tablos.sql` (migration, batch)
**Analog:** `backend/migrations/0002_auth.sql`
**Header comment and goose Up/Down pattern** (lines 1-30 of analog):
```sql
-- migrations/0002_auth.sql
-- Phase 2: Authentication — users + sessions tables.
-- +goose Up
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
-- +goose Down
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
-- citext + pgcrypto left in place — extensions are cheap and shared across migrations.
```
**Copy conventions:**
- Header comment: `-- migrations/NNNN_name.sql` then `-- Phase N: Description`
- UUID PKs via `DEFAULT gen_random_uuid()` (no import needed — pgcrypto loaded in 0002)
- `timestamptz NOT NULL DEFAULT now()` for all timestamp columns
- `ON DELETE CASCADE` for FK to users(id)
- Named index pattern: `tablename_columnname_idx`
- `-- +goose Down` drops tables in reverse dependency order
- Comment on retained extensions at Down block end
---
### `backend/internal/db/queries/tablos.sql` (query, CRUD)
**Analog:** `backend/internal/db/queries/users.sql`
**Full analog file** (lines 1-10):
```sql
-- name: InsertUser :one
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, created_at, updated_at;
-- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at, updated_at
FROM users
WHERE email = $1;
```
**Copy conventions:**
- `-- name: FuncName :one/:many/:exec` annotation on every query (sqlc requires it)
- Explicit column list in SELECT (no `SELECT *`)
- `RETURNING` on INSERT/UPDATE to get the full row back without a second query
- `$1, $2, ...` positional params (pgx/v5 style)
- `:exec` for DELETE (no return value needed)
- `:one` for INSERT/UPDATE RETURNING + single-row SELECT
- `:many` for list queries
**Note on nullable columns:** `description text` and `color text` are nullable. sqlc with pgx/v5 generates `pgtype.Text` for these. Templates must check `.Valid` before using `.String`.
---
### `backend/internal/web/handlers_tablos.go` (handler, request-response)
**Analog:** `backend/internal/web/handlers_auth.go`
**Imports pattern** (lines 1-18):
```go
package web
import (
"errors"
"log/slog"
"net"
"net/http"
"net/mail"
"strings"
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/templates"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
```
**Deps struct pattern** (lines 25-30):
```go
// AuthDeps holds the dependencies shared by all auth handlers.
type AuthDeps struct {
Queries *sqlc.Queries
Store *auth.Store
Secure bool
Limiter *auth.LimiterStore
}
```
For tablos, copy this as:
```go
// TablosDeps holds dependencies for all tablo handlers.
type TablosDeps struct {
Queries *sqlc.Queries
}
```
**Handler constructor pattern** (lines 50-55):
```go
// SignupPageHandler renders the GET /signup page with an empty form.
func SignupPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w)
}
}
```
Every handler returns `http.HandlerFunc` from a constructor that closes over the deps struct.
**HTMX-aware redirect pattern** (lines 137-143):
```go
// HTMX form submissions receive HX-Redirect so HTMX handles navigation client-side.
// Plain (no-JS) form submissions receive 303 See Other (NOT 302 — Pitfall 9).
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
```
**Fragment vs full-page dispatch pattern** (lines 149-157):
```go
func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if r.Header.Get("HX-Request") == "true" {
_ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
} else {
_ = templates.SignupPage(form, errs, csrf.Token(r)).Render(r.Context(), w)
}
}
```
**Form value reading** (lines 71-72 and 188-189):
```go
// Always r.PostFormValue, never r.Body — gorilla/csrf consumes the body.
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")
```
**pgx.ErrNoRows handling** (lines 227-231):
```go
user, err := deps.Queries.GetUserByEmail(ctx, normalized)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// handle not found
return
}
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
```
**auth.Authed usage** (lines 248-251):
```go
// Extract authenticated session — RequireAuth middleware guarantees this is set.
sess, _, ok := auth.Authed(r.Context())
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
```
For tablo handlers the three-value form is:
```go
_, user, _ := auth.Authed(r.Context())
// user is guaranteed non-nil inside RequireAuth-gated routes
```
**Ownership check pattern** (new for Phase 3 — derived from D-04):
```go
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
}
```
**slog error logging pattern** (lines 306-310):
```go
if err := deps.Store.Delete(r.Context(), sess.ID); err != nil {
slog.Default().Error("logout: delete session", "session_id", sess.ID, "err", err)
// Continue — partial invalidation is better than leaving state intact.
}
```
---
### `backend/templates/tablos.templ` (component, request-response)
**Analog:** `backend/templates/auth_signup.templ` (form + fragment pattern) and `backend/templates/index.templ` (dashboard page with Card + Button + HTMX attrs)
**Package declaration and imports** (auth_signup.templ lines 1-3, index.templ lines 1-8):
```go
package templates
import (
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
```
**Full-page template wrapping Layout** (auth_signup.templ lines 8-19):
```go
templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string) {
@Layout("Sign up", nil, csrfToken) {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
@SignupFormFragment(form, errs, csrfToken)
</div>
}
</div>
}
}
```
**Fragment component with HTMX form** (auth_signup.templ lines 25-72):
```go
templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) {
<form
id="signup-form"
method="POST"
action="/signup"
hx-post="/signup"
hx-target="#signup-form"
hx-swap="outerHTML"
class="space-y-5"
>
@ui.CSRFField(csrfToken)
@GeneralError(errs.General)
...
@ui.Button(ui.ButtonProps{
Label: "Create account",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</form>
}
```
**Button with HTMX attributes** (index.templ lines 27-39):
```go
@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",
},
})
```
**ui.Card with arbitrary attrs** (index.templ lines 21-22):
```go
@ui.Card(nil) {
// children here
}
// With attrs:
@ui.Card(templ.Attributes{"id": "tablo-123"}) {
// children here
}
```
**pgtype.Text null check in templates** (from RESEARCH Pitfall 6):
```go
if tablo.Description.Valid && tablo.Description.String != "" {
<p class="mt-2 text-base text-slate-600">{ tablo.Description.String }</p>
}
```
**FieldError and GeneralError** (auth_form_errors.templ lines 5-19):
```go
templ FieldError(msg string) {
if msg != "" {
<p class="mt-1 text-sm text-red-700">{ msg }</p>
}
}
templ GeneralError(msg string) {
if msg != "" {
<div class="mb-4 rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{ msg }
</div>
}
}
```
**OOB swap template shape** (from RESEARCH Pattern 4 — new pattern, no existing analog):
```go
// TabloCardWithOOBFormClear renders a card plus an OOB element to clear #create-form-slot.
// The OOB div MUST be a top-level sibling of the card, not nested (RESEARCH Pitfall 5).
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
@TabloCard(tablo, csrfToken)
<div id="create-form-slot" hx-swap-oob="true"></div>
}
```
---
### `backend/internal/web/router.go` (config, request-response — MODIFIED)
**Analog:** Self. Current file `backend/internal/web/router.go`.
**Current protected group pattern** (lines 74-78):
```go
// Protected routes — require an authenticated session (D-23, AUTH-05).
r.Group(func(r chi.Router) {
r.Use(auth.RequireAuth)
r.Get("/", IndexHandler())
r.Post("/logout", LogoutHandler(deps))
})
```
**NewRouter signature pattern** (line 47):
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
```
**What to add — NewRouter signature change:**
```go
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
```
**What to add — protected group extension:**
```go
r.Group(func(r chi.Router) {
r.Use(auth.RequireAuth)
r.Get("/", TablosListHandler(tabloDeps)) // replaces IndexHandler
r.Post("/logout", LogoutHandler(deps))
// Static segment BEFORE parametric — chi static takes precedence when declared first
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))
})
```
**Route ordering rule:** `GET /tablos/new` MUST be declared before `GET /tablos/{id}`. chi v5 resolves static segments before parametric at the same depth, but explicit declaration order is safest.
---
### `backend/cmd/web/main.go` (config — MODIFIED)
**Analog:** Self. Current file `backend/cmd/web/main.go`.
**Current deps construction and router call** (lines 78-80):
```go
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
router := web.NewRouter(pool, "./static", deps, csrfKey, env)
```
**What to add:**
```go
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
tabloDeps := web.TablosDeps{Queries: q}
router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env)
```
Note: `tabloDeps` shares the same `*sqlc.Queries` instance (`q`) as `deps`. No new DB pool needed.
---
### `backend/templates/layout.templ` (component — MODIFIED: footer text only)
**Analog:** Self. Current file `backend/templates/layout.templ`.
**Current footer** (line 51):
```go
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
Phase 2 · Authentication
</footer>
```
**Change to:**
```go
<footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
Phase 3 · Tablos
</footer>
```
No other layout changes. All structural classes, container width, asset paths, and logout form are locked by UI-SPEC.
---
### `backend/templates/index.templ` (component — DELETED or emptied)
**Analog:** Self. Current content replaced by tablo dashboard in `tablos.templ`.
`IndexHandler()` in `handlers.go` (the Phase 1 placeholder) should also be removed or replaced. The `GET /` route in `router.go` is reassigned to `TablosListHandler(tabloDeps)`.
The HTMX demo (`/demo/time` + `DemoTimeHandler`) stays — it is not removed in Phase 3.
---
### `backend/internal/web/ui/button.css` (config — MODIFIED: add danger + neutral variants)
**Analog:** Self. Current file `backend/internal/web/ui/button.css`.
**Existing variant pattern** (lines 29-48):
```css
.ui-button-solid-default-md {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
background-color: #2563eb;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 600;
color: #ffffff;
}
.ui-button-solid-default-md:hover {
background-color: #1d4ed8;
}
.ui-button-solid-default-md:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
```
**CSS conventions to follow (line 3-4 comment):**
- No CSS nesting (`&:hover`) — all pseudo-class rules are top-level selectors
- Class name format: `.ui-button-{tone}-{variant}-{size}` (matches `ButtonClass()` in variants.go)
**New classes needed per UI-SPEC:**
`ui-button-solid-danger-md` — for delete confirmation button (red, solid):
```css
.ui-button-solid-danger-md {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
background-color: #dc2626;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 600;
color: #ffffff;
}
.ui-button-solid-danger-md:hover {
background-color: #b91c1c;
}
.ui-button-solid-danger-md:focus-visible {
outline: 2px solid #dc2626;
outline-offset: 2px;
}
```
`ui-button-soft-neutral-md` — for cancel buttons (ghost-style, muted):
```css
.ui-button-soft-neutral-md {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
background-color: transparent;
border: 1px solid #cbd5e1;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 500;
color: #475569;
}
.ui-button-soft-neutral-md:hover {
background-color: #f1f5f9;
}
.ui-button-soft-neutral-md:focus-visible {
outline: 2px solid #64748b;
outline-offset: 2px;
}
```
**Usage in templ:**
```go
@ui.Button(ui.ButtonProps{
Label: "Delete",
Variant: ui.ButtonVariantDanger,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
@ui.Button(ui.ButtonProps{
Label: "Cancel",
Variant: ui.ButtonVariantNeutral,
Tone: ui.ButtonToneSoft,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/{id}/delete-cancel",
"hx-target": "#tablo-delete-zone",
"hx-swap": "outerHTML",
},
})
```
Note: `ButtonVariantDanger` and `ButtonVariantNeutral` already exist in `variants.go` (lines 20 and 18). `ButtonToneSoft` exists at line 32. The CSS classes are the only missing piece.
---
## Shared Patterns
### Authentication — `auth.Authed` extraction
**Source:** `backend/internal/auth/middleware.go` lines 26-32
**Apply to:** All handler functions in `handlers_tablos.go`
```go
// Authed extracts the session and user from the request context.
// Returns (session, user, true) when a valid session is present.
func Authed(ctx context.Context) (*Session, *User, bool) {
a, ok := ctx.Value(sessionKey).(*authed)
if !ok || a == nil {
return nil, nil, false
}
return a.Session, a.User, true
}
```
Inside `RequireAuth`-gated routes, the user is always present. Use the short form:
```go
_, user, _ := auth.Authed(r.Context())
```
### HTMX-aware redirect helper
**Source:** `backend/internal/auth/middleware.go` lines 119-126
**Apply to:** `TabloDeleteHandler` (post-delete navigation to `/`) and any handler that redirects
```go
func redirectTo(w http.ResponseWriter, r *http.Request, target string) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", target)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
```
This is an unexported function in the `auth` package. Replicate it inline in handlers or extract a shared helper in the `web` package. Do not add a new dependency on `auth` internals.
### CSRF token injection
**Source:** `backend/internal/web/ui/csrf_field.templ` lines 7-9
**Apply to:** Every `<form method="POST">` in `tablos.templ`
```go
templ CSRFField(token string) {
<input type="hidden" name="_csrf" value={ token }/>
}
```
Call as `@ui.CSRFField(csrfToken)` as the first child of every POST form.
### gorilla/csrf token extraction
**Source:** `backend/internal/web/handlers_auth.go` (throughout — e.g. line 53)
**Apply to:** Every handler that renders a template with a form
```go
csrf.Token(r) // import "github.com/gorilla/csrf"
```
Pass as `csrfToken` parameter to all template constructors.
### Content-Type header
**Source:** `backend/internal/web/handlers_auth.go` lines 52, 162, 150
**Apply to:** Every handler that writes HTML
```go
w.Header().Set("Content-Type", "text/html; charset=utf-8")
```
Set before calling `.Render()`. For error responses, set before `w.WriteHeader(status)`.
### UUID param extraction
**Source:** RESEARCH.md Pattern 6 (verified against go.mod)
**Apply to:** All tablo handlers that accept `{id}` URL param
```go
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
}
```
### Dual-target HTMX create response (HX-Retarget + hx-swap-oob)
**Source:** RESEARCH.md Pattern 4 (new pattern — no prior codebase analog)
**Apply to:** `TablosCreateHandler` success path only
```go
// Successful create: retarget to #tablos-list, prepend card, OOB-clear form slot.
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(r.Context(), w)
```
The OOB element MUST be a top-level sibling in the response body, not nested (RESEARCH Pitfall 5).
### Input validation via r.PostFormValue
**Source:** `backend/internal/web/handlers_auth.go` lines 71-72
**Apply to:** All POST handlers in `handlers_tablos.go`
```go
// Pitfall 2: gorilla/csrf consumes r.Body. Always use r.PostFormValue, never
// r.Body / io.ReadAll. r.PostFormValue calls r.ParseForm which caches the result.
title := strings.TrimSpace(r.PostFormValue("title"))
description := r.PostFormValue("description")
color := strings.TrimSpace(r.PostFormValue("color"))
```
---
## No Analog Found
No files are entirely without analog. All new files have at least a role-match or structural analog in the codebase. The only genuinely new patterns (OOB swap, HX-Retarget/HX-Reswap, ownership 404 check, UUID param extraction) are covered in the Shared Patterns section above and documented in RESEARCH.md with verification sources.
---
## Metadata
**Analog search scope:** `backend/internal/web/`, `backend/templates/`, `backend/migrations/`, `backend/internal/db/queries/`, `backend/internal/auth/`, `backend/cmd/web/`, `backend/internal/web/ui/`
**Files read:** 18
**Pattern extraction date:** 2026-05-14

View file

@ -701,19 +701,19 @@ Phase 3 is code/migration/template changes only. All runtime dependencies were e
---
## Open Questions
## Open Questions (RESOLVED)
1. **IndexHandler vs TablosListHandler for GET /**
1. RESOLVED: **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`**
2. RESOLVED: **`_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**
3. RESOLVED: **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.

View file

@ -0,0 +1,85 @@
---
phase: 03
slug: tablos-crud
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-05-14
---
# Phase 03 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| 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 ./...`) |
| **Estimated runtime** | ~30 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go test ./internal/web/... -run TestTablo`
- **After every plan wave:** Run `just test`
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 30 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 03-list | 01 | 1 | TABLO-01 | T-ownership | Authed user sees only their tablos | integration | `go test ./internal/web/... -run TestTabloList` | ❌ W0 | ⬜ pending |
| 03-list-empty | 01 | 1 | TABLO-01 | — | Empty state renders CTA | integration | `go test ./internal/web/... -run TestTabloList_Empty` | ❌ W0 | ⬜ pending |
| 03-create | 01 | 1 | TABLO-02 | T-csrf | CSRF token validated on POST | integration | `go test ./internal/web/... -run TestTabloCreate` | ❌ W0 | ⬜ pending |
| 03-create-validation | 01 | 1 | TABLO-02 | T-input | Empty title returns 422 + errors | integration | `go test ./internal/web/... -run TestTabloCreate_Validation` | ❌ W0 | ⬜ pending |
| 03-detail-owner | 02 | 1 | TABLO-03 | T-ownership | Owner sees detail page | integration | `go test ./internal/web/... -run TestTabloDetail_Owner` | ❌ W0 | ⬜ pending |
| 03-detail-nonowner | 02 | 1 | TABLO-03 | T-ownership | Non-owner gets 404 (not 403) | integration | `go test ./internal/web/... -run TestTabloDetail_NonOwner` | ❌ W0 | ⬜ pending |
| 03-detail-invalid | 02 | 1 | TABLO-03 | T-input | Invalid UUID gets 404 | integration | `go test ./internal/web/... -run TestTabloDetail_InvalidID` | ❌ W0 | ⬜ pending |
| 03-edit | 02 | 2 | TABLO-04 | T-csrf | CSRF validated; returns display fragment | integration | `go test ./internal/web/... -run TestTabloUpdate` | ❌ W0 | ⬜ pending |
| 03-delete-confirm | 02 | 2 | TABLO-05 | — | Returns confirm fragment | integration | `go test ./internal/web/... -run TestTabloDeleteConfirm` | ❌ W0 | ⬜ pending |
| 03-delete | 02 | 2 | TABLO-05 | T-csrf | CSRF validated; row removed | integration | `go test ./internal/web/... -run TestTabloDelete` | ❌ W0 | ⬜ pending |
| 03-htmx-fragment | 01 | 1 | TABLO-06 | — | HX-Request=true → fragment, not full page | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending |
| 03-htmx-degrade | 01 | 1 | TABLO-06 | — | Non-HTMX POST → 303 redirect | integration | part of TestTabloCreate | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `backend/internal/web/handlers_tablos_test.go` — integration tests for all TABLO-01..06 paths (follows `handlers_auth_test.go` pattern with real Postgres via `setupTestDB`)
- [ ] `backend/internal/db/queries/tablos.sql` — must exist and `just generate` must complete before tests compile
- [ ] `backend/migrations/0003_tablos.sql` — goose picks this up automatically during `setupTestDB` (`goose.Up`)
---
## Security Threat Model
### Applicable ASVS Categories (L1)
| ASVS Category | Applies | 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 — new** | Ownership check in every handler; 404 for non-owner (D-04) |
| V5 Input Validation | **yes — new** | title required + max 255; color max 32 chars |
| V6 Cryptography | no new requirements | UUID PKs are `gen_random_uuid()` (crypto-random) |
### Threats
| Threat | STRIDE | Mitigation |
|--------|--------|------------|
| Unauthorized tablo read/write | Elevation of privilege | `auth.Authed(ctx)` + `tablo.UserID != user.ID → http.NotFound` in every handler |
| CSRF on state-changing forms | Tampering | `@ui.CSRFField(csrfToken)` in every form; gorilla/csrf rejects missing token |
| Over-long title causes DB error | DoS/Data | `len(title) > 255` → 422 before DB write |
| UUID injection (non-UUID in {id}) | Tampering | `uuid.Parse(id)` returns error → 404 immediately |