From 9a92f358e8aeecfa6295a04dc98f6940238369c2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 10 May 2026 21:58:48 +0200 Subject: [PATCH] Add task management features with database schema and handlers Create a new tasks module with full CRUD operations, supporting both regular tasks and etapes (phases). Implements task hierarchy with parent-child relationships, assignees, and due dates. Includes database schema with validation triggers, SQLC query generation, in-memory repository implementation, HTTP handlers, view templates, and comprehensive test coverage. --- go-backend/internal/db/queries.sql | 82 ++++ go-backend/internal/db/repository.go | 177 +++++++ go-backend/internal/db/schema.sql | 77 +++ go-backend/internal/db/seed.sql | 128 +++++ go-backend/internal/db/sqlc/models.go | 16 + go-backend/internal/db/sqlc/querier.go | 7 + go-backend/internal/db/sqlc/queries.sql.go | 300 ++++++++++++ go-backend/internal/tasks/model.go | 76 +++ go-backend/internal/web/handlers/auth.go | 6 +- .../web/handlers/in_memory_auth_repository.go | 174 +++++++ go-backend/internal/web/handlers/tasks.go | 401 ++++++++++++++++ .../internal/web/handlers/tasks_test.go | 447 ++++++++++++++++++ .../internal/web/ui/catalog/catalog_test.go | 2 + go-backend/internal/web/ui/ui_test.go | 67 +++ go-backend/internal/web/views/tasks_view.go | 284 +++++++++++ go-backend/router.go | 4 + go-backend/router_test.go | 98 ++++ 17 files changed, 2343 insertions(+), 3 deletions(-) create mode 100644 go-backend/internal/tasks/model.go create mode 100644 go-backend/internal/web/handlers/tasks.go create mode 100644 go-backend/internal/web/handlers/tasks_test.go create mode 100644 go-backend/internal/web/views/tasks_view.go diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index 116aff7..ee7b305 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -75,6 +75,36 @@ INSERT INTO public.tablos ( ) RETURNING id, owner_id, name, color, status, created_at, updated_at, deleted_at; +-- name: CreateTask :one +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + now(), + now() +) +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at; + -- name: ListTablos :many SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos @@ -88,6 +118,29 @@ WHERE owner_id = sqlc.arg(owner_id) ) ORDER BY created_at DESC; +-- name: ListTasksByOwner :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: ListTasksByTablo :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND tablo_id = $2 + AND deleted_at IS NULL +ORDER BY created_at ASC; + +-- name: GetTaskByID :one +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +LIMIT 1; + -- name: UpdateTablo :execrows UPDATE public.tablos SET name = $3, @@ -97,9 +150,38 @@ WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL; +-- name: UpdateTask :one +UPDATE public.tasks +SET + title = $3, + description = $4, + status = $5, + due_date = $6, + assignee_id = $7, + parent_task_id = $8, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at; + -- name: SoftDeleteTablo :execrows UPDATE public.tablos SET deleted_at = now(), updated_at = now() WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL; + +-- name: ClearTaskChildrenParent :execrows +UPDATE public.tasks +SET parent_task_id = NULL, updated_at = now() +WHERE parent_task_id = $1 + AND owner_id = $2 + AND deleted_at IS NULL; + +-- name: SoftDeleteTask :execrows +UPDATE public.tasks +SET deleted_at = now(), updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index 5c72c3b..fa159fa 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -16,6 +16,7 @@ import ( sqlcdb "xtablo-backend/internal/db/sqlc" tablomodel "xtablo-backend/internal/tablos" + taskmodel "xtablo-backend/internal/tasks" "xtablo-backend/internal/web/handlers" ) @@ -209,6 +210,131 @@ func (r *PostgresAuthRepository) SoftDeleteTablo(ctx context.Context, tabloID uu return nil } +func (r *PostgresAuthRepository) CreateTask(ctx context.Context, input handlers.CreateTaskInput) (handlers.TaskRecord, error) { + row, err := r.queries.CreateTask(ctx, sqlcdb.CreateTaskParams{ + ID: uuid.New(), + TabloID: input.TabloID, + OwnerID: input.OwnerID, + Title: strings.TrimSpace(input.Title), + Description: strings.TrimSpace(input.Description), + Status: string(input.Status), + AssigneeID: nullableUUID(input.AssigneeID), + IsEtape: input.IsEtape, + ParentTaskID: nullableUUID(input.ParentTaskID), + DueDate: nullableDate(input.DueDate), + }) + if err != nil { + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]handlers.TaskRecord, error) { + rows, err := r.queries.ListTasksByOwner(ctx, ownerID) + if err != nil { + return nil, err + } + + records := make([]handlers.TaskRecord, 0, len(rows)) + for _, row := range rows { + records = append(records, mapTaskRecord(row)) + } + return records, nil +} + +func (r *PostgresAuthRepository) ListTasksByTablo(ctx context.Context, input handlers.ListTasksByTabloInput) ([]handlers.TaskRecord, error) { + rows, err := r.queries.ListTasksByTablo(ctx, sqlcdb.ListTasksByTabloParams{ + OwnerID: input.OwnerID, + TabloID: input.TabloID, + }) + if err != nil { + return nil, err + } + + records := make([]handlers.TaskRecord, 0, len(rows)) + for _, row := range rows { + records = append(records, mapTaskRecord(row)) + } + return records, nil +} + +func (r *PostgresAuthRepository) GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (handlers.TaskRecord, error) { + row, err := r.queries.GetTaskByID(ctx, sqlcdb.GetTaskByIDParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.TaskRecord{}, taskmodel.ErrNotFound + } + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) UpdateTask(ctx context.Context, input handlers.UpdateTaskInput) (handlers.TaskRecord, error) { + row, err := r.queries.UpdateTask(ctx, sqlcdb.UpdateTaskParams{ + ID: input.ID, + OwnerID: input.OwnerID, + Title: strings.TrimSpace(input.Title), + Description: strings.TrimSpace(input.Description), + Status: string(input.Status), + DueDate: nullableDate(input.DueDate), + AssigneeID: nullableUUID(input.AssigneeID), + ParentTaskID: nullableUUID(input.ParentTaskID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.TaskRecord{}, taskmodel.ErrNotFound + } + return handlers.TaskRecord{}, err + } + return mapTaskRecord(row), nil +} + +func (r *PostgresAuthRepository) SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + queries := r.queries.WithTx(tx) + + task, err := queries.GetTaskByID(ctx, sqlcdb.GetTaskByIDParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return taskmodel.ErrNotFound + } + return err + } + + if task.IsEtape { + if _, err := queries.ClearTaskChildrenParent(ctx, sqlcdb.ClearTaskChildrenParentParams{ + ParentTaskID: pgtypeUUID(task.ID), + OwnerID: ownerID, + }); err != nil { + return err + } + } + + rows, err := queries.SoftDeleteTask(ctx, sqlcdb.SoftDeleteTaskParams{ + ID: taskID, + OwnerID: ownerID, + }) + if err != nil { + return err + } + if rows == 0 { + return taskmodel.ErrNotFound + } + + return tx.Commit(ctx) +} + func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: value, Valid: true} } @@ -235,6 +361,26 @@ func nullableStatus(value *tablomodel.Status) pgtype.Text { return nullableText(string(*value)) } +func nullableUUID(value *uuid.UUID) pgtype.UUID { + if value == nil { + return pgtype.UUID{} + } + return pgtypeUUID(*value) +} + +func pgtypeUUID(value uuid.UUID) pgtype.UUID { + var bytes [16]byte + copy(bytes[:], value[:]) + return pgtype.UUID{Bytes: bytes, Valid: true} +} + +func nullableDate(value *time.Time) pgtype.Date { + if value == nil { + return pgtype.Date{} + } + return pgtype.Date{Time: *value, Valid: true} +} + func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record { record := tablomodel.Record{ ID: row.ID, @@ -251,3 +397,34 @@ func mapTabloRecord(row sqlcdb.Tablo) tablomodel.Record { } return record } + +func mapTaskRecord(row sqlcdb.Task) handlers.TaskRecord { + record := handlers.TaskRecord{ + ID: row.ID, + OwnerID: row.OwnerID, + TabloID: row.TabloID, + Title: row.Title, + Description: row.Description, + Status: taskmodel.Status(row.Status), + IsEtape: row.IsEtape, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } + if row.AssigneeID.Valid { + assigneeID := uuid.UUID(row.AssigneeID.Bytes) + record.AssigneeID = &assigneeID + } + if row.ParentTaskID.Valid { + parentID := uuid.UUID(row.ParentTaskID.Bytes) + record.ParentTaskID = &parentID + } + if row.DueDate.Valid { + dueDate := row.DueDate.Time + record.DueDate = &dueDate + } + if row.DeletedAt.Valid { + deletedAt := row.DeletedAt.Time + record.DeletedAt = &deletedAt + } + return record +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql index 58bc0f7..950f91d 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -43,6 +43,83 @@ CREATE INDEX IF NOT EXISTS tablos_owner_created_idx ON public.tablos (owner_id, created_at DESC) WHERE deleted_at IS NULL; +CREATE TABLE IF NOT EXISTS public.tasks ( + id uuid PRIMARY KEY, + tablo_id uuid NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE, + owner_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + status text NOT NULL CHECK (status IN ('todo', 'in_progress', 'in_review', 'done')), + assignee_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + is_etape boolean NOT NULL DEFAULT false, + parent_task_id uuid NULL REFERENCES public.tasks(id) ON DELETE SET NULL, + due_date date NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CHECK (NOT is_etape OR parent_task_id IS NULL) +); + +CREATE INDEX IF NOT EXISTS tasks_owner_tablo_deleted_idx +ON public.tasks (owner_id, tablo_id, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_assignee_deleted_idx +ON public.tasks (assignee_id, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_tablo_is_etape_deleted_idx +ON public.tasks (tablo_id, is_etape, deleted_at); + +CREATE INDEX IF NOT EXISTS tasks_parent_task_id_idx +ON public.tasks (parent_task_id); + +CREATE INDEX IF NOT EXISTS tasks_tablo_due_date_idx +ON public.tasks (tablo_id, due_date); + +CREATE OR REPLACE FUNCTION public.validate_task_parent() RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + parent_record public.tasks%ROWTYPE; +BEGIN + IF NEW.parent_task_id IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.is_etape THEN + RAISE EXCEPTION 'Etapes cannot have parent tasks'; + END IF; + + SELECT * + INTO parent_record + FROM public.tasks + WHERE id = NEW.parent_task_id; + + IF NOT FOUND OR parent_record.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'Parent task is invalid'; + END IF; + + IF parent_record.is_etape IS NOT TRUE THEN + RAISE EXCEPTION 'Parent task must be an etape'; + END IF; + + IF parent_record.owner_id <> NEW.owner_id THEN + RAISE EXCEPTION 'Parent task owner must match child owner'; + END IF; + + IF parent_record.tablo_id <> NEW.tablo_id THEN + RAISE EXCEPTION 'Parent task tablo must match child tablo'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS tasks_validate_parent_trigger ON public.tasks; +CREATE TRIGGER tasks_validate_parent_trigger +BEFORE INSERT OR UPDATE ON public.tasks +FOR EACH ROW +EXECUTE FUNCTION public.validate_task_parent(); + CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER diff --git a/go-backend/internal/db/seed.sql b/go-backend/internal/db/seed.sql index 0c59bd6..71417fd 100644 --- a/go-backend/internal/db/seed.sql +++ b/go-backend/internal/db/seed.sql @@ -14,3 +14,131 @@ INSERT INTO auth.users ( now() ) ON CONFLICT (email) DO NOTHING; + +INSERT INTO public.tablos ( + id, + owner_id, + name, + color, + status, + created_at, + updated_at, + deleted_at +) VALUES ( + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Démo produit', + '#3B82F6', + 'in_progress', + now(), + now(), + NULL +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at, + deleted_at +) VALUES +( + '33333333-3333-3333-3333-333333333331', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Préparation', + 'Étape de cadrage et de préparation de la démo.', + 'in_progress', + '11111111-1111-1111-1111-111111111111', + TRUE, + NULL, + '2026-05-20', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333332', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Livraison', + 'Étape finale avant mise en ligne de la démo.', + 'todo', + NULL, + TRUE, + NULL, + '2026-05-30', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333341', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Valider le périmètre', + 'Lister les fonctionnalités visibles dans la démo.', + 'done', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333331', + '2026-05-15', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333342', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Préparer les captures', + 'Assembler les écrans et les textes de présentation.', + 'in_progress', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333331', + '2026-05-18', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333343', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Relire le message d’annonce', + 'Vérifier le texte envoyé avec la démo.', + 'todo', + NULL, + FALSE, + NULL, + '2026-05-19', + now(), + now(), + NULL +), +( + '33333333-3333-3333-3333-333333333344', + '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', + 'Envoyer la démo', + 'Partager la version finale aux premiers retours.', + 'todo', + '11111111-1111-1111-1111-111111111111', + FALSE, + '33333333-3333-3333-3333-333333333332', + '2026-05-30', + now(), + now(), + NULL +) +ON CONFLICT (id) DO NOTHING; diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go index d8ea423..0fc8a7c 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -38,6 +38,22 @@ type Tablo struct { DeletedAt pgtype.Timestamptz `db:"deleted_at"` } +type Task struct { + ID uuid.UUID `db:"id"` + TabloID uuid.UUID `db:"tablo_id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + AssigneeID pgtype.UUID `db:"assignee_id"` + IsEtape bool `db:"is_etape"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` + DueDate pgtype.Date `db:"due_date"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` + DeletedAt pgtype.Timestamptz `db:"deleted_at"` +} + type User struct { ID uuid.UUID `db:"id"` Email string `db:"email"` diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go index c60deef..2492711 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -11,16 +11,23 @@ import ( ) type Querier interface { + ClearTaskChildrenParent(ctx context.Context, arg ClearTaskChildrenParentParams) (int64, error) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) CreateSession(ctx context.Context, arg CreateSessionParams) error CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo, error) + CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) + GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo, error) + ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]Task, error) + ListTasksByTablo(ctx context.Context, arg ListTasksByTabloParams) ([]Task, error) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams) (int64, error) + SoftDeleteTask(ctx context.Context, arg SoftDeleteTaskParams) (int64, error) UpdateTablo(ctx context.Context, arg UpdateTabloParams) (int64, error) + UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) } var _ Querier = (*Queries)(nil) diff --git a/go-backend/internal/db/sqlc/queries.sql.go b/go-backend/internal/db/sqlc/queries.sql.go index 85adafd..61f6951 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -12,6 +12,27 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const clearTaskChildrenParent = `-- name: ClearTaskChildrenParent :execrows +UPDATE public.tasks +SET parent_task_id = NULL, updated_at = now() +WHERE parent_task_id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +` + +type ClearTaskChildrenParentParams struct { + ParentTaskID pgtype.UUID `db:"parent_task_id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) ClearTaskChildrenParent(ctx context.Context, arg ClearTaskChildrenParentParams) (int64, error) { + result, err := q.db.Exec(ctx, clearTaskChildrenParent, arg.ParentTaskID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const createAuthUser = `-- name: CreateAuthUser :one INSERT INTO auth.users ( id, @@ -136,6 +157,82 @@ func (q *Queries) CreateTablo(ctx context.Context, arg CreateTabloParams) (Tablo return i, err } +const createTask = `-- name: CreateTask :one +INSERT INTO public.tasks ( + id, + tablo_id, + owner_id, + title, + description, + status, + assignee_id, + is_etape, + parent_task_id, + due_date, + created_at, + updated_at +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + now(), + now() +) +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +` + +type CreateTaskParams struct { + ID uuid.UUID `db:"id"` + TabloID uuid.UUID `db:"tablo_id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + AssigneeID pgtype.UUID `db:"assignee_id"` + IsEtape bool `db:"is_etape"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` + DueDate pgtype.Date `db:"due_date"` +} + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, createTask, + arg.ID, + arg.TabloID, + arg.OwnerID, + arg.Title, + arg.Description, + arg.Status, + arg.AssigneeID, + arg.IsEtape, + arg.ParentTaskID, + arg.DueDate, + ) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows DELETE FROM auth.sessions WHERE session_token = $1 @@ -218,6 +315,41 @@ func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (A return i, err } +const getTaskByID = `-- name: GetTaskByID :one +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +LIMIT 1 +` + +type GetTaskByIDParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) { + row := q.db.QueryRow(ctx, getTaskByID, arg.ID, arg.OwnerID) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + const listTablos = `-- name: ListTablos :many SELECT id, owner_id, name, color, status, created_at, updated_at, deleted_at FROM public.tablos @@ -267,6 +399,96 @@ func (q *Queries) ListTablos(ctx context.Context, arg ListTablosParams) ([]Tablo return items, nil } +const listTasksByOwner = `-- name: ListTasksByOwner :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND deleted_at IS NULL +ORDER BY created_at ASC +` + +func (q *Queries) ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]Task, error) { + rows, err := q.db.Query(ctx, listTasksByOwner, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + var i Task + if err := rows.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasksByTablo = `-- name: ListTasksByTablo :many +SELECT id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +FROM public.tasks +WHERE owner_id = $1 + AND tablo_id = $2 + AND deleted_at IS NULL +ORDER BY created_at ASC +` + +type ListTasksByTabloParams struct { + OwnerID uuid.UUID `db:"owner_id"` + TabloID uuid.UUID `db:"tablo_id"` +} + +func (q *Queries) ListTasksByTablo(ctx context.Context, arg ListTasksByTabloParams) ([]Task, error) { + rows, err := q.db.Query(ctx, listTasksByTablo, arg.OwnerID, arg.TabloID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + var i Task + if err := rows.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const softDeleteTablo = `-- name: SoftDeleteTablo :execrows UPDATE public.tablos SET deleted_at = now(), updated_at = now() @@ -288,6 +510,27 @@ func (q *Queries) SoftDeleteTablo(ctx context.Context, arg SoftDeleteTabloParams return result.RowsAffected(), nil } +const softDeleteTask = `-- name: SoftDeleteTask :execrows +UPDATE public.tasks +SET deleted_at = now(), updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +` + +type SoftDeleteTaskParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` +} + +func (q *Queries) SoftDeleteTask(ctx context.Context, arg SoftDeleteTaskParams) (int64, error) { + result, err := q.db.Exec(ctx, softDeleteTask, arg.ID, arg.OwnerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const updateTablo = `-- name: UpdateTablo :execrows UPDATE public.tablos SET name = $3, @@ -317,3 +560,60 @@ func (q *Queries) UpdateTablo(ctx context.Context, arg UpdateTabloParams) (int64 } return result.RowsAffected(), nil } + +const updateTask = `-- name: UpdateTask :one +UPDATE public.tasks +SET + title = $3, + description = $4, + status = $5, + due_date = $6, + assignee_id = $7, + parent_task_id = $8, + updated_at = now() +WHERE id = $1 + AND owner_id = $2 + AND deleted_at IS NULL +RETURNING id, tablo_id, owner_id, title, description, status, assignee_id, is_etape, parent_task_id, due_date, created_at, updated_at, deleted_at +` + +type UpdateTaskParams struct { + ID uuid.UUID `db:"id"` + OwnerID uuid.UUID `db:"owner_id"` + Title string `db:"title"` + Description string `db:"description"` + Status string `db:"status"` + DueDate pgtype.Date `db:"due_date"` + AssigneeID pgtype.UUID `db:"assignee_id"` + ParentTaskID pgtype.UUID `db:"parent_task_id"` +} + +func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, updateTask, + arg.ID, + arg.OwnerID, + arg.Title, + arg.Description, + arg.Status, + arg.DueDate, + arg.AssigneeID, + arg.ParentTaskID, + ) + var i Task + err := row.Scan( + &i.ID, + &i.TabloID, + &i.OwnerID, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.IsEtape, + &i.ParentTaskID, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/go-backend/internal/tasks/model.go b/go-backend/internal/tasks/model.go new file mode 100644 index 0000000..97606cb --- /dev/null +++ b/go-backend/internal/tasks/model.go @@ -0,0 +1,76 @@ +package tasks + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +var ErrNotFound = errors.New("task not found") +var ErrInvalidParent = errors.New("invalid parent task") +var ErrInvalidAssignee = errors.New("invalid assignee") +var ErrInvalidStatus = errors.New("invalid task status") + +type Status string + +const ( + StatusTodo Status = "todo" + StatusInProgress Status = "in_progress" + StatusInReview Status = "in_review" + StatusDone Status = "done" +) + +type Record struct { + ID uuid.UUID + OwnerID uuid.UUID + TabloID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + IsEtape bool + ParentTaskID *uuid.UUID + DueDate *time.Time + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type CreateInput struct { + OwnerID uuid.UUID + TabloID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + IsEtape bool + ParentTaskID *uuid.UUID + DueDate *time.Time +} + +type UpdateInput struct { + ID uuid.UUID + OwnerID uuid.UUID + Title string + Description string + Status Status + AssigneeID *uuid.UUID + ParentTaskID *uuid.UUID + DueDate *time.Time +} + +type ListByTabloInput struct { + OwnerID uuid.UUID + TabloID uuid.UUID +} + +func ParseStatus(raw string) (Status, error) { + switch Status(strings.TrimSpace(raw)) { + case StatusTodo, StatusInProgress, StatusInReview, StatusDone: + return Status(strings.TrimSpace(raw)), nil + default: + return "", ErrInvalidStatus + } +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 7b7df0c..291ef10 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -109,9 +109,9 @@ func (h *AuthHandler) GetHome() http.HandlerFunc { } func (h *AuthHandler) GetTasksPage() http.HandlerFunc { - return h.renderAppPage("/tasks", func(user PublicUser) templ.Component { - return views.TasksMainContent() - }) + return func(w http.ResponseWriter, r *http.Request) { + h.renderTasksPage(w, r) + } } func (h *AuthHandler) GetTablosPage() http.HandlerFunc { diff --git a/go-backend/internal/web/handlers/in_memory_auth_repository.go b/go-backend/internal/web/handlers/in_memory_auth_repository.go index 94a8e7b..427065f 100644 --- a/go-backend/internal/web/handlers/in_memory_auth_repository.go +++ b/go-backend/internal/web/handlers/in_memory_auth_repository.go @@ -2,10 +2,12 @@ package handlers import ( "context" + "errors" "sync" "time" "github.com/google/uuid" + taskmodel "xtablo-backend/internal/tasks" ) // InMemoryAuthRepository exists only as test support. @@ -16,6 +18,7 @@ type InMemoryAuthRepository struct { publicUsers map[uuid.UUID]PublicUser sessions map[string]Session tablos map[uuid.UUID]TabloRecord + tasks map[uuid.UUID]TaskRecord } // NewInMemoryAuthRepository creates a testing-only auth repository. @@ -26,6 +29,7 @@ func NewInMemoryAuthRepository() *InMemoryAuthRepository { publicUsers: map[uuid.UUID]PublicUser{}, sessions: map[string]Session{}, tablos: map[uuid.UUID]TabloRecord{}, + tasks: map[uuid.UUID]TaskRecord{}, } demoHash, err := hashPassword("xtablo-demo") @@ -132,3 +136,173 @@ func (r *InMemoryAuthRepository) DeleteSessionByToken(_ context.Context, token s delete(r.sessions, token) return nil } + +func (r *InMemoryAuthRepository) CreateTask(_ context.Context, input CreateTaskInput) (TaskRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if err := r.validateTaskInputLocked(input.OwnerID, input.TabloID, input.Status, input.AssigneeID, input.IsEtape, input.ParentTaskID); err != nil { + return TaskRecord{}, err + } + + now := time.Now().UTC() + record := TaskRecord{ + ID: uuid.New(), + OwnerID: input.OwnerID, + TabloID: input.TabloID, + Title: input.Title, + Description: input.Description, + Status: input.Status, + AssigneeID: input.AssigneeID, + IsEtape: input.IsEtape, + ParentTaskID: input.ParentTaskID, + DueDate: input.DueDate, + CreatedAt: now, + UpdatedAt: now, + } + + r.tasks[record.ID] = record + return record, nil +} + +func (r *InMemoryAuthRepository) ListTasksByTablo(_ context.Context, input ListTasksByTabloInput) ([]TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + tasks := make([]TaskRecord, 0) + for _, record := range r.tasks { + if record.OwnerID != input.OwnerID || record.TabloID != input.TabloID || record.DeletedAt != nil { + continue + } + tasks = append(tasks, record) + } + + sortTasksByCreatedAt(tasks) + return tasks, nil +} + +func (r *InMemoryAuthRepository) ListTasksByOwner(_ context.Context, ownerID uuid.UUID) ([]TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + tasks := make([]TaskRecord, 0) + for _, record := range r.tasks { + if record.OwnerID != ownerID || record.DeletedAt != nil { + continue + } + tasks = append(tasks, record) + } + + sortTasksByCreatedAt(tasks) + return tasks, nil +} + +func (r *InMemoryAuthRepository) GetTaskByID(_ context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + record, ok := r.tasks[taskID] + if !ok || record.OwnerID != ownerID || record.DeletedAt != nil { + return TaskRecord{}, taskmodel.ErrNotFound + } + return record, nil +} + +func (r *InMemoryAuthRepository) UpdateTask(_ context.Context, input UpdateTaskInput) (TaskRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + + record, ok := r.tasks[input.ID] + if !ok || record.OwnerID != input.OwnerID || record.DeletedAt != nil { + return TaskRecord{}, taskmodel.ErrNotFound + } + if err := r.validateTaskInputLocked(input.OwnerID, record.TabloID, input.Status, input.AssigneeID, record.IsEtape, input.ParentTaskID); err != nil { + return TaskRecord{}, err + } + + record.Title = input.Title + record.Description = input.Description + record.Status = input.Status + record.AssigneeID = input.AssigneeID + record.ParentTaskID = input.ParentTaskID + record.DueDate = input.DueDate + record.UpdatedAt = time.Now().UTC() + + r.tasks[input.ID] = record + return record, nil +} + +func (r *InMemoryAuthRepository) SoftDeleteTask(_ context.Context, taskID uuid.UUID, ownerID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + + record, ok := r.tasks[taskID] + if !ok || record.OwnerID != ownerID || record.DeletedAt != nil { + return taskmodel.ErrNotFound + } + + now := time.Now().UTC() + record.DeletedAt = &now + record.UpdatedAt = now + r.tasks[taskID] = record + + if record.IsEtape { + for id, child := range r.tasks { + if child.DeletedAt != nil || child.ParentTaskID == nil { + continue + } + if *child.ParentTaskID != taskID { + continue + } + child.ParentTaskID = nil + child.UpdatedAt = now + r.tasks[id] = child + } + } + + return nil +} + +func (r *InMemoryAuthRepository) validateTaskInputLocked(ownerID uuid.UUID, tabloID uuid.UUID, status TaskStatus, assigneeID *uuid.UUID, isEtape bool, parentTaskID *uuid.UUID) error { + tablo, ok := r.tablos[tabloID] + if !ok || tablo.OwnerID != ownerID || tablo.DeletedAt != nil { + return errors.New("tablo not found") + } + + if _, err := taskmodel.ParseStatus(string(status)); err != nil { + return err + } + + if assigneeID != nil { + if _, ok := r.publicUsers[*assigneeID]; !ok { + return taskmodel.ErrInvalidAssignee + } + } + + if isEtape && parentTaskID != nil { + return taskmodel.ErrInvalidParent + } + + if parentTaskID == nil { + return nil + } + + parent, ok := r.tasks[*parentTaskID] + if !ok || parent.DeletedAt != nil { + return taskmodel.ErrInvalidParent + } + if parent.OwnerID != ownerID || parent.TabloID != tabloID || !parent.IsEtape { + return taskmodel.ErrInvalidParent + } + return nil +} + +func sortTasksByCreatedAt(tasks []TaskRecord) { + for i := 0; i < len(tasks); i++ { + for j := i + 1; j < len(tasks); j++ { + if tasks[j].CreatedAt.Before(tasks[i].CreatedAt) { + tasks[i], tasks[j] = tasks[j], tasks[i] + } + } + } +} diff --git a/go-backend/internal/web/handlers/tasks.go b/go-backend/internal/web/handlers/tasks.go new file mode 100644 index 0000000..53b6406 --- /dev/null +++ b/go-backend/internal/web/handlers/tasks.go @@ -0,0 +1,401 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "html" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + 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 := make(map[uuid.UUID]string) + for _, record := range tasks { + if record.AssigneeID == nil { + continue + } + if _, exists := assigneeLabels[*record.AssigneeID]; exists { + continue + } + publicUser, err := h.repo.GetPublicUserByID(r.Context(), *record.AssigneeID) + if err != nil { + continue + } + assigneeLabels[*record.AssigneeID] = publicUser.DisplayName + } + + vm := views.NewTasksPageViewModel(tablos, tasks, assigneeLabels) + + 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 + } + + 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 + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf( + w, + `
`, + html.EscapeString(record.ID.String()), + html.EscapeString(record.Title), + html.EscapeString(record.Description), + html.EscapeString(string(record.Status)), + html.EscapeString(optionalUUIDString(record.AssigneeID)), + ) + } +} + +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 + } + + 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) { + 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, + 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 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) +} diff --git a/go-backend/internal/web/handlers/tasks_test.go b/go-backend/internal/web/handlers/tasks_test.go new file mode 100644 index 0000000..64c7e0a --- /dev/null +++ b/go-backend/internal/web/handlers/tasks_test.go @@ -0,0 +1,447 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + taskmodel "xtablo-backend/internal/tasks" +) + +func TestGetTasksPageRendersEtapesAndSansEtapeSections(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Production") + _ = mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Cut footage") + _ = mustCreateTask(t, repo, userID, tablo.ID, nil, "Inbox task") + + pageReq := httptest.NewRequest(http.MethodGet, "/tasks", nil) + pageReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetTasksPage().ServeHTTP(rec, pageReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{"Mes tâches", "Production", "Sans étape", "Inbox task", "Cut footage"} { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } +} + +func TestPostTasksCreatesEtape(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + tablo := mustCreateOwnedTablo(t, repo, userID) + + form := url.Values{} + form.Set("tablo_id", tablo.ID.String()) + form.Set("title", "Launch") + form.Set("status", string(taskmodel.StatusTodo)) + form.Set("is_etape", "true") + + postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PostTasks().ServeHTTP(rec, postReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Launch") { + t.Fatalf("expected response to contain new etape, got %q", rec.Body.String()) + } +} + +func TestPostTasksRejectsNonEtapeParent(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + tablo := mustCreateOwnedTablo(t, repo, userID) + parentTask := mustCreateTask(t, repo, userID, tablo.ID, nil, "Regular task") + + form := url.Values{} + form.Set("tablo_id", tablo.ID.String()) + form.Set("title", "Should fail") + form.Set("status", string(taskmodel.StatusTodo)) + form.Set("parent_task_id", parentTask.ID.String()) + + postReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PostTasks().ServeHTTP(rec, postReq) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected status 422, got %d", rec.Code) + } +} + +func TestGetEditTaskModalRendersCurrentValues(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "modal-assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "modal assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTaskWithAssignee(t, repo, userID, tablo.ID, nil, "Editable", &assigneeID) + + editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil) + editReq.SetPathValue("taskID", task.ID.String()) + editReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.GetEditTaskModal().ServeHTTP(rec, editReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + for _, want := range []string{"Editable", string(task.Status), assigneeID.String()} { + if !strings.Contains(body, want) { + t.Fatalf("expected edit modal to contain %q, got %q", want, body) + } + } +} + +func TestPatchTaskUpdatesEditableFields(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "patch-assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "patch assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + etape := mustCreateEtape(t, repo, userID, tablo.ID, "Editing") + task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Old title") + + form := url.Values{} + form.Set("title", "New title") + form.Set("description", "New description") + form.Set("status", string(taskmodel.StatusInReview)) + form.Set("due_date", "2026-05-20") + form.Set("assignee_id", assigneeID.String()) + form.Set("parent_task_id", etape.ID.String()) + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", task.ID.String()) + patchReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.PatchTask().ServeHTTP(rec, patchReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + updated, err := repo.GetTaskByID(context.Background(), task.ID, userID) + if err != nil { + t.Fatalf("get updated task: %v", err) + } + if updated.Title != "New title" || updated.Description != "New description" { + t.Fatalf("expected title/description to update, got %#v", updated) + } + if updated.Status != taskmodel.StatusInReview { + t.Fatalf("expected status to update, got %q", updated.Status) + } + if updated.AssigneeID == nil || *updated.AssigneeID != assigneeID { + t.Fatalf("expected assignee to update, got %#v", updated.AssigneeID) + } + if updated.ParentTaskID == nil || *updated.ParentTaskID != etape.ID { + t.Fatalf("expected parent etape to update, got %#v", updated.ParentTaskID) + } + if updated.DueDate == nil || updated.DueDate.Format("2006-01-02") != "2026-05-20" { + t.Fatalf("expected due date to update, got %#v", updated.DueDate) + } +} + +func TestDeleteTaskSoftDeletesRegularTask(t *testing.T) { + repo := NewInMemoryAuthRepository() + handler := NewAuthHandler(repo) + sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie) + userID, ok := handler.currentUserID(req.Context(), req) + if !ok { + t.Fatal("expected authenticated user") + } + + tablo := mustCreateOwnedTablo(t, repo, userID) + task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Delete me") + + deleteReq := httptest.NewRequest(http.MethodDelete, "/tasks/"+task.ID.String(), nil) + deleteReq.SetPathValue("taskID", task.ID.String()) + deleteReq.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + + handler.DeleteTask().ServeHTTP(rec, deleteReq) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + if _, err := repo.GetTaskByID(context.Background(), task.ID, userID); err == nil { + t.Fatal("expected deleted task to become unavailable") + } +} + +func TestInMemoryTasksListExcludesSoftDeletedRows(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Etape 1") + task := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Task 1") + + if err := repo.SoftDeleteTask(context.Background(), task.ID, user.ID); err != nil { + t.Fatalf("soft delete task: %v", err) + } + + records, err := repo.ListTasksByTablo(context.Background(), ListTasksByTabloInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + }) + if err != nil { + t.Fatalf("list tasks: %v", err) + } + + if len(records) != 1 || records[0].ID != etape.ID { + t.Fatalf("expected only etape to remain visible, got %#v", records) + } +} + +func TestInMemoryDeleteEtapeClearsChildParentID(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + etape := mustCreateEtape(t, repo, user.ID, tablo.ID, "Launch") + child := mustCreateTask(t, repo, user.ID, tablo.ID, &etape.ID, "Ship copy") + + if err := repo.SoftDeleteTask(context.Background(), etape.ID, user.ID); err != nil { + t.Fatalf("delete etape: %v", err) + } + + updated, err := repo.GetTaskByID(context.Background(), child.ID, user.ID) + if err != nil { + t.Fatalf("get child task: %v", err) + } + if updated.ParentTaskID != nil { + t.Fatalf("expected child task to move to Sans etape, got parent %v", *updated.ParentTaskID) + } +} + +func TestInMemoryEtapeCannotHaveParent(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + parent := mustCreateEtape(t, repo, user.ID, tablo.ID, "Parent") + + _, err = repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + Title: "Invalid child etape", + IsEtape: true, + Status: taskmodel.StatusTodo, + ParentTaskID: &parent.ID, + }) + if err == nil { + t.Fatal("expected etape with parent to fail") + } +} + +func TestInMemoryTaskParentMustBeEtape(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + taskParent := mustCreateTask(t, repo, user.ID, tablo.ID, nil, "Not an etape") + + _, err = repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: user.ID, + TabloID: tablo.ID, + Title: "Invalid child task", + Status: taskmodel.StatusTodo, + ParentTaskID: &taskParent.ID, + }) + if err == nil { + t.Fatal("expected task with non-etape parent to fail") + } +} + +func TestInMemoryTaskAssigneePersistsAndCanBeCleared(t *testing.T) { + repo := NewInMemoryAuthRepository() + user, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error %v", err) + } + + assigneeID, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "assignee@xtablo.com", + EncryptedPassword: "hash", + DisplayName: "assignee", + }) + if err != nil { + t.Fatalf("create assignee: %v", err) + } + + tablo := mustCreateOwnedTablo(t, repo, user.ID) + task := mustCreateTaskWithAssignee(t, repo, user.ID, tablo.ID, nil, "Assigned", &assigneeID) + + if task.AssigneeID == nil || *task.AssigneeID != assigneeID { + t.Fatalf("expected assignee to persist, got %#v", task.AssigneeID) + } + + updated, err := repo.UpdateTask(context.Background(), UpdateTaskInput{ + ID: task.ID, + OwnerID: user.ID, + Title: task.Title, + Description: task.Description, + Status: task.Status, + AssigneeID: nil, + DueDate: task.DueDate, + ParentTaskID: task.ParentTaskID, + }) + if err != nil { + t.Fatalf("clear assignee: %v", err) + } + + if updated.AssigneeID != nil { + t.Fatalf("expected assignee to clear, got %#v", updated.AssigneeID) + } +} + +func mustCreateOwnedTablo(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID) TabloRecord { + t.Helper() + + tablo, err := repo.CreateTablo(context.Background(), CreateTabloInput{ + OwnerID: ownerID, + Name: "Owned Tablo", + Color: "#3B82F6", + Status: TabloStatusTodo, + }) + if err != nil { + t.Fatalf("create tablo: %v", err) + } + return tablo +} + +func mustCreateEtape(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, title string) TaskRecord { + t.Helper() + + record, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: ownerID, + TabloID: tabloID, + Title: title, + IsEtape: true, + Status: taskmodel.StatusTodo, + }) + if err != nil { + t.Fatalf("create etape: %v", err) + } + return record +} + +func mustCreateTask(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string) TaskRecord { + t.Helper() + + return mustCreateTaskWithAssignee(t, repo, ownerID, tabloID, parentTaskID, title, nil) +} + +func mustCreateTaskWithAssignee(t *testing.T, repo *InMemoryAuthRepository, ownerID uuid.UUID, tabloID uuid.UUID, parentTaskID *uuid.UUID, title string, assigneeID *uuid.UUID) TaskRecord { + t.Helper() + + record, err := repo.CreateTask(context.Background(), CreateTaskInput{ + OwnerID: ownerID, + TabloID: tabloID, + Title: title, + Status: taskmodel.StatusTodo, + ParentTaskID: parentTaskID, + AssigneeID: assigneeID, + }) + if err != nil { + t.Fatalf("create task: %v", err) + } + return record +} diff --git a/go-backend/internal/web/ui/catalog/catalog_test.go b/go-backend/internal/web/ui/catalog/catalog_test.go index d37a4b0..c6a2400 100644 --- a/go-backend/internal/web/ui/catalog/catalog_test.go +++ b/go-backend/internal/web/ui/catalog/catalog_test.go @@ -40,6 +40,7 @@ func TestPagesIncludePrimitiveCatalogCoverage(t *testing.T) { "badges", "icon-buttons", "inputs", + "selects", "form-fields", "modals", "spacing", @@ -134,6 +135,7 @@ func TestPrimitiveExamplesRenderRealMarkup(t *testing.T) { {slug: "badges", want: []string{`ui-badge`, `En cours`}}, {slug: "icon-buttons", want: []string{`borderless-icon-button`, `aria-label="Supprimer le projet"`}}, {slug: "inputs", want: []string{`class="ui-input"`, `placeholder="Nom du projet"`}}, + {slug: "selects", want: []string{`class="ui-select"`, `class="ui-select-control"`}}, {slug: "form-fields", want: []string{`ui-form-field`, `ui-form-label`}}, {slug: "modals", want: []string{`ui-modal-panel`, `Créer le projet`}}, {slug: "tables", want: []string{`class="ui-table"`, `Table View`}}, diff --git a/go-backend/internal/web/ui/ui_test.go b/go-backend/internal/web/ui/ui_test.go index 4d56475..27e6a46 100644 --- a/go-backend/internal/web/ui/ui_test.go +++ b/go-backend/internal/web/ui/ui_test.go @@ -105,6 +105,70 @@ func TestBadgeRendersSemanticStatusVariant(t *testing.T) { } } +func TestSelectRendersSingleSelectMarkup(t *testing.T) { + component := Select(SelectProps{ + ID: "project-status", + Name: "status", + Placeholder: "Select a status", + Value: "in-progress", + Options: []SelectOption{ + {Value: "todo", Label: "To do"}, + {Value: "in-progress", Label: "In progress"}, + {Value: "done", Label: "Done"}, + }, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `class="ui-select"`, + `id="project-status"`, + `name="status"`, + `class="ui-select-native"`, + `class="ui-select-control"`, + `class="ui-select-value-wrapper"`, + `class="ui-select-arrow-zone"`, + `class="ui-select-arrow-icon"`, + `value="in-progress" selected`, + `data-ui-select-root`, + `data-ui-select-label`, + `Select a status`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + +func TestSelectRendersMultiSelectMarkup(t *testing.T) { + component := Select(SelectProps{ + Name: "assignee_ids", + Placeholder: "Select multiple values", + Multiple: true, + Values: []string{"u_1", "u_2"}, + Options: []SelectOption{ + {Value: "u_1", Label: "Alice"}, + {Value: "u_2", Label: "Bob"}, + {Value: "u_3", Label: "Charlie"}, + }, + }) + + html := renderToString(t, component) + + for _, want := range []string{ + `multiple`, + `data-ui-select-multiple="true"`, + `data-placeholder="Select multiple values"`, + `data-selected-label="Alice, Bob"`, + `value="u_1" selected`, + `value="u_2" selected`, + } { + if !strings.Contains(html, want) { + t.Fatalf("expected %q in %q", want, html) + } + } +} + func TestModalRendersShellStructure(t *testing.T) { component := Modal(ModalProps{ Title: "Nouveau projet", @@ -205,6 +269,9 @@ func TestSharedSemanticClassesExistInStylesheet(t *testing.T) { `.ui-space-x-md`, `.ui-space-y-md`, `.borderless-icon-button`, + `.ui-select-control`, + `.ui-select-native`, + `.ui-select-chip`, `.ui-icon-button-solid.ui-icon-button-neutral`, `.ui-icon-button-ghost.ui-icon-button-neutral`, `.ui-icon-button-ghost.ui-icon-button-danger`, diff --git a/go-backend/internal/web/views/tasks_view.go b/go-backend/internal/web/views/tasks_view.go new file mode 100644 index 0000000..f9ae029 --- /dev/null +++ b/go-backend/internal/web/views/tasks_view.go @@ -0,0 +1,284 @@ +package views + +import ( + "context" + "fmt" + "html" + "io" + "slices" + "strings" + "time" + + "github.com/a-h/templ" + "github.com/google/uuid" + tablomodel "xtablo-backend/internal/tablos" + taskmodel "xtablo-backend/internal/tasks" +) + +type TasksPageViewModel struct { + Tablos []TasksTabloGroupView +} + +type TasksTabloGroupView struct { + ID string + Name string + Color string + Sections []TasksSectionView +} + +type TasksSectionView struct { + ID string + Title string + Description string + IsEtape bool + Tasks []TaskRowView + Status string + StatusValue string + DueDate string + DueDateValue string + Assignee string + AssigneeID string +} + +type TaskRowView struct { + ID string + Title string + Description string + Status string + StatusValue string + DueDate string + DueDateValue string + Assignee string + AssigneeID string + ParentTaskID string +} + +func NewTasksPageViewModel(tablos []tablomodel.Record, tasks []taskmodel.Record, assigneeLabels map[uuid.UUID]string) TasksPageViewModel { + groups := make([]TasksTabloGroupView, 0, len(tablos)) + tasksByTablo := make(map[uuid.UUID][]taskmodel.Record) + for _, record := range tasks { + tasksByTablo[record.TabloID] = append(tasksByTablo[record.TabloID], record) + } + + for _, tablo := range tablos { + records := tasksByTablo[tablo.ID] + if len(records) == 0 { + continue + } + + etapes := make([]taskmodel.Record, 0) + childrenByParent := make(map[uuid.UUID][]taskmodel.Record) + parentless := make([]taskmodel.Record, 0) + + for _, record := range records { + if record.IsEtape { + etapes = append(etapes, record) + continue + } + if record.ParentTaskID == nil { + parentless = append(parentless, record) + continue + } + childrenByParent[*record.ParentTaskID] = append(childrenByParent[*record.ParentTaskID], record) + } + + sections := make([]TasksSectionView, 0, len(etapes)+1) + slices.SortFunc(etapes, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + for _, etape := range etapes { + children := childrenByParent[etape.ID] + slices.SortFunc(children, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + sections = append(sections, TasksSectionView{ + ID: etape.ID.String(), + Title: etape.Title, + Description: etape.Description, + IsEtape: true, + Status: taskStatusLabel(etape.Status), + StatusValue: string(etape.Status), + DueDate: formatOptionalDate(etape.DueDate), + DueDateValue: formatOptionalDateInput(etape.DueDate), + Assignee: assigneeName(etape.AssigneeID, assigneeLabels), + AssigneeID: optionalUUIDString(etape.AssigneeID), + Tasks: toTaskRows(children, assigneeLabels), + }) + } + + if len(parentless) > 0 { + slices.SortFunc(parentless, func(a, b taskmodel.Record) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + sections = append(sections, TasksSectionView{ + ID: "sans-etape-" + tablo.ID.String(), + Title: "Sans étape", + IsEtape: false, + Tasks: toTaskRows(parentless, assigneeLabels), + }) + } + + groups = append(groups, TasksTabloGroupView{ + ID: tablo.ID.String(), + Name: tablo.Name, + Color: tablo.Color, + Sections: sections, + }) + } + + return TasksPageViewModel{Tablos: groups} +} + +func TasksPageContent(vm TasksPageViewModel) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, `
Espace de travail
`) + _, _ = io.WriteString(w, `

Mes tâches

`) + _, _ = io.WriteString(w, `

Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.

`) + _, _ = io.WriteString(w, `
`) + + if len(vm.Tablos) == 0 { + _, _ = io.WriteString(w, `

Aucune tâche pour le moment.

`) + return nil + } + + for _, group := range vm.Tablos { + if err := renderTaskTabloGroup(w, group); err != nil { + return err + } + } + + _, _ = io.WriteString(w, ``) + return nil + }) +} + +func renderTaskTabloGroup(w io.Writer, group TasksTabloGroupView) error { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(group.ID), html.EscapeString(group.Name)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(group.ID), html.EscapeString(group.ID)); err != nil { + return err + } + for _, section := range group.Sections { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(section.ID), html.EscapeString(section.Title)); err != nil { + return err + } + if section.IsEtape { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(joinTaskMeta(section.Status, section.DueDate, section.Assignee))); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(section.ID), html.EscapeString(section.Title), html.EscapeString(section.Description), html.EscapeString(section.StatusValue), html.EscapeString(section.DueDateValue), html.EscapeString(section.AssigneeID), html.EscapeString(section.ID)); err != nil { + return err + } + } + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + for _, task := range section.Tasks { + if err := renderTaskRow(w, task); err != nil { + return err + } + } + if _, err := io.WriteString(w, `
`); err != nil { + return err + } + } + _, err := io.WriteString(w, `
`) + return err +} + +func renderTaskRow(w io.Writer, task TaskRowView) error { + if _, err := fmt.Fprintf(w, `

%s

`, html.EscapeString(task.ID), html.EscapeString(task.Title)); err != nil { + return err + } + if strings.TrimSpace(task.Description) != "" { + if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(task.Description)); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, `
%s
`, html.EscapeString(joinTaskMeta(task.Status, task.DueDate, task.Assignee))); err != nil { + return err + } + if _, err := fmt.Fprintf(w, `
`, html.EscapeString(task.ID), html.EscapeString(task.Title), html.EscapeString(task.Description), html.EscapeString(task.StatusValue), html.EscapeString(task.DueDateValue), html.EscapeString(task.AssigneeID), html.EscapeString(task.ParentTaskID), html.EscapeString(task.ID)); err != nil { + return err + } + _, err := io.WriteString(w, `
`) + return err +} + +func toTaskRows(records []taskmodel.Record, assigneeLabels map[uuid.UUID]string) []TaskRowView { + rows := make([]TaskRowView, 0, len(records)) + for _, record := range records { + rows = append(rows, TaskRowView{ + ID: record.ID.String(), + Title: record.Title, + Description: record.Description, + Status: taskStatusLabel(record.Status), + StatusValue: string(record.Status), + DueDate: formatOptionalDate(record.DueDate), + DueDateValue: formatOptionalDateInput(record.DueDate), + Assignee: assigneeName(record.AssigneeID, assigneeLabels), + AssigneeID: optionalUUIDString(record.AssigneeID), + ParentTaskID: optionalUUIDString(record.ParentTaskID), + }) + } + return rows +} + +func taskStatusLabel(status taskmodel.Status) string { + switch status { + case taskmodel.StatusInProgress: + return "En cours" + case taskmodel.StatusInReview: + return "En revue" + case taskmodel.StatusDone: + return "Terminé" + default: + return "À faire" + } +} + +func formatOptionalDate(value *time.Time) string { + if value == nil { + return "" + } + return value.Format("02/01/2006") +} + +func formatOptionalDateInput(value *time.Time) string { + if value == nil { + return "" + } + return value.Format("2006-01-02") +} + +func assigneeName(id *uuid.UUID, labels map[uuid.UUID]string) string { + if id == nil { + return "" + } + if label, ok := labels[*id]; ok { + return label + } + return "" +} + +func optionalUUIDString(id *uuid.UUID) string { + if id == nil { + return "" + } + return id.String() +} + +func joinTaskMeta(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + filtered = append(filtered, part) + } + return strings.Join(filtered, " · ") +} diff --git a/go-backend/router.go b/go-backend/router.go index 6038acb..9a47525 100644 --- a/go-backend/router.go +++ b/go-backend/router.go @@ -32,6 +32,10 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler { // Views mux.Get("/", authHandler.GetHome()) mux.Get("/tasks", authHandler.GetTasksPage()) + mux.Post("/tasks", authHandler.PostTasks()) + mux.Get("/tasks/{taskID}/edit", authHandler.GetEditTaskModal()) + mux.Patch("/tasks/{taskID}", authHandler.PatchTask()) + mux.Delete("/tasks/{taskID}", authHandler.DeleteTask()) mux.Get("/tablos", authHandler.GetTablosPage()) mux.Get("/planning", authHandler.GetPlanningPage()) mux.Get("/chat", authHandler.GetChatPage()) diff --git a/go-backend/router_test.go b/go-backend/router_test.go index 1126b76..fff745a 100644 --- a/go-backend/router_test.go +++ b/go-backend/router_test.go @@ -499,6 +499,85 @@ func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { } } +func TestTasksRoutesCreatePatchAndDeleteThroughRouter(t *testing.T) { + repo := handlers.NewInMemoryAuthRepository() + authUser, err := repo.GetAuthUserByEmail(context.Background(), "demo@xtablo.com") + if err != nil { + t.Fatalf("expected demo user, got error: %v", err) + } + + tablo, err := repo.CreateTablo(context.Background(), handlers.CreateTabloInput{ + OwnerID: authUser.ID, + Name: "Router Tablo", + Color: "#3B82F6", + Status: handlers.TabloStatusTodo, + }) + if err != nil { + t.Fatalf("expected tablo creation to succeed, got error: %v", err) + } + + router := newRouterWithHandler(handlers.NewAuthHandler(repo)) + sessionCookie := loginCookieForRouter(t, router) + + createForm := url.Values{} + createForm.Set("tablo_id", tablo.ID.String()) + createForm.Set("title", "Route Task") + createForm.Set("status", "todo") + + createReq := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(createForm.Encode())) + createReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + createReq.AddCookie(sessionCookie) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + + if createRec.Code != http.StatusOK { + t.Fatalf("expected task create status 200, got %d", createRec.Code) + } + if !strings.Contains(createRec.Body.String(), "Route Task") { + t.Fatalf("expected create response to contain task title, got %q", createRec.Body.String()) + } + + tasks, err := repo.ListTasksByTablo(context.Background(), handlers.ListTasksByTabloInput{ + OwnerID: authUser.ID, + TabloID: tablo.ID, + }) + if err != nil || len(tasks) != 1 { + t.Fatalf("expected one task after create, got %d / err=%v", len(tasks), err) + } + + patchForm := url.Values{} + patchForm.Set("title", "Updated Route Task") + patchForm.Set("description", "Updated from router test") + patchForm.Set("status", "done") + + patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+tasks[0].ID.String(), strings.NewReader(patchForm.Encode())) + patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + patchReq.SetPathValue("taskID", tasks[0].ID.String()) + patchReq.AddCookie(sessionCookie) + patchRec := httptest.NewRecorder() + router.ServeHTTP(patchRec, patchReq) + + if patchRec.Code != http.StatusOK { + t.Fatalf("expected task patch status 200, got %d", patchRec.Code) + } + if !strings.Contains(patchRec.Body.String(), "Updated Route Task") { + t.Fatalf("expected patch response to contain updated title, got %q", patchRec.Body.String()) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/tasks/"+tasks[0].ID.String(), nil) + deleteReq.SetPathValue("taskID", tasks[0].ID.String()) + deleteReq.AddCookie(sessionCookie) + deleteRec := httptest.NewRecorder() + router.ServeHTTP(deleteRec, deleteReq) + + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected task delete status 200, got %d", deleteRec.Code) + } + if strings.Contains(deleteRec.Body.String(), "Updated Route Task") { + t.Fatalf("expected delete response to remove updated task, got %q", deleteRec.Body.String()) + } +} + func TestTablosPageRendersFullDashboardPage(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") @@ -770,3 +849,22 @@ func findCookie(cookies []*http.Cookie, name string) *http.Cookie { } return nil } + +func loginCookieForRouter(t *testing.T, router http.Handler) *http.Cookie { + t.Helper() + + form := url.Values{} + form.Set("email", "demo@xtablo.com") + form.Set("password", "xtablo-demo") + + loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, loginReq) + + sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") + if sessionCookie == nil { + t.Fatal("expected session cookie to be set") + } + return sessionCookie +}