feat(api): purge periodique des donnees arrivees a echeance

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:57 +02:00
parent 917220c419
commit 94a681601f
5 changed files with 317 additions and 0 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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'),

View File

@ -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);
});
});

View File

@ -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<string>('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<RetentionReport> {
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<RetentionReport> {
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<void> {
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)}`
);
}
}
}