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:
parent
1a00f84364
commit
9a92f358e8
17 changed files with 2343 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
76
go-backend/internal/tasks/model.go
Normal file
76
go-backend/internal/tasks/model.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
401
go-backend/internal/web/handlers/tasks.go
Normal file
401
go-backend/internal/web/handlers/tasks.go
Normal 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)
|
||||
}
|
||||
447
go-backend/internal/web/handlers/tasks_test.go
Normal file
447
go-backend/internal/web/handlers/tasks_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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`}},
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
284
go-backend/internal/web/views/tasks_view.go
Normal file
284
go-backend/internal/web/views/tasks_view.go
Normal 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, " · ")
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue