---
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"
---
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
## 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.
@/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
@.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
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.
Task 1: Migration 0003_tablos.sql + sqlc query file + regenerate sqlc
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/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)
- 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).
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.
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 ./...
- 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.
Migration committed, query file committed, sqlc-generated files committed, build green.
Task 2: handlers_tablos_test.go — RED integration test scaffold for TABLO-01..06
backend/internal/web/handlers_tablos_test.go
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)
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 /.
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.
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"
- 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.
Test scaffold compiles; running `go test ./internal/web/... -run TestTablo` lists all 10 tests as FAIL (no implementation yet).
Task 3: Add ui-button-solid-danger-md and ui-button-soft-neutral-md CSS rules
backend/internal/web/ui/button.css
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)
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).
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
- 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).
CSS classes present, Tailwind regenerated, no other files modified.
## 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. |
- `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.
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.