xtablo-source/backend/internal/web/handlers_tasks.go
Arthur Belleville 392b5321be
fix(04-CR-02): replace fmt.Fprintf in TaskDeleteHandler with TaskCardGone templ component
The raw fmt.Fprintf bypassed templ's auto-escaping pipeline and was
inconsistent with every other handler. Added TaskCardGone(taskID uuid.UUID)
to tasks.templ and updated TaskDeleteHandler to use it. Ran just generate.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 10:18:34 +02:00

508 lines
17 KiB
Go

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()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
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).
_ = templates.TaskCardGone(task.ID).Render(r.Context(), w)
return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
}
}
// TaskEditHandler handles GET /tablos/{id}/tasks/{task_id}/edit.
// Returns 200 + TaskEditFragment HTML pre-filled with existing task data (TASK-03).
// Security: loadOwnedTabloForTask verifies ownership; non-owners receive 404.
func TaskEditHandler(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.TaskEditFragment(
tablo.ID,
task,
templates.TaskUpdateForm{
Title: task.Title,
Description: task.Description.String,
},
templates.TaskUpdateErrors{},
csrf.Token(r),
).Render(r.Context(), w)
}
}
// TaskUpdateHandler handles POST /tablos/{id}/tasks/{task_id}.
// Validates title and description, updates title+description in DB, and returns:
// - HTMX: 200 + TaskCard fragment (success) or 422 + TaskEditFragment (validation error)
// - Non-HTMX: 303 redirect to /tablos/{id}
//
// Security invariants:
// - title validated non-empty, max 255 chars (T-04-04)
// - existing Status and Position preserved — only title+description change (T-04-12)
// - tablo+task ownership verified by loadOwnedTabloForTask (T-04-03)
func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps)
if !ok {
return
}
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.PostFormValue("title"))
description := r.PostFormValue("description")
var errs templates.TaskUpdateErrors
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.TaskEditFragment(
tablo.ID,
task,
templates.TaskUpdateForm{Title: title, Description: description},
errs,
csrf.Token(r),
).Render(ctx, w)
return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
return
}
// Preserve existing Status and Position — only title+description change (T-04-12).
updated, err := deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
ID: task.ID,
Title: title,
Description: pgtype.Text{String: description, Valid: description != ""},
Status: task.Status,
Position: task.Position,
})
if err != nil {
slog.Default().Error("tasks update: UpdateTask failed", "id", task.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.TaskEditFragment(
tablo.ID,
task,
templates.TaskUpdateForm{Title: title, Description: description},
errs,
csrf.Token(r),
).Render(ctx, w)
return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.TaskCard(tablo.ID, updated, csrf.Token(r)).Render(ctx, w)
return
}
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
}
}
// TaskReorderHandler handles POST /tablos/{id}/tasks/reorder.
// Reads task_id + task_col (array form fields) to update task status and position in DB,
// then returns the updated KanbanBoard outerHTML.
//
// Security invariants:
// - tablo ownership verified by loadOwnedTablo (T-04-10)
// - each task fetched via GetTaskByID before update — title/description preserved (T-04-08)
// - invalid task UUIDs silently skipped (D-05 last-write-wins)
// - task_col values validated as known TaskStatus via parseTaskStatus (T-04-09)
func TaskReorderHandler(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()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
taskIDs := r.Form["task_id"]
taskCols := r.Form["task_col"]
// Support both array-based (Sortable.js) and single-value form submissions.
// Single-value fields ("status" and "position") are used by the existing
// test scaffold — fall back to them when task_col array is absent.
if len(taskCols) == 0 {
// Single-task reorder: task_id + status + optional position.
if len(taskIDs) == 0 {
taskIDs = r.Form["task_id"]
}
statusVal := r.FormValue("status")
positionVal := r.FormValue("position")
taskCols = make([]string, len(taskIDs))
for i := range taskCols {
taskCols[i] = statusVal
}
if len(taskIDs) == 1 && positionVal != "" {
// Single-task update with explicit position.
taskID, err := uuid.Parse(taskIDs[0])
if err == nil {
existing, err := deps.Queries.GetTaskByID(ctx, sqlc.GetTaskByIDParams{
ID: taskID,
TabloID: tablo.ID,
})
if err == nil {
newStatus := parseTaskStatus(taskCols[0])
var newPos int32
if _, scanErr := fmt.Sscanf(positionVal, "%d", &newPos); scanErr != nil || newPos <= 0 {
newPos = 100
}
_, _ = deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
ID: existing.ID,
Title: existing.Title,
Description: existing.Description,
Status: newStatus,
Position: newPos,
})
}
}
tasks, _ := deps.Queries.ListTasksByTablo(ctx, tablo.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w)
return
}
}
if len(taskIDs) != len(taskCols) {
http.Error(w, "mismatched task_id and task_col arrays", http.StatusBadRequest)
return
}
// Process each task: fetch existing row (T-04-08), update status+position only.
for i, rawID := range taskIDs {
taskID, err := uuid.Parse(rawID)
if err != nil {
// Invalid UUID — skip silently (D-05).
continue
}
newStatus := parseTaskStatus(taskCols[i])
newPos := int32((i + 1) * 100)
existing, err := deps.Queries.GetTaskByID(ctx, sqlc.GetTaskByIDParams{
ID: taskID,
TabloID: tablo.ID,
})
if err != nil {
// Task not found or belongs to different tablo — skip (T-04-10).
continue
}
// Preserve title+description; only update status+position (T-04-08).
_, _ = deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
ID: existing.ID,
Title: existing.Title,
Description: existing.Description,
Status: newStatus,
Position: newPos,
})
}
tasks, err := deps.Queries.ListTasksByTablo(ctx, tablo.ID)
if err != nil {
slog.Default().Error("tasks reorder: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w)
}
}