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
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).
purgeExpiredDatapackages/backend/src/modules/privacy/retention.service.ts:31purgeDetachedImageConsentFormspackages/backend/src/modules/imageConsent/imageConsentForm.service.ts:161prisma.imageConsentForm.deletepackages/backend/src/modules/imageConsent/imageConsentForm.service.ts:180ImageConsentForm supprimée
db.passwordResetToken.deleteManypackages/backend/src/modules/privacy/retention.service.ts:38PasswordResetToken suppriméedb.session.deleteManypackages/backend/src/modules/privacy/retention.service.ts:37Session suppriméedb.ticket.deleteManypackages/backend/src/modules/privacy/retention.service.ts:34Ticket suppriméeonDelete: Cascade (TicketEvent → Ticket)prisma/schema.prisma:2146TicketEvent supprimée · en cascadeonDelete: 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.
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 :
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. |
Showing entries1to5entries
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 :
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.
// ⚠ 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 :
// 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. |
Showing entries1to5entries
À 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.