package web import ( "errors" "log/slog" "net/http" "strings" "backend/internal/db/sqlc" "backend/templates" "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 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 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 } tasks, etapes, counts, filter, ok := loadTasksTabData(w, r, deps.Queries, 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(ctx, w) } }