3 plans in 2 waves: handler+viewmodel (wave 1), templ components + CSS restyle in parallel (wave 2). Covers DETAIL-01 and TASK-01. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
19 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20-tablo-detail-kanban-restyle | 01 | execute | 1 |
|
true |
|
|
Purpose: The tablo detail page does not exist. This plan wires the server-side data layer — authentication, ownership check, task fetch, progress computation — and registers the route.
Output: Handler file, view model file, test scaffold, updated router.go.
<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>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-RESEARCH.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-UI-SPEC.mdFrom go-backend/internal/web/handlers/auth.go:
// AuthRepository interface — does NOT include ListTasksByTablo
type AuthRepository interface {
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error
GetSessionByToken(ctx context.Context, token string) (Session, error)
DeleteSessionByToken(ctx context.Context, token string) error
CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error)
UpdateTablo(ctx context.Context, input UpdateTabloInput) error
ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error)
SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error
}
type AuthHandler struct { repo AuthRepository }
func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool)
From go-backend/internal/web/handlers/tasks.go:
// taskPageRepository — separate interface for task reads; ListTasksByTablo NOT in AuthRepository
type taskPageRepository interface {
ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]TaskRecord, error)
}
// taskMutationRepository — separate interface for task writes
type taskMutationRepository interface {
CreateTask(ctx context.Context, input CreateTaskInput) (TaskRecord, error)
GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error)
UpdateTask(ctx context.Context, input UpdateTaskInput) (TaskRecord, error)
SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error
}
type ListTasksByTabloInput = taskmodel.ListByTabloInput // {OwnerID uuid.UUID, TabloID uuid.UUID}
type TaskRecord = taskmodel.Record // {ID, TabloID, OwnerID, Title, Status, ...}
type TaskStatus = taskmodel.Status
const TaskStatusDone = taskmodel.StatusDone // "done"
const TaskStatusTodo = taskmodel.StatusTodo
const TaskStatusInProgress = taskmodel.StatusInProgress
const TaskStatusInReview = taskmodel.StatusInReview
From go-backend/internal/web/handlers/tablos.go:
// findTabloByID — existing helper, available within handlers package
func findTabloByID(tablos []TabloRecord, targetID uuid.UUID) (TabloRecord, bool)
func tabloStatusPresentation(status TabloStatus) (string, string, int, string)
// returns: (statusLabel, statusClass, progress, statusTone)
type TabloRecord = tablomodel.Record // {ID, OwnerID, Name, Color, Status, CreatedAt, UpdatedAt}
type ListTablosInput = tablomodel.ListInput // {OwnerID, Status*}
From go-backend/internal/web/handlers/in_memory_auth_repository.go (line 168):
func (r *InMemoryAuthRepository) ListTasksByTablo(_ context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
// InMemoryAuthRepository already implements this — safe to use in tests
From go-backend/router.go:
func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux := chi.NewRouter()
// ... existing routes ...
mux.Get("/tablos/{tabloID}/edit", authHandler.GetEditTabloModal())
mux.Post("/tablos/{tabloID}", authHandler.PostTabloUpdate())
mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo())
// ADD: mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage())
// Must be BEFORE the edit/post/delete routes to avoid conflicts — chi uses first-match
}
From go-backend/internal/web/views (render pattern from tablos.go renderTablosResponse):
// DashboardPageWithMainClass and DashboardContentSwapWithMainClass are used for full-width content
views.DashboardContentSwapWithMainClass(activePath, tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
// activePath for tablo detail: "/tablos" (parent, so sidebar Tablos item stays highlighted per Pitfall 3)
Task 1: Define TabloDetailViewModel and test scaffold
go-backend/internal/web/views/tablo_detail_view.go, go-backend/internal/web/handlers/tablo_detail_test.go
- go-backend/internal/web/views/tablos_view.go (TabloCardView struct pattern, normalizedView helper)
- go-backend/internal/web/views/tasks_view.go (TaskCardView, TasksKanbanColumnView pattern)
- go-backend/internal/web/handlers/tablos_test.go (test scaffold pattern for handlers)
- go-backend/internal/web/handlers/in_memory_auth_repository.go (InMemoryAuthRepository method list, lines 168+)
- TabloDetailViewModel has fields: TabloID string, TabloName string, Color string, Initial string, OwnerName string, DueDate string, StatusLabel string, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView
- TabloDetailColumnView has fields: ID string, Label string, Tasks []TabloDetailTaskView, CreateHref string
- TabloDetailTaskView has fields: ID string, Title string, DeleteHref string, EditHref string
- NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) builds 4 columns in order: "todo", "in_progress", "in_review", "done"
- computeTabloProgress(tasks []TaskRecord) int returns 0 when total==0, else (doneCount*100)/total
- Test: TestComputeTabloProgress_Empty — computeTabloProgress([]) == 0
- Test: TestComputeTabloProgress_AllDone — computeTabloProgress([done, done]) == 100
- Test: TestComputeTabloProgress_Half — computeTabloProgress([done, todo]) == 50
- Test: TestNewTabloDetailViewModel_Groups tasks into correct columns by status
- Test: TestGetTabloDetailPage_Returns200 — GET /tablos/{validTabloID} with session cookie returns 200 and tablo name in body
- Test: TestGetTabloDetailPage_Returns404 — GET /tablos/{unknownID} with session cookie returns 404
- Test: TestGetTabloDetailPage_Returns400 — GET /tablos/not-a-uuid returns 400
- Test: TestGetTabloDetailPage_Unauthenticated — GET /tablos/{validID} without session cookie returns 302 to /login
- Test: TestTabloDetailKanbanColumns — response body contains the 4 kanban column status values (todo, in_progress, in_review, done) per Pitfall 3
Create go-backend/internal/web/views/tablo_detail_view.go in package views. Define:
1. TabloDetailTaskView struct with ID, Title, DeleteHref, EditHref string fields.
2. TabloDetailColumnView struct with ID, Label, Tasks []TabloDetailTaskView, CreateHref string fields.
3. TabloDetailViewModel struct with TabloID, TabloName, Color, Initial, OwnerName, DueDate, StatusLabel, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView fields.
4. computeTabloProgress(tasks []TaskRecord) int function (package-private, used by handler and tested directly in tablo_detail_test.go via same package). Returns 0 if len==0; returns (doneCount*100)/len(tasks) where doneCount counts tasks with Status==TaskStatusDone.
5. NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) TabloDetailViewModel that:
- Sets TabloID=tablo.ID.String(), TabloName=tablo.Name, Color=tablo.Color, Initial=projectInitial(tablo.Name)
- Sets OwnerName=ownerName
- Calls tabloStatusPresentation(tablo.Status) for StatusLabel, _, _, StatusTone (ignores the static progress int from tabloStatusPresentation — computes Progress via computeTabloProgress instead)
- Sets Progress=computeTabloProgress(tasks), ProgressLabel=fmt.Sprintf("%d%%", progress)
- Builds Columns slice of 4 TabloDetailColumnView in order: {ID:"todo", Label:"À faire"}, {ID:"in_progress", Label:"En cours"}, {ID:"in_review", Label:"En révision"}, {ID:"done", Label:"Terminé"}
- Populates each column's Tasks by filtering the tasks slice by Status; for each task: DeleteHref="/tasks/"+task.ID.String(), EditHref="/tasks/"+task.ID.String()+"/edit"
- Sets CreateHref="/tablos/"+tablo.ID.String()+"/tasks/create?status="+colID for each column
- DueDate: tablo.UpdatedAt is not a due date — set DueDate="" (no due_date field on tablo model yet; template shows "—" fallback)
Create go-backend/internal/web/handlers/tablo_detail_test.go in package handlers. Import testing, net/http, net/http/httptest, strings, context, github.com/google/uuid, and xtablo-backend/internal/web/views. Use newTestRouter() from router_test.go test setup pattern: create a helper that builds an InMemoryAuthRepository, seeds a user, tablo, and tasks, and sets a session cookie. Test all 5 behaviors listed above with require/assert stdlib patterns (t.Fatal on unexpected).
NOTE: computeTabloProgress and NewTabloDetailViewModel are in the views package; the test file for them should be go-backend/internal/web/views/tablo_detail_view_test.go (not in handlers). Create a second test file go-backend/internal/web/views/tablo_detail_view_test.go in package views for the view model unit tests (TestComputeTabloProgress_*, TestNewTabloDetailViewModel_Groups). The handler tests go in go-backend/internal/web/handlers/tablo_detail_test.go in package handlers.
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" -count=1 -v 2>&1 | tail -20
go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" passes; all 4 view-layer tests are green; tablo_detail_view.go compiles with TabloDetailViewModel exported.
Task 2: GetTabloDetailPage handler + router registration
go-backend/internal/web/handlers/tablo_detail.go, go-backend/router.go
- go-backend/internal/web/handlers/tablos.go (findTabloByID, renderTablosResponse pattern, tabloStatusPresentation, authenticatedUser)
- go-backend/internal/web/handlers/tasks.go (type assertion pattern h.repo.(taskPageRepository), ListTasksByTabloInput)
- go-backend/internal/web/handlers/auth.go (AuthHandler struct, AuthRepository interface)
- go-backend/router.go (current route list — locate where to insert GET /tablos/{tabloID} before edit/post/delete)
- go-backend/internal/web/views/tablo_detail_view.go (just created — NewTabloDetailViewModel signature)
- tabloDetailRepository interface exposes ListTasksByTablo(ctx, ListTasksByTabloInput) ([]TaskRecord, error)
- GetTabloDetailPage returns http.HandlerFunc that: (a) checks auth, (b) parses tabloID as UUID, (c) ListTablos+findTabloByID, (d) type-asserts h.repo to tabloDetailRepository, (e) ListTasksByTablo, (f) fetches owner via GetPublicUserByID (falls back to empty string on error), (g) builds TabloDetailViewModel, (h) renders DashboardPageWithMainClass or DashboardContentSwapWithMainClass with activePath="/tablos" and a placeholder views.TabloDetailPage(vm) stub
- On auth failure: 302 /login
- On invalid UUID: 400 "invalid tablo id"
- On tablo not found: 404 "tablo not found"
- On type assertion failure (should never happen in production): 500 "tasks repository not configured"
- activePath passed to Dashboard helpers is "/tablos" (not "/tablos/{id}") so sidebar "Tablos" nav item stays highlighted (Pitfall 3 from RESEARCH)
- router.go adds: mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage()) — positioned before the existing mux.Get("/tablos/{tabloID}/edit", ...) line
Create go-backend/internal/web/handlers/tablo_detail.go in package handlers. Import context, net/http, github.com/google/uuid, xtablo-backend/internal/web/views.
Define tabloDetailRepository interface:
type tabloDetailRepository interface {
ListTasksByTablo(ctx context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
}
Define GetTabloDetailPage() http.HandlerFunc on *AuthHandler:
- Call h.authenticatedUser; on failure redirect /login
- uuid.Parse(r.PathValue("tabloID")); on error http.Error 400 "invalid tablo id"
- h.repo.ListTablos(ctx, ListTablosInput{OwnerID: user.ID}); on error 500
- findTabloByID(tablos, tabloID); on not-found http.Error 404 "tablo not found"
- taskRepo, ok := h.repo.(tabloDetailRepository); if !ok http.Error 500 "tasks repository not configured"
- taskRepo.ListTasksByTablo(ctx, ListTasksByTabloInput{OwnerID: user.ID, TabloID: tabloID}); on error 500
- ownerName: call h.repo.GetPublicUserByID(ctx, user.ID); use owner.DisplayName; on error ownerName=""
- vm := views.NewTabloDetailViewModel(tablo, tasks, ownerName)
- content := views.TabloDetailPage(vm) [this templ function will be a stub until Plan 02]
- Render using DashboardPageWithMainClass (non-HX) or DashboardContentSwapWithMainClass (HX) with activePath="/tablos", mainClass="flex-1 overflow-auto"
IMPORTANT: views.TabloDetailPage does not exist yet. Add a temporary stub in go-backend/internal/web/views/tablo_detail_view.go:
func TabloDetailPage(vm TabloDetailViewModel) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := fmt.Fprintf(w, "<div>%s</div>", vm.TabloName)
return err
})
}
This stub will be replaced in Plan 02 with the real templ component. Import "context", "fmt", "io", "github.com/a-h/templ" for the stub.
Update go-backend/router.go: add mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage()) immediately before the existing mux.Get("/tablos/{tabloID}/edit", ...) line.
Verify handler tests from tablo_detail_test.go pass.
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./... -run "TestGetTabloDetailPage" -count=1 -v 2>&1 | tail -30
All TestGetTabloDetailPage_* tests pass; go build ./... succeeds; GET /tablos/{validID} integration test returns 200 with tablo name in body.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| client→GET /tablos/{tabloID} | Unauthenticated or cross-user path value |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-20-01 | Elevation of Privilege | GetTabloDetailPage — IDOR via guessed UUID | mitigate | findTabloByID filters by OwnerID from authenticated session; returns 404 for any tablo not owned by authenticated user |
| T-20-02 | Spoofing | Cookie-based session auth | mitigate | h.authenticatedUser validates session cookie via GetSessionByToken + expiry check — existing pattern |
| T-20-03 | Tampering | ListTasksByTablo input | mitigate | OwnerID comes from authenticated session, not from request params; tabloID validated via uuid.Parse before use |
| T-20-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan — pure Go code using existing dependencies |
| </threat_model> |
<success_criteria>
- GET /tablos/{validUUID} (authenticated, owned) returns 200 containing tablo name
- GET /tablos/{validUUID} (authenticated, not owned) returns 404
- GET /tablos/not-a-uuid returns 400
- GET /tablos/{validUUID} (unauthenticated) returns 302 to /login
- Progress computed from task statuses, not tablo.Status field
- Full test suite (
go test ./... -count=1) remains green </success_criteria>