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
This commit is contained in:
Arthur Belleville 2026-05-15 09:31:59 +02:00
parent 2be4cb6bc9
commit 181ae79369
No known key found for this signature in database
8 changed files with 328 additions and 27 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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, `<div id="task-%s" class="task-card-zone"></div>`, 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)
}
}

View file

@ -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) ----

View file

@ -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)

View file

@ -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))