xtablo-source/backend/internal/web/handlers_etapes.go
2026-05-15 22:44:50 +02:00

352 lines
12 KiB
Go

package web
import (
"errors"
"log/slog"
"net/http"
"strings"
"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"
)
type EtapesDeps struct {
Queries *sqlc.Queries
}
func buildEtapeTaskCounts(tasks []sqlc.Task) templates.EtapeTaskCounts {
counts := templates.EtapeTaskCounts{
All: len(tasks),
ByEtape: make(map[uuid.UUID]int),
}
for _, task := range tasks {
if task.EtapeID.Valid {
counts.ByEtape[uuid.UUID(task.EtapeID.Bytes)]++
continue
}
counts.Unassigned++
}
return counts
}
func taskHasEtape(task sqlc.Task, id uuid.UUID) bool {
return task.EtapeID.Valid && uuid.UUID(task.EtapeID.Bytes) == id
}
func loadTasksTabData(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) ([]sqlc.Task, []sqlc.Etape, templates.EtapeTaskCounts, templates.EtapeFilter, bool) {
ctx := r.Context()
etapes, err := q.ListEtapesByTablo(ctx, tablo.ID)
if err != nil {
slog.Default().Error("tasks tab: ListEtapesByTablo failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false
}
if etapes == nil {
etapes = []sqlc.Etape{}
}
allTasks, err := q.ListTasksByTablo(ctx, tablo.ID)
if err != nil {
slog.Default().Error("tasks tab: ListTasksByTablo failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false
}
if allTasks == nil {
allTasks = []sqlc.Task{}
}
counts := buildEtapeTaskCounts(allTasks)
filter := templates.EtapeFilter{Kind: templates.EtapeFilterAll}
tasks := allTasks
rawFilter := strings.TrimSpace(r.URL.Query().Get("etape"))
switch {
case rawFilter == "":
case rawFilter == "unassigned":
filter.Kind = templates.EtapeFilterUnassigned
tasks = tasks[:0]
for _, task := range allTasks {
if !task.EtapeID.Valid {
tasks = append(tasks, task)
}
}
default:
etapeID, err := uuid.Parse(rawFilter)
if err != nil {
http.NotFound(w, r)
return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false
}
if _, err := q.GetEtapeByID(ctx, sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false
}
slog.Default().Error("tasks tab: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, false
}
filter = templates.EtapeFilter{Kind: templates.EtapeFilterEtape, EtapeID: etapeID}
tasks = tasks[:0]
for _, task := range allTasks {
if taskHasEtape(task, etapeID) {
tasks = append(tasks, task)
}
}
}
return tasks, etapes, counts, filter, true
}
func parseOwnedEtapeID(r *http.Request, q *sqlc.Queries, tabloID uuid.UUID, raw string) (pgtype.UUID, bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return pgtype.UUID{}, false, nil
}
etapeID, err := uuid.Parse(raw)
if err != nil {
return pgtype.UUID{}, false, err
}
if _, err := q.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tabloID}); err != nil {
return pgtype.UUID{}, false, err
}
return pgtype.UUID{Bytes: etapeID, Valid: true}, true, nil
}
func loadOwnedEtape(w http.ResponseWriter, r *http.Request, deps EtapesDeps) (sqlc.Tablo, sqlc.Etape, bool) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return sqlc.Tablo{}, sqlc.Etape{}, false
}
etapeID, err := uuid.Parse(chi.URLParam(r, "etape_id"))
if err != nil {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
etape, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
slog.Default().Error("etapes: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return sqlc.Tablo{}, sqlc.Etape{}, false
}
return tablo, etape, true
}
func renderTasksTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
tasks, etapes, counts, filter, ok := loadTasksTabData(w, r, q, tablo)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Retarget", "#tasks-tab")
w.Header().Set("HX-Reswap", "outerHTML")
}
_ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w)
}
func EtapeNewFormHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EtapeCreateFormFragment(tablo.ID, templates.EtapeCreateForm{}, templates.EtapeCreateErrors{}, csrf.Token(r)).Render(r.Context(), w)
}
}
func EtapeEditFormHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EtapeEditFormFragment(
tablo.ID,
etape,
templates.EtapeUpdateForm{Title: etape.Title, Description: etape.Description.String},
templates.EtapeUpdateErrors{},
csrf.Token(r),
).Render(r.Context(), w)
}
}
func EtapeUpdateHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(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"))
form := templates.EtapeUpdateForm{Title: title, Description: description}
var errs templates.EtapeUpdateErrors
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", "#etape-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EtapeEditFormFragment(tablo.ID, etape, form, errs, csrf.Token(r)).Render(ctx, w)
return
}
if _, err := deps.Queries.UpdateEtape(ctx, sqlc.UpdateEtapeParams{
ID: etape.ID,
TabloID: tablo.ID,
Title: title,
Description: pgtype.Text{String: description, Valid: description != ""},
}); err != nil {
slog.Default().Error("etapes update: UpdateEtape failed", "tablo_id", tablo.ID, "etape_id", etape.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeDeleteConfirmHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EtapeDeleteConfirmFragment(tablo.ID, etape, csrf.Token(r)).Render(r.Context(), w)
}
}
func EtapeDeleteHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, etape, ok := loadOwnedEtape(w, r, deps)
if !ok {
return
}
if err := deps.Queries.DeleteEtape(r.Context(), sqlc.DeleteEtapeParams{ID: etape.ID, TabloID: tablo.ID}); err != nil {
slog.Default().Error("etapes delete: DeleteEtape failed", "tablo_id", tablo.ID, "etape_id", etape.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeReorderHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
for i, rawID := range r.Form["etape_id"] {
etapeID, err := uuid.Parse(rawID)
if err != nil {
continue
}
if _, err := deps.Queries.GetEtapeByID(r.Context(), sqlc.GetEtapeByIDParams{ID: etapeID, TabloID: tablo.ID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
continue
}
slog.Default().Error("etapes reorder: GetEtapeByID failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if _, err := deps.Queries.UpdateEtapePosition(r.Context(), sqlc.UpdateEtapePositionParams{
ID: etapeID,
TabloID: tablo.ID,
Position: int32((i + 1) * 100),
}); err != nil {
slog.Default().Error("etapes reorder: UpdateEtapePosition failed", "tablo_id", tablo.ID, "etape_id", etapeID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}
func EtapeCancelNewHandler(deps EtapesDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(""))
}
}
func EtapeCreateHandler(deps EtapesDeps) 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"))
description := strings.TrimSpace(r.PostFormValue("description"))
form := templates.EtapeCreateForm{Title: title, Description: description}
var errs templates.EtapeCreateErrors
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", "#etape-form-slot")
w.Header().Set("HX-Reswap", "innerHTML")
}
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.EtapeCreateFormFragment(tablo.ID, form, errs, csrf.Token(r)).Render(ctx, w)
return
}
maxPos, err := deps.Queries.MaxEtapePositionByTablo(ctx, tablo.ID)
if err != nil {
slog.Default().Error("etapes create: MaxEtapePositionByTablo failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if _, err := deps.Queries.InsertEtape(ctx, sqlc.InsertEtapeParams{
TabloID: tablo.ID,
Title: title,
Description: pgtype.Text{String: description, Valid: description != ""},
Position: maxPos + 100,
}); err != nil {
slog.Default().Error("etapes create: InsertEtape failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
renderTasksTab(w, r, deps.Queries, tablo)
}
}