Modèle de domaine TaskFlow
TaskFlow gère des projets, leurs membres et leurs tâches. Cette page décrit les quatre tables, la façon dont chaque relation est déclarée avec TypeORM, le cycle de vie des statuts de tâche, et le compromis de conception que pose le statut OVERDUE. Les entités apparaissent au J4 (Project), au J5 (Task) et au J8 (User, membres).
Modèle
┌──────────┐ 1 n ┌─────────────────┐ n 1 ┌──────────┐
│ users ├──────────┤ project_members ├──────────┤ projects │
└────┬─────┘ └─────────────────┘ └────┬─────┘
│ 1 │ 1
│ assignee_id (nullable) project_id │
│ n │ n
│ ┌─────────┐ │
└───────────────────┤ tasks ├────────────────────┘
└─────────┘- Un utilisateur participe à plusieurs projets, et un projet a plusieurs membres : relation n-n portée par la table
project_members. - Un projet contient des tâches ; une tâche appartient à un seul projet.
- Une tâche peut être assignée à un utilisateur (ou à personne).
Les tables ci-dessous reprennent la migration générée (src/database/migrations/1793093891613-InitialSchema.ts). Toutes les clés primaires sont des UUID générés par PostgreSQL (uuid_generate_v4()).
users
| Colonne | Type PostgreSQL | Propriété User | Contraintes |
|---|---|---|---|
id | uuid | id: string | Clé primaire |
email | varchar(254) | email: string | Non nul, unique |
password_hash | varchar | passwordHash: string | Non nul, jamais sérialisé (@Exclude()), absent du schéma OpenAPI (@ApiHideProperty()) |
role | enum user_role (ADMIN, MEMBER) | role: Role | Défaut MEMBER |
created_at | timestamptz | createdAt: Date | Défaut now() |
projects
| Colonne | Type PostgreSQL | Propriété Project | Contraintes |
|---|---|---|---|
id | uuid | id: string | Clé primaire |
name | varchar(100) | name: string | Non nul |
description | text | description: string | null | Nullable |
archived | boolean | archived: boolean | Défaut false |
created_at, updated_at | timestamptz | createdAt, updatedAt: Date | Remplies par TypeORM |
deleted_at | timestamptz | deletedAt: Date | null | Remplie par softDelete() ; les find() ignorent alors la ligne |
project_members
| Colonne | Type PostgreSQL | Propriété ProjectMember | Contraintes |
|---|---|---|---|
project_id | uuid | projectId: string | Clé primaire composite ; clé étrangère vers projects, ON DELETE CASCADE |
user_id | uuid | userId: string | Clé primaire composite ; clé étrangère vers users, ON DELETE CASCADE |
joined_at | timestamptz | joinedAt: Date | Défaut now() |
tasks
| Colonne | Type PostgreSQL | Propriété Task | Contraintes |
|---|---|---|---|
id | uuid | id: string | Clé primaire |
title | varchar(200) | title: string | Non nul |
status | enum task_status | status: TaskStatus | Défaut TODO |
due_date | date | dueDate: string | null | Nullable ; revient en chaîne 'YYYY-MM-DD' |
archived | boolean | archived: boolean | Défaut false |
project_id | uuid | projectId: string | Non nul ; clé étrangère vers projects, ON DELETE CASCADE |
assignee_id | uuid | assigneeId: string | null | Nullable ; clé étrangère vers users, ON DELETE SET NULL |
created_at, updated_at | timestamptz | createdAt, updatedAt: Date | Remplies par TypeORM |
Index idx_tasks_project_status sur (project_id, status) : il sert la requête la plus fréquente, la liste des tâches d'un projet filtrée par statut.
Ce que TaskFlow ajoute au modèle du plan
Le plan décrit USER (id, email, passwordHash, role), PROJECT (id, name, description, archived), TASK (id, title, status, dueDate) et la table de liaison des membres. TaskFlow ajoute :
| Ajout | Pourquoi |
|---|---|
created_at, updated_at | Tri par date de création, traçabilité (J4) |
projects.deleted_at | Suppression logique avec softDelete() (J4) |
tasks.archived | Archiver un projet archive ses tâches dans la même transaction (J5) |
project_members.joined_at | La table de liaison porte une donnée : elle devient une entité à part entière |
ON DELETE CASCADE ne joue que sur une suppression physique : la suppression d'un projet par l'API est logique (deleted_at) et laisse ses lignes liées en place, membres compris. C'est pourquoi le contrôle d'accès vérifie l'existence du projet avant l'appartenance (J8, membres).
Relations et entités TypeORM
| Relation | Côté qui porte la clé (@ManyToOne + @JoinColumn) | Côté inverse | ON DELETE |
|---|---|---|---|
| Un projet, n tâches | Task.project, colonne project_id | Project.tasks (@OneToMany) | CASCADE |
| Un utilisateur, n tâches assignées | Task.assignee, colonne assignee_id, nullable | Aucun : relation unidirectionnelle | SET NULL |
| n utilisateurs, n projets | ProjectMember.project et ProjectMember.user | Project.members ; rien côté User | CASCADE des deux côtés |
Comme en JPA, la clé étrangère vit du côté @ManyToOne, et @JoinColumn({ name: 'project_id' }) nomme la colonne. La fonction (task) => task.project passée à @OneToMany joue le rôle de mappedBy. Les types cibles sont donnés par une fonction (() => Project) parce que les fichiers d'entités s'importent mutuellement : la classe n'est lue qu'une fois les deux fichiers chargés.
@Entity('tasks')
@Index('idx_tasks_project_status', ['projectId', 'status'])
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status', default: TaskStatus.Todo })
status: TaskStatus;
// A PostgreSQL `date` comes back as a 'YYYY-MM-DD' string, not a Date.
@Column({ name: 'due_date', type: 'date', nullable: true })
dueDate: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
// Keeping the foreign key as a plain column avoids loading the relation
// just to read or set an id.
@Column({ name: 'project_id', type: 'uuid' })
projectId: string;
@ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ name: 'assignee_id', type: 'uuid', nullable: true })
assigneeId: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'assignee_id' })
assignee: User | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}Task déclare la clé étrangère deux fois : en relation (project) et en colonne simple (projectId). La colonne permet de lire ou de fixer l'identifiant sans charger le projet, ce que font TasksService et l'index composite.
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// softDelete() fills this column; normal find() calls then skip the row.
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];
@OneToMany(() => ProjectMember, (member) => member.project)
members: ProjectMember[];
}La relation n-n n'utilise pas @ManyToMany : la table de liaison porte une donnée (joinedAt), elle devient donc une entité avec une clé primaire composite, comme une entité JPA avec @IdClass ou @EmbeddedId.
// The join table of a many-to-many relation, modelled as its own entity so it
// can carry data (joinedAt). Composite primary key: (project_id, user_id).
@Entity('project_members')
export class ProjectMember {
@PrimaryColumn({ name: 'project_id', type: 'uuid' })
projectId: string;
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => Project, (project) => project.members, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
joinedAt: Date;
}@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 254, unique: true })
email: string;
// Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
// @ApiHideProperty keeps it out of the OpenAPI schema as well.
@Exclude()
@ApiHideProperty()
@Column({ name: 'password_hash', type: 'varchar' })
passwordHash: string;
@Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
role: Role;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}Règles d'accès qui découlent du modèle :
- Un
ADMINvoit tous les projets ; unMEMBERne voit que ceux dont il est membre. - Le créateur d'un projet en devient membre dans la même transaction que la création (J5, transactions).
- Un non-membre reçoit 404, pas 403, pour ne pas révéler qu'un projet existe (J8, membres).
- Un projet supprimé logiquement répond 404 à tous, administrateur compris, et ses tâches aussi :
ProjectsService.findOne()exige que le projet existe et que l'appelant en soit membre (ou admin), etassertAccess(), utilisé parTasksService, lui délègue.
Statuts de tâche
export enum TaskStatus {
Todo = 'TODO',
InProgress = 'IN_PROGRESS',
Done = 'DONE',
Overdue = 'OVERDUE',
}| Statut | Signification | Qui le pose |
|---|---|---|
TODO | À faire | Valeur par défaut à la création (défaut de colonne) |
IN_PROGRESS | En cours | Le client, via PATCH /v1/tasks/:id/status |
DONE | Terminée | Le client, idem |
OVERDUE | En retard | Le cron quotidien, pour les tâches TODO ou IN_PROGRESS non archivées dont l'échéance est passée ; le client peut aussi l'envoyer |
TaskFlow n'impose aucune règle de transition : UpdateTaskStatusDto accepte n'importe quelle valeur de l'enum (@IsEnum(TaskStatus)). Un changement de statut est enregistré, puis publié sous forme d'événement task.status.changed ; renvoyer le statut actuel ne produit ni écriture ni événement.
async updateStatus(id: string, status: TaskStatus, user: AuthUser): Promise<Task> {
const task = await this.tasks.findOneBy({ id });
if (!task) throw new NotFoundException(`Task ${id} not found`);
await this.projects.assertAccess(task.projectId, user);
if (task.status === status) return task;
const from = task.status;
task.status = status;
const saved = await this.tasks.save(task);
// Emit only once the change is saved. Listeners run separately: if one
// fails, this request still succeeds (see NotificationsListener).
this.events.emit(
TASK_STATUS_CHANGED,
new TaskStatusChangedEvent(task.id, task.projectId, from, status, user.id, this.clock()),
);
return saved;
}Le filtre par statut passe par la query string : GET /v1/projects/:projectId/tasks?status=DONE. Une valeur hors enum (?status=LATE) est refusée par ParseEnumPipe avec une 400 (vérifié par les e2e).
OVERDUE : un choix de conception
Le plan fait de OVERDUE un statut, posé chaque nuit par un cron (J9). TaskFlow suit le plan :
/** One UPDATE for every late task; returns how many changed. */
async markOverdue(): Promise<number> {
// The Paris calendar day, like the cron schedule: toISOString() would give
// the UTC day, one day behind at 01:00 in summer.
const today = localDate(this.clock());
const result = await this.tasks.update(
{
dueDate: LessThan(today),
status: In([TaskStatus.Todo, TaskStatus.InProgress]),
archived: false,
},
{ status: TaskStatus.Overdue },
);
return result.affected ?? 0;
}Ce choix mélange deux informations de nature différente : l'avancement du travail (à faire, en cours, terminé), décidé par une personne, et un fait qui découle du temps (l'échéance est passée). Les conséquences :
- L'état précédent est perdu. Une tâche
IN_PROGRESSqui passeOVERDUEne dit plus si quelqu'un travaillait dessus. - Le statut retarde. Entre l'échéance et le passage du cron, la tâche est en retard sans l'afficher ; et si l'échéance devenait modifiable (TaskFlow n'a pas de route pour cela), une tâche reportée resterait
OVERDUEjusqu'à ce qu'on change son statut à la main. - Le changement n'est pas notifié. Le cron fait un seul
UPDATEgroupé, sans passer parupdateStatus(): aucun événementtask.status.changedn'est émis pour ces tâches.
L'alternative consiste à garder status pour le seul avancement (TODO, IN_PROGRESS, DONE) et à calculer le retard à partir de dueDate et status :
// Exemple hors TaskFlow : le retard est calculé, le statut ne décrit que l'avancement
const isOverdue = (task: Task, today: string): boolean =>
task.status !== TaskStatus.Done && task.dueDate !== null && task.dueDate < today;En SQL, le même filtre s'écrit due_date < CURRENT_DATE AND status <> 'DONE'. Comparer des chaînes 'YYYY-MM-DD' fonctionne, car l'ordre alphabétique de ce format est l'ordre chronologique.
| Critère | Statut stocké (TaskFlow, plan) | Retard calculé |
|---|---|---|
| Lister les tâches en retard | status = 'OVERDUE', servi par l'index (project_id, status) | Condition sur due_date et status |
| Fraîcheur | Jusqu'à un jour de décalage (cron quotidien) | Toujours à jour |
| Avancement réel | Perdu au passage en retard | Conservé |
| Tâche dont l'échéance change | Reste OVERDUE | Recalculée automatiquement |
| Tâche planifiée | Nécessaire | Inutile pour le statut (utile seulement pour notifier) |
Les deux approches doivent décider de ce qu'est « aujourd'hui », et dans quel fuseau. Le cron tourne à 1 h, heure de Paris. Calculée avec toISOString(), la date serait celle d'UTC : en heure d'hiver, 1 h à Paris correspond à minuit UTC et les deux dates coïncident ; en heure d'été, il est encore 23 h UTC la veille, et les tâches arrivées à échéance la veille ne seraient marquées qu'au passage suivant. TaskFlow évite ce piège : markOverdue() prend la date de Paris avec localDate(), dans le fuseau APP_TIME_ZONE que le cron utilise aussi.
// Business dates (due dates, "today") are Paris calendar days, not UTC ones:
// at 01:00 in Paris during summer time it is still the previous day in UTC.
export const APP_TIME_ZONE = 'Europe/Paris';
/** 'YYYY-MM-DD' of `date` in APP_TIME_ZONE. */
export function localDate(date: Date, timeZone = APP_TIME_ZONE): string {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}Un test unitaire fixe l'horloge au 30 juin à 23 h UTC (1 h le 1er juillet à Paris) et vérifie que la date comparée est 2026-07-01 (J10). Le retard calculé a le même besoin : en SQL, CURRENT_DATE dépend du fuseau de la session PostgreSQL ; passez plutôt la date du jour en paramètre, calculée de la même façon.
TaskFlow garde le statut stocké pour rester fidèle au plan. Sur un projet réel, le retard calculé est en général le choix le plus sûr : il ne perd aucune information et ne dépend d'aucun job.