xtablo-source/go-backend/internal/web/handlers/tasks.go
2026-05-10 23:14:47 +02:00

562 lines
16 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"html"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
"xtablo-backend/internal/web/views"
)
type TaskStatus = taskmodel.Status
const (
TaskStatusTodo = taskmodel.StatusTodo
TaskStatusInProgress = taskmodel.StatusInProgress
TaskStatusInReview = taskmodel.StatusInReview
TaskStatusDone = taskmodel.StatusDone
)
type TaskRecord = taskmodel.Record
type CreateTaskInput = taskmodel.CreateInput
type UpdateTaskInput = taskmodel.UpdateInput
type ListTasksByTabloInput = taskmodel.ListByTabloInput
type taskPageRepository interface {
ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]TaskRecord, error)
}
type taskMutationRepository interface {
CreateTask(ctx context.Context, input CreateTaskInput) (TaskRecord, error)
GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error)
UpdateTask(ctx context.Context, input UpdateTaskInput) (TaskRecord, error)
SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error
}
func (h *AuthHandler) renderTasksPage(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
taskRepo, ok := h.repo.(taskPageRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
tasks, err := taskRepo.ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
assigneeLabels := h.buildAssigneeLabels(r.Context(), user.ID, tasks)
state := parseTaskPageState(r)
vm := views.NewTasksPageViewModel(tablos, tasks, assigneeLabels, state, time.Now().UTC())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
content := views.TasksPageContent(vm)
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwap("/tasks", tablos, content).Render(r.Context(), w)
} else {
renderErr = views.DashboardPage("/tasks", tablos, content).Render(r.Context(), w)
}
if renderErr != nil {
http.Error(w, "failed to render tasks page", http.StatusInternalServerError)
}
}
func (h *AuthHandler) PostTasks() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
repo, ok := h.repo.(taskMutationRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
input, err := parseCreateTaskInput(r, user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
if err := validateTaskTabloAndParent(taskRecords, input.TabloID, input.ParentTaskID); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
if _, err := repo.CreateTask(r.Context(), input); err != nil {
if isTaskValidationError(err) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
http.Error(w, "failed to create task", http.StatusInternalServerError)
return
}
h.renderTasksPage(w, r)
}
}
func (h *AuthHandler) GetEditTaskModal() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
taskID, err := uuid.Parse(r.PathValue("taskID"))
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
repo, ok := h.repo.(taskMutationRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
record, err := repo.GetTaskByID(r.Context(), taskID, user.ID)
if err != nil {
if errors.Is(err, taskmodel.ErrNotFound) {
http.Error(w, "task not found", http.StatusNotFound)
return
}
http.Error(w, "failed to load task", http.StatusInternalServerError)
return
}
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{OwnerID: user.ID})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
assigneeLabels := h.buildAssigneeLabels(r.Context(), user.ID, taskRecords)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, renderTaskEditForm(record, tablos, taskRecords, assigneeLabels))
}
}
func (h *AuthHandler) PatchTask() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
taskID, err := uuid.Parse(r.PathValue("taskID"))
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
repo, ok := h.repo.(taskMutationRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
record, err := repo.GetTaskByID(r.Context(), taskID, user.ID)
if err != nil {
if errors.Is(err, taskmodel.ErrNotFound) {
http.Error(w, "task not found", http.StatusNotFound)
return
}
http.Error(w, "failed to load task", http.StatusInternalServerError)
return
}
input, err := parseUpdateTaskInput(r, user.ID, record)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
taskRecords, err := h.repo.(taskPageRepository).ListTasksByOwner(r.Context(), user.ID)
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
if err := validateTaskTabloAndParent(taskRecords, input.TabloID, input.ParentTaskID); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
if _, err := repo.UpdateTask(r.Context(), input); err != nil {
if isTaskValidationError(err) {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
http.Error(w, "failed to update task", http.StatusInternalServerError)
return
}
h.renderTasksPage(w, r)
}
}
func (h *AuthHandler) DeleteTask() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
taskID, err := uuid.Parse(r.PathValue("taskID"))
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
repo, ok := h.repo.(taskMutationRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
if err := repo.SoftDeleteTask(r.Context(), taskID, user.ID); err != nil {
if errors.Is(err, taskmodel.ErrNotFound) {
http.Error(w, "task not found", http.StatusNotFound)
return
}
http.Error(w, "failed to delete task", http.StatusInternalServerError)
return
}
h.renderTasksPage(w, r)
}
}
func parseCreateTaskInput(r *http.Request, ownerID uuid.UUID) (CreateTaskInput, error) {
tabloID, err := uuid.Parse(strings.TrimSpace(r.FormValue("tablo_id")))
if err != nil {
return CreateTaskInput{}, errors.New("tablo_id invalide")
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
return CreateTaskInput{}, errors.New("le titre est requis")
}
status, err := parseTaskStatusFormValue(r.FormValue("status"))
if err != nil {
return CreateTaskInput{}, err
}
parentTaskID, err := parseOptionalUUID(r.FormValue("parent_task_id"))
if err != nil {
return CreateTaskInput{}, errors.New("parent_task_id invalide")
}
assigneeID, err := parseOptionalUUID(r.FormValue("assignee_id"))
if err != nil {
return CreateTaskInput{}, errors.New("assignee_id invalide")
}
dueDate, err := parseOptionalDate(r.FormValue("due_date"))
if err != nil {
return CreateTaskInput{}, errors.New("due_date invalide")
}
isEtape, _ := strconv.ParseBool(strings.TrimSpace(r.FormValue("is_etape")))
return CreateTaskInput{
OwnerID: ownerID,
TabloID: tabloID,
Title: title,
Description: strings.TrimSpace(r.FormValue("description")),
Status: status,
AssigneeID: assigneeID,
IsEtape: isEtape,
ParentTaskID: parentTaskID,
DueDate: dueDate,
}, nil
}
func parseUpdateTaskInput(r *http.Request, ownerID uuid.UUID, current TaskRecord) (UpdateTaskInput, error) {
tabloRaw := strings.TrimSpace(r.FormValue("tablo_id"))
tabloID := current.TabloID
if tabloRaw != "" {
parsedTabloID, err := uuid.Parse(tabloRaw)
if err != nil {
return UpdateTaskInput{}, errors.New("tablo_id invalide")
}
tabloID = parsedTabloID
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
return UpdateTaskInput{}, errors.New("le titre est requis")
}
status, err := parseTaskStatusFormValue(r.FormValue("status"))
if err != nil {
return UpdateTaskInput{}, err
}
parentTaskID, err := parseOptionalUUID(r.FormValue("parent_task_id"))
if err != nil {
return UpdateTaskInput{}, errors.New("parent_task_id invalide")
}
assigneeID, err := parseOptionalUUID(r.FormValue("assignee_id"))
if err != nil {
return UpdateTaskInput{}, errors.New("assignee_id invalide")
}
dueDate, err := parseOptionalDate(r.FormValue("due_date"))
if err != nil {
return UpdateTaskInput{}, errors.New("due_date invalide")
}
if current.IsEtape {
parentTaskID = nil
}
return UpdateTaskInput{
ID: current.ID,
OwnerID: ownerID,
TabloID: tabloID,
Title: title,
Description: strings.TrimSpace(r.FormValue("description")),
Status: status,
AssigneeID: assigneeID,
ParentTaskID: parentTaskID,
DueDate: dueDate,
}, nil
}
func parseTaskStatusFormValue(raw string) (TaskStatus, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskStatusTodo, nil
}
status, err := taskmodel.ParseStatus(raw)
if err != nil {
return "", errors.New("status invalide")
}
return status, nil
}
func parseTaskPageState(r *http.Request) taskmodel.TaskPageState {
query := r.URL.Query()
state := taskmodel.TaskPageState{
View: taskmodel.NormalizeTaskView(query.Get("view")),
RoadmapMode: taskmodel.NormalizeTaskRoadmapMode(query.Get("roadmap_mode")),
TabloIDs: parseUUIDList(query["tablo"]),
AssigneeIDs: parseUUIDList(query["assignee"]),
Statuses: parseStatusList(query["status"]),
}
if state.View != taskmodel.TaskViewRoadmap {
state.RoadmapMode = taskmodel.TaskRoadmapModeWeek
}
return state
}
func parseUUIDList(values []string) []uuid.UUID {
parsed := make([]uuid.UUID, 0, len(values))
for _, value := range values {
id, err := uuid.Parse(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, id)
}
}
return parsed
}
func parseStatusList(values []string) []taskmodel.Status {
parsed := make([]taskmodel.Status, 0, len(values))
for _, value := range values {
status, err := taskmodel.ParseStatus(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, status)
}
}
return parsed
}
func parseOptionalUUID(raw string) (*uuid.UUID, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
id, err := uuid.Parse(raw)
if err != nil {
return nil, err
}
return &id, nil
}
func parseOptionalDate(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := time.Parse("2006-01-02", raw)
if err != nil {
return nil, err
}
return &value, nil
}
func optionalUUIDString(id *uuid.UUID) string {
if id == nil {
return ""
}
return id.String()
}
func isTaskValidationError(err error) bool {
return errors.Is(err, taskmodel.ErrInvalidParent) || errors.Is(err, taskmodel.ErrInvalidAssignee) || errors.Is(err, taskmodel.ErrInvalidStatus)
}
func validateTaskTabloAndParent(records []TaskRecord, tabloID uuid.UUID, parentTaskID *uuid.UUID) error {
if parentTaskID == nil {
return nil
}
for _, record := range records {
if record.ID != *parentTaskID {
continue
}
if !record.IsEtape {
return errors.New("parent_task_id must reference an étape")
}
if record.TabloID != tabloID {
return errors.New("parent_task_id must belong to the selected tablo")
}
return nil
}
return errors.New("parent_task_id must reference an active étape")
}
func (h *AuthHandler) buildAssigneeLabels(ctx context.Context, ownerID uuid.UUID, tasks []TaskRecord) map[uuid.UUID]string {
assigneeLabels := make(map[uuid.UUID]string)
if publicUser, err := h.repo.GetPublicUserByID(ctx, ownerID); err == nil {
assigneeLabels[ownerID] = publicUser.DisplayName
}
for _, record := range tasks {
if record.AssigneeID == nil {
continue
}
if _, exists := assigneeLabels[*record.AssigneeID]; exists {
continue
}
publicUser, err := h.repo.GetPublicUserByID(ctx, *record.AssigneeID)
if err != nil {
continue
}
assigneeLabels[*record.AssigneeID] = publicUser.DisplayName
}
return assigneeLabels
}
func renderTaskEditForm(record TaskRecord, tablos []tablomodel.Record, tasks []TaskRecord, assigneeLabels map[uuid.UUID]string) string {
var builder strings.Builder
builder.WriteString(`<form class="task-edit-form" data-task-id="`)
builder.WriteString(html.EscapeString(record.ID.String()))
builder.WriteString(`">`)
builder.WriteString(`<label>Tablo<select name="tablo_id">`)
for _, tablo := range tablos {
builder.WriteString(`<option value="`)
builder.WriteString(html.EscapeString(tablo.ID.String()))
builder.WriteString(`"`)
if tablo.ID == record.TabloID {
builder.WriteString(` selected`)
}
builder.WriteString(`>`)
builder.WriteString(html.EscapeString(tablo.Name))
builder.WriteString(`</option>`)
}
builder.WriteString(`</select></label>`)
builder.WriteString(`<input name="title" value="`)
builder.WriteString(html.EscapeString(record.Title))
builder.WriteString(`">`)
builder.WriteString(`<textarea name="description">`)
builder.WriteString(html.EscapeString(record.Description))
builder.WriteString(`</textarea>`)
builder.WriteString(`<input name="status" value="`)
builder.WriteString(html.EscapeString(string(record.Status)))
builder.WriteString(`">`)
builder.WriteString(`<input name="assignee_id" value="`)
builder.WriteString(html.EscapeString(optionalUUIDString(record.AssigneeID)))
builder.WriteString(`">`)
builder.WriteString(`<select name="parent_task_id"><option value="">Sans étape</option>`)
for _, task := range tasks {
if !task.IsEtape || task.TabloID != record.TabloID {
continue
}
builder.WriteString(`<option value="`)
builder.WriteString(html.EscapeString(task.ID.String()))
builder.WriteString(`"`)
if record.ParentTaskID != nil && *record.ParentTaskID == task.ID {
builder.WriteString(` selected`)
}
builder.WriteString(`>`)
builder.WriteString(html.EscapeString(task.Title))
builder.WriteString(`</option>`)
}
builder.WriteString(`</select>`)
if len(assigneeLabels) > 0 {
builder.WriteString(`<div class="task-assignees">`)
for id, label := range assigneeLabels {
builder.WriteString(`<span data-assignee-id="`)
builder.WriteString(html.EscapeString(id.String()))
builder.WriteString(`">`)
builder.WriteString(html.EscapeString(label))
builder.WriteString(`</span>`)
}
builder.WriteString(`</div>`)
}
builder.WriteString(`</form>`)
return builder.String()
}