352 lines
12 KiB
Go
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)
|
|
}
|
|
}
|