562 lines
16 KiB
Go
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()
|
|
}
|