Skip to content

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 ​

text
┌──────────┐ 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 ​

ColonneType PostgreSQLPropriété UserContraintes
iduuidid: stringClé primaire
emailvarchar(254)email: stringNon nul, unique
password_hashvarcharpasswordHash: stringNon nul, jamais sérialisé (@Exclude()), absent du schéma OpenAPI (@ApiHideProperty())
roleenum user_role (ADMIN, MEMBER)role: RoleDéfaut MEMBER
created_attimestamptzcreatedAt: DateDéfaut now()

projects ​

ColonneType PostgreSQLPropriété ProjectContraintes
iduuidid: stringClé primaire
namevarchar(100)name: stringNon nul
descriptiontextdescription: string | nullNullable
archivedbooleanarchived: booleanDéfaut false
created_at, updated_attimestamptzcreatedAt, updatedAt: DateRemplies par TypeORM
deleted_attimestamptzdeletedAt: Date | nullRemplie par softDelete() ; les find() ignorent alors la ligne

project_members ​

ColonneType PostgreSQLPropriété ProjectMemberContraintes
project_iduuidprojectId: stringClé primaire composite ; clé étrangère vers projects, ON DELETE CASCADE
user_iduuiduserId: stringClé primaire composite ; clé étrangère vers users, ON DELETE CASCADE
joined_attimestamptzjoinedAt: DateDéfaut now()

tasks ​

ColonneType PostgreSQLPropriété TaskContraintes
iduuidid: stringClé primaire
titlevarchar(200)title: stringNon nul
statusenum task_statusstatus: TaskStatusDéfaut TODO
due_datedatedueDate: string | nullNullable ; revient en chaîne 'YYYY-MM-DD'
archivedbooleanarchived: booleanDéfaut false
project_iduuidprojectId: stringNon nul ; clé étrangère vers projects, ON DELETE CASCADE
assignee_iduuidassigneeId: string | nullNullable ; clé étrangère vers users, ON DELETE SET NULL
created_at, updated_attimestamptzcreatedAt, updatedAt: DateRemplies 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 :

AjoutPourquoi
created_at, updated_atTri par date de création, traçabilité (J4)
projects.deleted_atSuppression logique avec softDelete() (J4)
tasks.archivedArchiver un projet archive ses tâches dans la même transaction (J5)
project_members.joined_atLa 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 ​

RelationCôté qui porte la clé (@ManyToOne + @JoinColumn)Côté inverseON DELETE
Un projet, n tâchesTask.project, colonne project_idProject.tasks (@OneToMany)CASCADE
Un utilisateur, n tâches assignéesTask.assignee, colonne assignee_id, nullableAucun : relation unidirectionnelleSET NULL
n utilisateurs, n projetsProjectMember.project et ProjectMember.userProject.members ; rien côté UserCASCADE 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.

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

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

ts
// 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;
}
ts
@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 ADMIN voit tous les projets ; un MEMBER ne 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), et assertAccess(), utilisé par TasksService, lui délègue.

Statuts de tâche ​

ts
export enum TaskStatus {
  Todo = 'TODO',
  InProgress = 'IN_PROGRESS',
  Done = 'DONE',
  Overdue = 'OVERDUE',
}
StatutSignificationQui le pose
TODOÀ faireValeur par défaut à la création (défaut de colonne)
IN_PROGRESSEn coursLe client, via PATCH /v1/tasks/:id/status
DONETerminéeLe client, idem
OVERDUEEn retardLe 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.

ts
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 :

ts
/** 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_PROGRESS qui passe OVERDUE ne 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 OVERDUE jusqu'à ce qu'on change son statut à la main.
  • Le changement n'est pas notifié. Le cron fait un seul UPDATE groupé, sans passer par updateStatus() : aucun événement task.status.changed n'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 :

ts
// 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èreStatut stocké (TaskFlow, plan)Retard calculé
Lister les tâches en retardstatus = 'OVERDUE', servi par l'index (project_id, status)Condition sur due_date et status
FraîcheurJusqu'à un jour de décalage (cron quotidien)Toujours à jour
Avancement réelPerdu au passage en retardConservé
Tâche dont l'échéance changeReste OVERDUERecalculée automatiquement
Tâche planifiéeNécessaireInutile 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.

ts
// 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.