From 94a681601f5b8c9978ecd07bafc9005e3401751f Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:57 +0200 Subject: [PATCH] feat(api): purge periodique des donnees arrivees a echeance Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- apps/backend/package-lock.json | 53 ++++++ apps/backend/package.json | 2 + apps/backend/src/app.module.ts | 7 + .../services/retention.service.spec.ts | 98 +++++++++++ .../application/services/retention.service.ts | 157 ++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 apps/backend/src/application/services/retention.service.spec.ts create mode 100644 apps/backend/src/application/services/retention.service.ts diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 2240c5f..9ac3639 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.2.10", "@nestjs/platform-socket.io": "^10.4.20", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^7.1.16", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^10.0.1", @@ -3216,6 +3217,33 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", + "integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==", + "license": "MIT", + "dependencies": { + "cron": "3.2.1", + "uuid": "11.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -4441,6 +4469,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -6792,6 +6826,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz", + "integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -10843,6 +10887,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index a7d1e84..22d95ad 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "nest build && tsc-alias -p tsconfig.build.json", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "knowledge:build": "node scripts/setup/build-knowledge-corpus.js", "start": "nest start", "dev": "nest start --watch", "start:debug": "nest start --debug --watch", @@ -35,6 +36,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.2.10", "@nestjs/platform-socket.io": "^10.4.20", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^7.1.16", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^10.0.1", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index a8a636d..f4f58c9 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,6 +1,7 @@ import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module'; import { McpModule } from './application/mcp/mcp.module'; import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggerModule } from 'nestjs-pino'; @@ -46,6 +47,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; @Module({ imports: [ + ScheduleModule.forRoot(), // Configuration ConfigModule.forRoot({ isGlobal: true, @@ -78,6 +80,11 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'), SMTP_SECURE: Joi.boolean().default(false), // Stripe Configuration (optional for development) + // Purge des donnees arrivees au terme de leur duree de + // conservation. Desactivee par defaut : elle supprime + // definitivement des lignes, l'activer est une decision + // d'exploitation. + RETENTION_PURGE_ENABLED: Joi.string().valid('true', 'false').default('false'), OPENAI_API_KEY: Joi.string().allow('').optional(), OPENAI_MODEL: Joi.string().default('gpt-4.1-mini'), OPENAI_EMBEDDING_MODEL: Joi.string().default('text-embedding-3-small'), diff --git a/apps/backend/src/application/services/retention.service.spec.ts b/apps/backend/src/application/services/retention.service.spec.ts new file mode 100644 index 0000000..ba663b0 --- /dev/null +++ b/apps/backend/src/application/services/retention.service.spec.ts @@ -0,0 +1,98 @@ +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { RetentionService } from './retention.service'; +import { AuditService } from './audit.service'; +import { AuditAction } from '@domain/entities/audit-log.entity'; +import { RETENTION_RULES, purgeableRules } from '@domain/services/data-retention'; + +/** + * La purge supprime définitivement des lignes. Ces tests portent sur ce qu'elle + * touche, et surtout sur ce qu'elle doit épargner. + */ + +const ROWS_REMOVED = 4; + +function buildService(enabled = true) { + const executed: { sql: string; parameters: unknown[] }[] = []; + + const dataSource = { + query: jest.fn(async (sql: string, parameters: unknown[]) => { + executed.push({ sql, parameters }); + return sql.trimStart().startsWith('SELECT') + ? [{ expired: ROWS_REMOVED, oldest: '2020-01-01T00:00:00.000Z' }] + : [[], ROWS_REMOVED]; + }), + } as unknown as DataSource; + + const config = { + get: jest.fn(() => (enabled ? 'true' : 'false')), + } as unknown as ConfigService; + + const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService; + + return { service: new RetentionService(dataSource, config, audit), executed, audit }; +} + +describe('RetentionService', () => { + it("n'applique un délai qu'aux tables qui en ont un", async () => { + const { service, executed } = buildService(); + + await service.purge(); + + const purgeable = purgeableRules().map(rule => rule.table); + expect(executed).toHaveLength(purgeable.length); + for (const rule of RETENTION_RULES) { + const touched = executed.some(query => query.sql.includes(rule.table)); + // Une durée liée à la vie du compte n'a pas de point de départ en base : + // elle est traitée par l'effacement, pas par la purge. + expect(touched).toBe(purgeable.includes(rule.table)); + } + }); + + it("épargne les traces de traitement des demandes de droits", async () => { + const { service, executed } = buildService(); + + await service.purge(); + + const auditPurge = executed.find(query => query.sql.includes('audit_logs')); + // Sans cette clause, la purge des journaux à douze mois effacerait la + // preuve, exigée par l'art. 5.2, qu'un effacement a été honoré. + expect(auditPurge?.sql).toContain("action NOT LIKE 'gdpr\\_%'"); + }); + + it('journalise ce qui a été supprimé', async () => { + const { service, audit } = buildService(); + + const report = await service.purge(); + + expect(report.lines.every(line => line.expired === ROWS_REMOVED)).toBe(true); + const [entry] = (audit.log as jest.Mock).mock.calls[0]; + expect(entry.action).toBe(AuditAction.GDPR_RETENTION_PURGE); + }); + + it("ne supprime rien quand la purge automatique n'est pas activée", async () => { + const { service, executed } = buildService(false); + + await service.scheduledPurge(); + + expect(executed).toHaveLength(0); + }); + + it('ne compte pas la forme du résultat à la place des lignes', async () => { + const { service } = buildService(); + + const report = await service.purge(); + + // Le pilote renvoie `[lignes, nombre]` : mesurer la longueur donnerait 2. + expect(report.lines.map(line => line.expired)).not.toContain(2); + }); + + it('signale ce qui serait supprimé sans rien supprimer', async () => { + const { service, executed } = buildService(); + + const report = await service.preview(); + + expect(executed.every(query => query.sql.trimStart().startsWith('SELECT'))).toBe(true); + expect(report.lines.every(line => line.expired === ROWS_REMOVED)).toBe(true); + }); +}); diff --git a/apps/backend/src/application/services/retention.service.ts b/apps/backend/src/application/services/retention.service.ts new file mode 100644 index 0000000..2802c71 --- /dev/null +++ b/apps/backend/src/application/services/retention.service.ts @@ -0,0 +1,157 @@ +/** + * Application des durées de conservation (RGPD art. 5.1.e). + * + * La politique de confidentialité annonce que les données sont supprimées au + * terme des durées annoncées. Rien ne le faisait : aucune tâche périodique + * n'existait dans le projet, et les journaux comme les notifications + * s'accumulaient indéfiniment. Annoncer une durée sans l'appliquer revient à + * ne pas en avoir. + * + * La purge est **désactivée par défaut**. Elle supprime définitivement des + * lignes : la mettre en route est une décision d'exploitation, pas un effet de + * bord d'un déploiement. `preview()` permet de voir exactement ce qu'elle + * emporterait avant de l'activer par `RETENTION_PURGE_ENABLED=true`. + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { AuditService } from './audit.service'; +import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity'; +import { assertSafeIdentifier, purgeableRules } from '@domain/services/data-retention'; + +export interface RetentionLine { + table: string; + months: number; + /** Lignes ayant dépassé la durée de conservation. */ + expired: number; + /** Date la plus ancienne encore présente, pour situer l'ampleur. */ + oldest: string | null; +} + +export interface RetentionReport { + enabled: boolean; + runAt: string; + lines: RetentionLine[]; +} + +/** Compte technique porté au journal : la purge n'émane d'aucune personne. */ +const SYSTEM_ACTOR = '00000000-0000-0000-0000-000000000000'; + +@Injectable() +export class RetentionService { + private readonly logger = new Logger(RetentionService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly audit: AuditService + ) {} + + get enabled(): boolean { + return this.config.get('RETENTION_PURGE_ENABLED') === 'true'; + } + + /** + * Ce que la purge supprimerait, sans rien supprimer. + * + * C'est la vue que consulte la console de conformité : on voit l'effet avant + * de l'autoriser, plutôt que de découvrir après coup ce qui a disparu. + */ + async preview(): Promise { + const lines: RetentionLine[] = []; + + for (const rule of purgeableRules()) { + const table = assertSafeIdentifier(rule.table); + const column = assertSafeIdentifier(rule.timestampColumn); + const keep = rule.keepWhere ? ` AND (${rule.keepWhere})` : ''; + + const [row] = await this.dataSource.query( + `SELECT count(*)::int AS expired, min(${column}) AS oldest + FROM ${table} WHERE ${column} < now() - ($1 || ' months')::interval${keep}`, + [rule.months] + ); + + lines.push({ + table: rule.table, + months: rule.months, + expired: row?.expired ?? 0, + oldest: row?.oldest ? new Date(row.oldest).toISOString() : null, + }); + } + + return { enabled: this.enabled, runAt: new Date().toISOString(), lines }; + } + + /** + * Supprime les lignes dont la durée de conservation est écoulée. + * + * `trade_messages` n'apparaît pas : les messages suivent la suppression de + * leur conversation par cascade. + */ + async purge(): Promise { + const lines: RetentionLine[] = []; + + for (const rule of purgeableRules()) { + const table = assertSafeIdentifier(rule.table); + const column = assertSafeIdentifier(rule.timestampColumn); + // `keepWhere` protège notamment les traces de traitement des demandes de + // droits : elles vivent dans `audit_logs`, dont le délai est le plus + // court. Sans cette exception, la purge effacerait la preuve qu'une + // demande d'effacement a été honorée. + const keep = rule.keepWhere ? ` AND (${rule.keepWhere})` : ''; + + const result = await this.dataSource.query( + `DELETE FROM ${table} WHERE ${column} < now() - ($1 || ' months')::interval${keep}`, + [rule.months] + ); + // Le pilote renvoie `[lignes, nombre]` pour un DELETE. + const removed = Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0; + + lines.push({ table: rule.table, months: rule.months, expired: removed, oldest: null }); + } + + const total = lines.reduce((sum, line) => sum + line.expired, 0); + this.logger.warn(`Retention purge removed ${total} rows: ${JSON.stringify(lines)}`); + + if (total > 0) { + await this.audit.log({ + action: AuditAction.GDPR_RETENTION_PURGE, + status: AuditStatus.SUCCESS, + userId: SYSTEM_ACTOR, + userEmail: 'system@xpeditis', + organizationId: SYSTEM_ACTOR, + resourceType: 'retention', + metadata: { lines }, + }); + } + + return { enabled: this.enabled, runAt: new Date().toISOString(), lines }; + } + + /** + * Une fois par nuit, à une heure creuse. + * + * Quotidien plutôt qu'horaire : une durée exprimée en mois ne gagne rien à + * être vérifiée toutes les heures, et une purge est une opération d'écriture + * sur des tables volumineuses. + */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async scheduledPurge(): Promise { + if (!this.enabled) { + this.logger.debug('Retention purge disabled (RETENTION_PURGE_ENABLED)'); + return; + } + + try { + await this.purge(); + } catch (error) { + // Une purge qui échoue ne doit pas emporter le processus : elle + // repassera demain, et l'erreur doit être visible. + this.logger.error( + `Retention purge failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +}