Les données personnelles

Ce que le site fait réellement des données : une purge chaque nuit qui tient les durées annoncées, des comptes jamais supprimés sans décision humaine, et l’export par chaque membre de toutes ses données, sans mot de passe ni données d’autrui.

Précédemment. Les pages légales sont générées depuis les réglages, et la politique de confidentialité comme le registre présentent une même description des traitements.

Les parcours du chapitre#

Deux parcours traversent ce chapitre : la purge de la nuit, et le membre qui exerce son droit d’accès.

Acteur : Serveur (chaque nuit à 3 heures, et au démarrage)

  1. 1

    Les demandes closes depuis la durée réglée, les sessions et liens expirés, les vignettes orphelines, les formulaires détachés depuis cinq ans sont supprimés

    purgeExpiredData

    • ImageConsentForm supprimée
    • PasswordResetToken supprimée
    • Session supprimée
    • Ticket supprimée
    • TicketEvent supprimée · en cascade
    • TicketMessage supprimée · en cascade

    Lit : ImageConsentForm, SiteSettings, TicketMessage

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    purgeExpiredData packages/backend/src/modules/privacy/retention.service.ts:31
    • purgeDetachedImageConsentForms packages/backend/src/modules/imageConsent/imageConsentForm.service.ts:161
      • prisma.imageConsentForm.delete packages/backend/src/modules/imageConsent/imageConsentForm.service.ts:180ImageConsentForm supprimée
    • db.passwordResetToken.deleteMany packages/backend/src/modules/privacy/retention.service.ts:38PasswordResetToken supprimée
    • db.session.deleteMany packages/backend/src/modules/privacy/retention.service.ts:37Session supprimée
    • db.ticket.deleteMany packages/backend/src/modules/privacy/retention.service.ts:34Ticket supprimée
      • onDelete: Cascade (TicketEvent → Ticket) prisma/schema.prisma:2146TicketEvent supprimée · en cascade
      • onDelete: Cascade (TicketMessage → Ticket) prisma/schema.prisma:2116TicketMessage supprimée · en cascade

Des durées de conservation appliquées 🗓️#

Une durée de conservation annoncée doit être tenue. Une politique qui promet « trois ans après le dernier échange » et une base qui garde tout indéfiniment, c’est une promesse fausse. Une purge tourne donc chaque nuit :

purgeExpiredData — ce qui part chaque nuit

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Les demandes closes depuis longtemps
TS
packages/backend/src/modules/privacy/retention.service.ts
guided extract: lines 31-49focus line 5
import type { PrismaClient } from "@prisma/client";import { parisClock } from "@backend/modules/tickets/recap.service";import { sweepOrphanDerivatives } from "@backend/modules/media/services/mediaDerivatives.service";import { purgeDetachedImageConsentForms } from "@backend/modules/imageConsent/imageConsentForm.service";/* * Durées de conservation (RGPD) APPLIQUÉES, pas seulement annoncées : *   - demande close depuis plus de N mois (réglage ticketRetentionMonths) et sans message depuis : *     supprimée avec ses messages et son historique ; *   - sessions expirées et liens de réinitialisation périmés : supprimés ; *   - formulaires de droit à l'image d'un compte supprimé depuis plus de 5 ans : supprimés. * Les comptes ne sont JAMAIS supprimés automatiquement (le site ne connaît pas la fin d'adhésion) : * listInactiveAccounts les signale aux administrateurs (registre RGPD), qui décident. */const MONTH_MS = 30.4375 * 24 * 3600 * 1000;export function monthsAgo(now: Date, months: number): Date {  return new Date(now.getTime() - months * MONTH_MS);}export async function retentionSettings(db: PrismaClient): Promise<{ ticketMonths: number; accountMonths: number }> {  const s = await db.siteSettings.findUnique({    where: { id: "site" },    select: { ticketRetentionMonths: true, accountRetentionMonths: true },  });  return { ticketMonths: s?.ticketRetentionMonths ?? 36, accountMonths: s?.accountRetentionMonths ?? 12 };}export async function purgeExpiredData(db: PrismaClient, now = new Date()) {  const { ticketMonths } = await retentionSettings(db);  const cutoff = monthsAgo(now, ticketMonths);  const tickets = await db.ticket.deleteMany({    where: { status: "CLOSED", closedAt: { lt: cutoff }, messages: { none: { createdAt: { gte: cutoff } } } },  });  const sessions = await db.session.deleteMany({ where: { expiresAt: { lt: now } } });  const resetTokens = await db.passwordResetToken.deleteMany({ where: { expiresAt: { lt: now } } });  // Vignettes et affiches dont la photo ou la vidéo d'origine a disparu (sans R2 configuré : 0).  const derivatives = await sweepOrphanDerivatives().catch((err) => {    console.error("[rgpd/purge] déclinaisons :", err instanceof Error ? err.message : err);    return 0;  });  const imageConsentForms = await purgeDetachedImageConsentForms(db, now).catch((err) => {    console.error("[rgpd/purge] formulaires de droit à l'image :", err instanceof Error ? err.message : err);    return 0;  });  return { tickets: tickets.count, sessions: sessions.count, resetTokens: resetTokens.count, derivatives, imageConsentForms };}export type InactiveAccount = {  id: string;  name: string;  email: string;  lastLoginAt: Date | null;  createdAt: Date;  memberSince: Date | null;};/** Comptes sans connexion depuis plus de N mois (accountRetentionMonths) : à examiner par un admin. */export async function listInactiveAccounts(db: PrismaClient, now = new Date()): Promise<{ months: number; accounts: InactiveAccount[] }> {  const { accountMonths } = await retentionSettings(db);  const cutoff = monthsAgo(now, accountMonths);  const users = await db.user.findMany({    where: { OR: [{ lastLoginAt: { lt: cutoff } }, { lastLoginAt: null, createdAt: { lt: cutoff } }] },    select: { id: true, firstName: true, lastName: true, email: true, lastLoginAt: true, createdAt: true, memberSince: true },    orderBy: [{ lastLoginAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],  });  return {    months: accountMonths,    accounts: users.map((u) => ({      id: u.id,      name: [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email,      email: u.email,      lastLoginAt: u.lastLoginAt,      createdAt: u.createdAt,      memberSince: u.memberSince,    })),  };}let retentionTimer: ReturnType<typeof setInterval> | null = null;/** Purge quotidienne à 3 h (heure de Paris), plus une passe au démarrage. RETENTION_PURGE_DISABLED=1 la coupe. */export function startRetentionSchedule(db: PrismaClient): boolean {  if (process.env.RETENTION_PURGE_DISABLED === "1" || retentionTimer) return false;  let lastDay: string | null = null;  let busy = false;  const run = (now: Date): void => {    busy = true;    lastDay = parisClock(now).day;    purgeExpiredData(db, now)      .then((r) => {        if (r.tickets || r.sessions || r.resetTokens || r.derivatives || r.imageConsentForms) {          console.log(`[rgpd/purge] ${r.tickets} demande(s) close(s), ${r.sessions} session(s), ${r.resetTokens} lien(s) de réinitialisation, ${r.derivatives} vignette(s) orpheline(s), ${r.imageConsentForms} formulaire(s) de droit à l'image supprimé(s)`);        }      })      .catch((err) => console.error("[rgpd/purge] échec :", err instanceof Error ? err.message : err))      .finally(() => { busy = false; });  };  run(new Date());  retentionTimer = setInterval(() => {    const now = new Date();    const c = parisClock(now);    if (busy || c.hour !== 3 || c.day === lastDay) return;    run(now);  }, 60_000);  return true;}

La purge est lancée au démarrage du serveur, puis chaque nuit à 3 heures, heure de Paris :

Une purge par jour, à heure fixe
TS
packages/backend/src/modules/privacy/retention.service.ts
export function startRetentionSchedule(db: PrismaClient): boolean {  if (process.env.RETENTION_PURGE_DISABLED === "1" || retentionTimer) return false;  let lastDay: string | null = null;  let busy = false;  const run = (now: Date): void => {    busy = true;    lastDay = parisClock(now).day;    purgeExpiredData(db, now)      .then((r) => {        if (r.tickets || r.sessions || r.resetTokens || r.derivatives || r.imageConsentForms) {          console.log(`[rgpd/purge] ${r.tickets} demande(s) close(s), ${r.sessions} session(s), ${r.resetTokens} lien(s) de réinitialisation, ${r.derivatives} vignette(s) orpheline(s), ${r.imageConsentForms} formulaire(s) de droit à l'image supprimé(s)`);        }      })      .catch((err) => console.error("[rgpd/purge] échec :", err instanceof Error ? err.message : err))      .finally(() => { busy = false; });  };  run(new Date());  retentionTimer = setInterval(() => {    const now = new Date();    const c = parisClock(now);    if (busy || c.hour !== 3 || c.day === lastDay) return;    run(now);  }, 60_000);  return true;}

Pas de planificateur externe : une minuterie vérifie chaque minute s’il est 3 heures et si la purge du jour n’a pas déjà tourné (lastDay). busy empêche deux passes de se chevaucher. RETENTION_PURGE_DISABLED=1 la coupe, pour un environnement de démonstration ou une base qu’on examine.

Données

Ce que fait la purge

Demandes closes
Supprimées avec leurs messages et leur historique, une fois la durée réglée écoulée depuis la clôture et le dernier message.
Sessions expirées, liens de réinitialisation périmés
Supprimés.
Vignettes et affiches dont l’original a disparu
Supprimées du stockage.
Formulaires de droit à l’image d’un compte supprimé
Supprimés cinq ans après la suppression du compte.
Comptes
Jamais supprimés automatiquement : listés aux administrateurs.

Des comptes, jamais supprimés sans décision#

Les comptes font exception. Le site ne sait pas quand une adhésion prend fin : l’inscription se fait auprès de la MJC. Supprimer automatiquement un compte resté sans connexion risquerait d’effacer celui d’un membre toujours inscrit, qui ne se connecte simplement pas. Le registre liste donc les comptes inactifs :

Les comptes à examiner
TS
packages/backend/src/modules/privacy/retention.service.ts
export async function listInactiveAccounts(db: PrismaClient, now = new Date()): Promise<{ months: number; accounts: InactiveAccount[] }> {  const { accountMonths } = await retentionSettings(db);  const cutoff = monthsAgo(now, accountMonths);  const users = await db.user.findMany({    where: { OR: [{ lastLoginAt: { lt: cutoff } }, { lastLoginAt: null, createdAt: { lt: cutoff } }] },    select: { id: true, firstName: true, lastName: true, email: true, lastLoginAt: true, createdAt: true, memberSince: true },    orderBy: [{ lastLoginAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],

Un compte sans connexion depuis la durée réglée, ou jamais connecté et créé depuis plus longtemps. Ce sont les administrateurs qui décident, compte par compte, sur la page du registre. La suppression d’un compte emporte alors tout ce qui n’existe que pour lui (partie « Membres et collaboration ») ; seul le formulaire de droit à l’image signé survit, détaché, comme preuve.

Une suppression automatique demande de savoir, sans erreur possible, que la donnée n’a plus de raison d’être. Quand le site ne le sait pas, il signale et laisse décider : une donnée gardée un peu trop longtemps se rattrape, une adhésion effacée par erreur, non.

Le droit d’accès, télécharger ses données#

Depuis son profil, un membre télécharge toutes ses données personnelles en un fichier JSON lisible (droit d’accès et de portabilité, articles 15 et 20 du RGPD) :

GET /api/me/export

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Les siennes, et seulement les siennes
TS
apps/web/src/app/api/me/export/route.ts
guided extract: lines 11-25focus line 4
// ⚠ Fichier introuvable : apps/web/src/app/api/me/export/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.

L’export contient le compte et le profil (dont la majorité et le parent référent pour un mineur), les groupes, les sessions (leurs dates), les demandes et leurs échanges, les documents reçus et leur état de lecture, les publications, commentaires, réactions et votes, les fichiers déposés et les autorisations de droit à l’image.

Il ne contient jamais le mot de passe, même sous forme d’empreinte. Il ne contient pas non plus les données d’autres personnes :

Des réponses pour d’autres : le nombre seulement
TS
packages/backend/src/modules/privacy/exportMyData.service.ts
  // Réponses rédigées en tant que responsable sur les demandes d'AUTRES personnes : nombre seulement.  const repliesForOthers = await db.ticketMessage.count({    where: { authorId: userId, NOT: { ticket: { requesterId: userId } } },  });

Un membre responsable d’une catégorie de demandes a écrit des réponses à d’autres demandeurs ; ces échanges contiennent les données de ces demandeurs. L’export dit combien il en a écrit, pas ce qu’ils contiennent. Les fichiers sont listés par leur nom, sans être joints : le fichier reste lisible et léger.

Un export de données personnelles se construit par une liste blanche de champs (select explicite), jamais par « toute la ligne » : une colonne ajoutée plus tard (un jeton, une empreinte) ne doit pas y entrer par défaut.

Récapitulatif#

Question

Réponse du projet

Les durées de conservation sont-elles tenues ?
Oui : purge au démarrage et chaque nuit à 3 h des demandes closes anciennes, sessions et liens périmés, vignettes orphelines, anciens formulaires détachés.
D’où viennent les durées ?
Des Réglages, comme le texte de la politique : la promesse et son application lisent la même valeur.
Et les comptes ?
Jamais supprimés automatiquement : listés aux administrateurs, qui décident.
Comment un membre accède-t-il à ses données ?
Par un export JSON depuis son profil, en pièce jointe, jamais en cache.
Que ne contient pas l’export ?
Le mot de passe (ni son empreinte), et les données d’autres personnes : seul le nombre de réponses écrites pour d’autres.

À suivre#

Une donnée personnelle demande un soin particulier : l’image des personnes. Une photo de cours peut montrer des membres reconnaissables, et chacun choisit ce qu’il accepte — usage par usage.

Chapitre suivant : le droit à l’image. Un formulaire d’autorisation signé, conservé hors de la bibliothèque et servi à ses seuls ayants droit, un statut qui en découle, et une preuve gardée après le départ.

Sur cette page