From 181ae7936904495d0dd585e364e2cf7a629edd11 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 09:31:59 +0200 Subject: [PATCH] feat(04-02): TasksDeps, task handlers, router task routes - Add handlers_tasks.go: TasksDeps, TaskNewFormHandler, TaskCancelNewHandler, TaskCreateHandler, TaskShowHandler, TaskDeleteConfirmHandler, TaskDeleteHandler, plus stub Edit/Update/Reorder handlers - Add task routes to router.go (static before parametric per Pitfall 1) - Add TasksDeps param to NewRouter; update main.go and all test callers - Move TaskColumns/TaskColumnLabels to templates package to avoid import cycle --- backend/cmd/web/main.go | 3 +- backend/internal/web/csrf_test.go | 2 +- backend/internal/web/handlers_auth_test.go | 4 +- backend/internal/web/handlers_tablos_test.go | 2 +- backend/internal/web/handlers_tasks.go | 303 +++++++++++++++++++ backend/internal/web/handlers_tasks_test.go | 20 +- backend/internal/web/handlers_test.go | 6 +- backend/internal/web/router.go | 15 +- 8 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 backend/internal/web/handlers_tasks.go diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 4a1469a..7986571 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -77,8 +77,9 @@ func main() { deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl} tabloDeps := web.TablosDeps{Queries: q} + taskDeps := web.TasksDeps{Queries: q} - router := web.NewRouter(pool, "./static", deps, tabloDeps, csrfKey, env) + router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env) srv := &http.Server{ Addr: ":" + port, diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 04be53f..c81f712 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, csrfKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, csrfKey, "dev", "localhost") } // extractCSRFToken performs a GET request and extracts the _csrf token from the diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index b0f0904..7479597 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -33,14 +33,14 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // newTestRouterWithLimiter builds a router with an injected LimiterStore, // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // getCSRFToken performs a GET request to path and extracts the CSRF token diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 9ada092..2727b89 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -26,7 +26,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // loginUser signs up a user and returns the session cookie set after signup. diff --git a/backend/internal/web/handlers_tasks.go b/backend/internal/web/handlers_tasks.go new file mode 100644 index 0000000..5527e17 --- /dev/null +++ b/backend/internal/web/handlers_tasks.go @@ -0,0 +1,303 @@ +package web + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "backend/internal/auth" + "backend/internal/db/sqlc" + "backend/templates" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// TasksDeps holds dependencies for all task handlers. +type TasksDeps struct { + Queries *sqlc.Queries +} + +// validTaskStatuses is the set of accepted status string values. +var validTaskStatuses = map[string]sqlc.TaskStatus{ + "todo": sqlc.TaskStatusTodo, + "in_progress": sqlc.TaskStatusInProgress, + "in_review": sqlc.TaskStatusInReview, + "done": sqlc.TaskStatusDone, +} + +// parseTaskStatus parses a status string and returns the sqlc.TaskStatus. If +// the string is not a valid status, it defaults to TaskStatusTodo. +func parseTaskStatus(s string) sqlc.TaskStatus { + if ts, ok := validTaskStatuses[s]; ok { + return ts + } + return sqlc.TaskStatusTodo +} + +// loadOwnedTabloForTask is the shared preamble for all /tablos/{id}/tasks/{task_id}* +// handlers. It calls loadOwnedTablo for tablo ownership verification, then parses +// the {task_id} URL param and fetches the task (verifying it belongs to the tablo). +// Returns (tablo, task, user, true) on success. On failure it writes the appropriate +// HTTP response and returns false; callers must return immediately. +func loadOwnedTabloForTask(w http.ResponseWriter, r *http.Request, deps TasksDeps) (sqlc.Tablo, sqlc.Task, *auth.User, bool) { + // Re-use TablosDeps to call the existing loadOwnedTablo helper. + tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return sqlc.Tablo{}, sqlc.Task{}, nil, false + } + + taskID, err := uuid.Parse(chi.URLParam(r, "task_id")) + if err != nil { + http.NotFound(w, r) + return sqlc.Tablo{}, sqlc.Task{}, nil, false + } + + task, err := deps.Queries.GetTaskByID(r.Context(), sqlc.GetTaskByIDParams{ + ID: taskID, + TabloID: tablo.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return sqlc.Tablo{}, sqlc.Task{}, nil, false + } + slog.Default().Error("tasks: GetTaskByID failed", "id", taskID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return sqlc.Tablo{}, sqlc.Task{}, nil, false + } + + return tablo, task, user, true +} + +// TaskNewFormHandler handles GET /tablos/{id}/tasks/new?status={status}. +// Returns the TaskCreateForm fragment for HTMX insertion into the add-task slot. +func TaskNewFormHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return + } + statusStr := r.URL.Query().Get("status") + status := parseTaskStatus(statusStr) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TaskCreateFormFragment( + tablo.ID, + status, + templates.TaskCreateForm{}, + templates.TaskCreateErrors{}, + csrf.Token(r), + ).Render(r.Context(), w) + } +} + +// TaskCancelNewHandler handles GET /tablos/{id}/tasks/cancel-new?status={status}. +// Returns the AddTaskTrigger fragment to restore the "+ Add task" button. +func TaskCancelNewHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return + } + statusStr := r.URL.Query().Get("status") + status := parseTaskStatus(statusStr) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.AddTaskTrigger(tablo.ID, status, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TaskCreateHandler handles POST /tablos/{id}/tasks. +// Validates title, inserts the task at max+100 position, and returns the new +// TaskCard fragment (HTMX) or redirects to the tablo detail page (non-HTMX). +// +// Security invariants: +// - title validated non-empty, max 255 chars (T-04-04) +// - status validated against known TaskStatus constants (T-04-05) +// - tablo ownership verified by loadOwnedTablo (T-04-07) +func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}) + if !ok { + return + } + ctx := r.Context() + + title := strings.TrimSpace(r.PostFormValue("title")) + statusStr := r.PostFormValue("status") + status := parseTaskStatus(statusStr) + + var errs templates.TaskCreateErrors + if title == "" { + errs.Title = "Title is required" + } else if len(title) > 255 { + errs.Title = "Title must be 255 characters or fewer" + } + + if errs.Title != "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusUnprocessableEntity) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TaskCreateFormFragment( + tablo.ID, + status, + templates.TaskCreateForm{Title: title, Status: statusStr}, + errs, + csrf.Token(r), + ).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + return + } + + // Compute next position: max + 100 + maxPos, err := deps.Queries.MaxPositionByTabloAndStatus(ctx, sqlc.MaxPositionByTabloAndStatusParams{ + TabloID: tablo.ID, + Status: status, + }) + if err != nil { + slog.Default().Error("tasks create: MaxPositionByTabloAndStatus failed", "tablo_id", tablo.ID, "err", err) + errs.General = "Something went wrong. Please try again." + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TaskCreateFormFragment( + tablo.ID, + status, + templates.TaskCreateForm{Title: title, Status: statusStr}, + errs, + csrf.Token(r), + ).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + return + } + + task, err := deps.Queries.InsertTask(ctx, sqlc.InsertTaskParams{ + TabloID: tablo.ID, + Title: title, + Description: pgtype.Text{Valid: false}, + Status: status, + Position: maxPos + 100, + }) + if err != nil { + slog.Default().Error("tasks create: InsertTask failed", "tablo_id", tablo.ID, "err", err) + errs.General = "Something went wrong. Please try again." + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TaskCreateFormFragment( + tablo.ID, + status, + templates.TaskCreateForm{Title: title, Status: statusStr}, + errs, + csrf.Token(r), + ).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + return + } + + // HTMX: set retarget/reswap headers and return combined card+OOB fragment. + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Reswap", "beforeend") + w.Header().Set("HX-Retarget", "#column-"+string(status)) + _ = templates.TaskCardOOB(status, task, tablo.ID, csrf.Token(r)).Render(ctx, w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + } +} + +// TaskShowHandler handles GET /tablos/{id}/tasks/{task_id}/show. +// Returns the TaskCard fragment — used by the cancel paths after edit or delete-confirm. +func TaskShowHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TaskCard(tablo.ID, task, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TaskDeleteConfirmHandler handles GET /tablos/{id}/tasks/{task_id}/delete-confirm. +// Returns the delete confirmation fragment for HTMX outerHTML swap. +func TaskDeleteConfirmHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TaskDeleteConfirmFragment(tablo.ID, task, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TaskDeleteHandler handles POST /tablos/{id}/tasks/{task_id}/delete. +// Hard-deletes the task and returns: +// - HTMX: 200 + empty div with id="task-{task_id}" (TASK-06) +// - Non-HTMX: 303 redirect to /tablos/{id} +// +// Security: loadOwnedTabloForTask verifies tablo ownership AND task-to-tablo +// binding (T-04-03 — tablo_id in WHERE clause). +func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps) + if !ok { + return + } + + if err := deps.Queries.DeleteTask(r.Context(), sqlc.DeleteTaskParams{ + ID: task.ID, + TabloID: tablo.ID, + }); err != nil { + slog.Default().Error("tasks delete: DeleteTask failed", "id", task.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Return empty zone div so HTMX removes the card from the DOM (TASK-06). + fmt.Fprintf(w, `
`, task.ID.String()) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + } +} + +// TaskEditHandler handles GET /tablos/{id}/tasks/{task_id}/edit. +// Stub — returns 501 Not Implemented until Plan 03 implements it. +func TaskEditHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) + } +} + +// TaskUpdateHandler handles POST /tablos/{id}/tasks/{task_id}. +// Stub — returns 501 Not Implemented until Plan 03 implements it. +func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) + } +} + +// TaskReorderHandler handles POST /tablos/{id}/tasks/reorder. +// Stub — returns 501 Not Implemented until Plan 03 implements it. +func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not implemented", http.StatusNotImplemented) + } +} diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 4820cf0..d15262c 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -24,28 +24,12 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -// TasksDeps holds dependencies for task handlers. -// Stub declared here so tests compile; will be moved to handlers_tasks.go in Plan 02. -type TasksDeps struct { - Queries *sqlc.Queries -} - // newTaskTestRouter builds a router with both TablosDeps and TasksDeps wired. -// In Plan 02, TasksDeps will be passed to NewRouter; for now we pass only -// TablosDeps (which is what NewRouter currently accepts) so tests compile. -// The Task routes will be added to NewRouter in Plan 02. func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, testCSRFKey, "dev", "localhost") -} - -// insertTestTablo is a helper that creates a tablo owned by the given user for -// use in task tests. -func insertTestTablo(t *testing.T, ctx context.Context, q *sqlc.Queries, userID interface{ GetID() interface{} }) sqlc.Tablo { - t.Helper() - t.Skip("handlers_tasks not yet implemented") - return sqlc.Tablo{} + taskDeps := TasksDeps{Queries: q} + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, testCSRFKey, "dev", "localhost") } // ---- TestTasksKanbanRenders (TASK-01) ---- diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index c73a053..fdbc32c 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -66,7 +66,7 @@ func TestHealthz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -81,7 +81,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) @@ -104,7 +104,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index cafbba4..fa56d78 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -44,7 +44,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -90,6 +90,19 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps)) r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps)) r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps)) + // Task routes — static segments BEFORE parametric (Pitfall 1). + // /tablos/{id}/tasks/new and /tablos/{id}/tasks/cancel-new are static + // segments relative to /tablos/{id}/tasks/* and must come first. + r.Get("/tablos/{id}/tasks/new", TaskNewFormHandler(taskDeps)) + r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps)) + r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps)) + r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps)) + // Parametric task routes — must come after static task segments. + r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) + r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) + r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps)) + r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps)) + r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps)) }) r.Get("/healthz", HealthzHandler(pinger))