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.
This commit is contained in:
Arthur Belleville 2026-05-10 21:58:48 +02:00
parent 1a00f84364
commit 9a92f358e8
No known key found for this signature in database
17 changed files with 2343 additions and 3 deletions

View file

@ -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;

View file

@ -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
}

View file

@ -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

View file

@ -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 dannonce',
'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;

View file

@ -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"`

View file

@ -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)

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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]
}
}
}
}

View file

@ -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,
`<form class="task-edit-form" data-task-id="%s"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="assignee_id" value="%s"></form>`,
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)
}

View file

@ -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
}

View file

@ -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`}},

View file

@ -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`,

View file

@ -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, `<div class="app-section-page tasks-page">`)
_, _ = io.WriteString(w, `<div class="app-section-surface">`)
_, _ = io.WriteString(w, `<div class="app-section-eyebrow">Espace de travail</div>`)
_, _ = io.WriteString(w, `<h2>Mes tâches</h2>`)
_, _ = io.WriteString(w, `<p>Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.</p>`)
_, _ = io.WriteString(w, `</div>`)
if len(vm.Tablos) == 0 {
_, _ = io.WriteString(w, `<div class="app-section-surface"><p>Aucune tâche pour le moment.</p></div></div>`)
return nil
}
for _, group := range vm.Tablos {
if err := renderTaskTabloGroup(w, group); err != nil {
return err
}
}
_, _ = io.WriteString(w, `</div>`)
return nil
})
}
func renderTaskTabloGroup(w io.Writer, group TasksTabloGroupView) error {
if _, err := fmt.Fprintf(w, `<section class="app-section-surface" data-task-tablo-id="%s"><h3>%s</h3>`, html.EscapeString(group.ID), html.EscapeString(group.Name)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<div class="tasks-create-actions"><form class="task-create-form" hx-post="/tasks" hx-target="#app-main-content" hx-swap="outerHTML"><input type="hidden" name="tablo_id" value="%s"><input type="hidden" name="status" value="todo"><input name="title" placeholder="Nouvelle tâche"><button type="submit">Créer une tâche</button></form><form class="task-create-form" hx-post="/tasks" hx-target="#app-main-content" hx-swap="outerHTML"><input type="hidden" name="tablo_id" value="%s"><input type="hidden" name="status" value="todo"><input type="hidden" name="is_etape" value="true"><input name="title" placeholder="Nouvelle étape"><button type="submit">Créer une étape</button></form></div>`, html.EscapeString(group.ID), html.EscapeString(group.ID)); err != nil {
return err
}
for _, section := range group.Sections {
if _, err := fmt.Fprintf(w, `<div class="tasks-section" data-task-section-id="%s"><div class="tasks-section-header"><h4>%s</h4>`, html.EscapeString(section.ID), html.EscapeString(section.Title)); err != nil {
return err
}
if section.IsEtape {
if _, err := fmt.Fprintf(w, `<p>%s</p>`, html.EscapeString(joinTaskMeta(section.Status, section.DueDate, section.Assignee))); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<form class="task-inline-form" hx-patch="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="due_date" value="%s"><input name="assignee_id" value="%s"><button type="submit">Enregistrer l'étape</button></form><button type="button" hx-delete="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML">Supprimer l'étape</button>`, 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, `</div><div class="task-list">`); err != nil {
return err
}
for _, task := range section.Tasks {
if err := renderTaskRow(w, task); err != nil {
return err
}
}
if _, err := io.WriteString(w, `</div></div>`); err != nil {
return err
}
}
_, err := io.WriteString(w, `</section>`)
return err
}
func renderTaskRow(w io.Writer, task TaskRowView) error {
if _, err := fmt.Fprintf(w, `<article class="task-row" data-task-id="%s"><div class="task-body"><p>%s</p>`, html.EscapeString(task.ID), html.EscapeString(task.Title)); err != nil {
return err
}
if strings.TrimSpace(task.Description) != "" {
if _, err := fmt.Fprintf(w, `<div class="task-description">%s</div>`, html.EscapeString(task.Description)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, `<div class="task-meta"><span>%s</span></div>`, html.EscapeString(joinTaskMeta(task.Status, task.DueDate, task.Assignee))); err != nil {
return err
}
if _, err := fmt.Fprintf(w, `<form class="task-inline-form" hx-patch="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML"><input name="title" value="%s"><textarea name="description">%s</textarea><input name="status" value="%s"><input name="due_date" value="%s"><input name="assignee_id" value="%s"><input name="parent_task_id" value="%s"><button type="submit">Enregistrer</button></form><button type="button" hx-delete="/tasks/%s" hx-target="#app-main-content" hx-swap="outerHTML">Supprimer</button>`, 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, `</div></article>`)
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, " · ")
}

View file

@ -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())

View file

@ -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
}