584 lines
20 KiB
Go
584 lines
20 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)
|
|
filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll}
|
|
if rawEtape := strings.TrimSpace(r.URL.Query().Get("etape")); rawEtape != "" {
|
|
if rawEtape == "unassigned" {
|
|
filter.Kind = templates.EtapeFilterUnassigned
|
|
} else if etapeID, err := uuid.Parse(rawEtape); err == nil {
|
|
if _, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err == nil {
|
|
filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID}
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.TaskCreateFormFragment(
|
|
tablo.ID,
|
|
status,
|
|
templates.TaskCreateForm{EtapeID: filter.TaskEtapeIDValue()},
|
|
templates.TaskCreateErrors{},
|
|
csrf.Token(r),
|
|
filter,
|
|
).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)
|
|
filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll}
|
|
if rawEtape := strings.TrimSpace(r.URL.Query().Get("etape")); rawEtape == "unassigned" {
|
|
filter.Kind = templates.EtapeFilterUnassigned
|
|
} else if rawEtape != "" {
|
|
if etapeID, err := uuid.Parse(rawEtape); err == nil {
|
|
filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.AddTaskTrigger(tablo.ID, status, csrf.Token(r), filter).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)
|
|
etapeID, _, err := parseOwnedEtapeID(r, deps.Queries, tablo.ID, r.PostFormValue("etape_id"))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll}
|
|
if etapeID.Valid {
|
|
filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: uuid.UUID(etapeID.Bytes)}
|
|
}
|
|
|
|
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")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Retarget", "#add-task-slot-"+statusStr)
|
|
w.Header().Set("HX-Reswap", "innerHTML")
|
|
}
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
_ = templates.TaskCreateFormFragment(
|
|
tablo.ID,
|
|
status,
|
|
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
|
errs,
|
|
csrf.Token(r),
|
|
filter,
|
|
).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, EtapeID: r.PostFormValue("etape_id")},
|
|
errs,
|
|
csrf.Token(r),
|
|
filter,
|
|
).Render(ctx, w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
const maxAllowedPosition = int32(2_000_000_000)
|
|
if maxPos > maxAllowedPosition-100 {
|
|
errs.General = "Column has too many tasks."
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Retarget", "#add-task-slot-"+statusStr)
|
|
w.Header().Set("HX-Reswap", "innerHTML")
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = templates.TaskCreateFormFragment(
|
|
tablo.ID,
|
|
status,
|
|
templates.TaskCreateForm{Title: title, Status: statusStr, EtapeID: r.PostFormValue("etape_id")},
|
|
errs,
|
|
csrf.Token(r),
|
|
filter,
|
|
).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,
|
|
EtapeID: etapeID,
|
|
})
|
|
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, EtapeID: r.PostFormValue("etape_id")},
|
|
errs,
|
|
csrf.Token(r),
|
|
filter,
|
|
).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), filter).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 := strings.TrimSpace(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,
|
|
EtapeID: task.EtapeID,
|
|
})
|
|
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
|
|
}
|
|
if _, err := deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
|
|
ID: existing.ID,
|
|
Title: existing.Title,
|
|
Description: existing.Description,
|
|
Status: newStatus,
|
|
Position: newPos,
|
|
EtapeID: existing.EtapeID,
|
|
}); err != nil {
|
|
slog.Default().Error("tasks reorder: UpdateTask failed (single)",
|
|
"task_id", existing.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
tasks, _, _, filter, ok := loadTasksTabData(w, r, deps.Queries, tablo)
|
|
if !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter).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).
|
|
if _, err := deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
|
|
ID: existing.ID,
|
|
Title: existing.Title,
|
|
Description: existing.Description,
|
|
Status: newStatus,
|
|
Position: newPos,
|
|
EtapeID: existing.EtapeID,
|
|
}); err != nil {
|
|
slog.Default().Error("tasks reorder: UpdateTask failed",
|
|
"task_id", existing.ID, "err", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
tasks, _, _, filter, ok := loadTasksTabData(w, r, deps.Queries, tablo)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter).Render(ctx, w)
|
|
}
|
|
}
|