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:
parent
917220c419
commit
94a681601f
53
apps/backend/package-lock.json
generated
53
apps/backend/package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.2.10",
|
"@nestjs/platform-express": "^10.2.10",
|
||||||
"@nestjs/platform-socket.io": "^10.4.20",
|
"@nestjs/platform-socket.io": "^10.4.20",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/swagger": "^7.1.16",
|
"@nestjs/swagger": "^7.1.16",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@nestjs/typeorm": "^10.0.1",
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
@ -3216,6 +3217,33 @@
|
|||||||
"rxjs": "^7.1.0"
|
"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": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "10.2.3",
|
"version": "10.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||||
@ -4441,6 +4469,12 @@
|
|||||||
"@types/geojson": "*"
|
"@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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@ -6792,6 +6826,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-env": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
@ -10843,6 +10887,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build && tsc-alias -p tsconfig.build.json",
|
"build": "nest build && tsc-alias -p tsconfig.build.json",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"knowledge:build": "node scripts/setup/build-knowledge-corpus.js",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
@ -35,6 +36,7 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.2.10",
|
"@nestjs/platform-express": "^10.2.10",
|
||||||
"@nestjs/platform-socket.io": "^10.4.20",
|
"@nestjs/platform-socket.io": "^10.4.20",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/swagger": "^7.1.16",
|
"@nestjs/swagger": "^7.1.16",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@nestjs/typeorm": "^10.0.1",
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module';
|
import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module';
|
||||||
import { McpModule } from './application/mcp/mcp.module';
|
import { McpModule } from './application/mcp/mcp.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
@ -46,6 +47,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
// Configuration
|
// Configuration
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
@ -78,6 +80,11 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||||
SMTP_SECURE: Joi.boolean().default(false),
|
SMTP_SECURE: Joi.boolean().default(false),
|
||||||
// Stripe Configuration (optional for development)
|
// 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_API_KEY: Joi.string().allow('').optional(),
|
||||||
OPENAI_MODEL: Joi.string().default('gpt-4.1-mini'),
|
OPENAI_MODEL: Joi.string().default('gpt-4.1-mini'),
|
||||||
OPENAI_EMBEDDING_MODEL: Joi.string().default('text-embedding-3-small'),
|
OPENAI_EMBEDDING_MODEL: Joi.string().default('text-embedding-3-small'),
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
apps/backend/src/application/services/retention.service.ts
Normal file
157
apps/backend/src/application/services/retention.service.ts
Normal 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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user