PD-279 — Revue de Code (7a)¶
Producteur : ChatGPT Date : 2026-03-01 Contexte : PD-279 — Restitution ISO 14641 d'un document archivé (statut RESTITUTED)
Prompt de revue¶
Tu es un revieweur de code senior spécialisé en applications NestJS avec des exigences de conformité probatoire (ISO 14641, archivage électronique). Tu effectues une revue de code pour la story PD-279.
Contexte¶
PD-279 implémente le statut RESTITUTED et les transitions de restitution d'un document archivé (SEALED -> RESTITUTED -> SEALED) avec : - Gardes métier séquentielles (ownership, legal_lock, geo_copy_count, état source) - Atomicité transactionnelle (status + attestation lifecycle_log dans la même transaction) - SLA scheduler (alerte 80%, escalade 100%, RESTITUTION_OVERDUE) - Interdiction de destruction cross-module (HTTP 409) - Idempotence des endpoints (INV-279-11)
Documents de référence¶
Code contracts (invariants)¶
INV-279-01: RESTITUTED DOIT exister dans DocumentStatus
INV-279-02: SEALED→RESTITUTED si owner, legal_lock=false, geo_copy_count>=2, état SEALED (ordre §5.3)
INV-279-03: RESTITUTED→SEALED si owner et état RESTITUTED (pas de check geo_copy_count)
INV-279-04: Attestation lifecycle_log dans la même transaction que MAJ status
INV-279-05: restituted_at + restitution_deadline ; alerte 80%, escalade 100%
INV-279-06: Toute destruction de RESTITUTED → rejet HTTP 409
INV-279-07: Transitions explicitement autorisées/interdites dans la matrice
INV-279-08: Migration up/down réversible
INV-279-09: Atomicité ACID via QueryRunner
INV-279-10: Module destruction vérifie status sur 3 routes (inclusion, exécution, soft-delete)
INV-279-11: Idempotence (déjà dans l'état cible → 200 sans attestation)
Code source à revoir¶
1. RestitutionService (src/modules/documents/services/restitution.service.ts)¶
import {
Injectable,
Logger,
NotFoundException,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, QueryRunner } from 'typeorm';
import { DocumentSecure, DocumentStatus } from '../entities/document-secure.entity';
import { RestitutionErrorCode } from '../dto/restitution-error.dto';
import { RestitutionConfig } from '../config/restitution.config';
import { JournalEventType } from '../../integrity/enums/journal-event-type.enum';
import { AuditLogService } from '../../audit/services/audit-log.service';
import { AuditActionType } from '../../audit/types/audit-action.types';
import { createHash } from 'node:crypto';
import canonicalize from 'canonicalize';
@Injectable()
export class RestitutionService {
private readonly logger = new Logger(RestitutionService.name);
constructor(
private readonly dataSource: DataSource,
private readonly configService: ConfigService,
private readonly auditLogService: AuditLogService,
) {}
private getConfig(): RestitutionConfig {
return this.configService.get<RestitutionConfig>('restitution') as RestitutionConfig;
}
async restitute(
documentId: string,
actorId: string,
securityLevel: string,
): Promise<DocumentSecure> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.startTransaction();
const rows = (await queryRunner.query(
`SELECT
d.id, d.status, d.user_id, d.legal_lock, d.geo_copy_count,
d.restituted_at, d.restitution_deadline,
d.sealed_at, d.retention_until, d.expired_at,
d.deleted_at, d.ovh_path, d.created_at, d.updated_at
FROM vault_secure.documents d
WHERE d.id = $1
FOR UPDATE`,
[documentId],
)) as Array<Record<string, unknown>>;
if (rows.length === 0) {
await queryRunner.rollbackTransaction();
throw new NotFoundException({
error_code: 'DOCUMENT_NOT_FOUND',
message: `Document ${documentId} not found`,
});
}
const row = rows[0];
const status = row.status as DocumentStatus;
const userId = row.user_id as string;
const legalLock = row.legal_lock as boolean;
const geoCopyCount = row.geo_copy_count as number;
if (userId !== actorId) {
await queryRunner.rollbackTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RESTITUTION_REFUSED,
entityId: documentId,
entityType: 'document',
metadata: { reason: 'NOT_DOCUMENT_OWNER' },
});
throw new ForbiddenException({
error_code: 'NOT_DOCUMENT_OWNER',
message: 'Only the document owner can restitute',
});
}
if (status !== DocumentStatus.SEALED && status !== DocumentStatus.RESTITUTED) {
await queryRunner.rollbackTransaction();
throw new ConflictException({
error_code: RestitutionErrorCode.INVALID_SOURCE_STATE,
message: `Cannot restitute document in state ${status}`,
details: { current_state: status },
});
}
if (status === DocumentStatus.RESTITUTED) {
await queryRunner.rollbackTransaction();
return this.buildDocumentFromRow(row);
}
if (legalLock) {
await queryRunner.rollbackTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RESTITUTION_REFUSED,
entityId: documentId,
entityType: 'document',
metadata: { reason: 'DOCUMENT_UNDER_LEGAL_LOCK' },
});
throw new ConflictException({
error_code: RestitutionErrorCode.DOCUMENT_UNDER_LEGAL_LOCK,
message: 'Document is under legal lock and cannot be restituted',
});
}
if (geoCopyCount < 2) {
await queryRunner.rollbackTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RESTITUTION_REFUSED,
entityId: documentId,
entityType: 'document',
metadata: { reason: 'INSUFFICIENT_GEO_COPIES', geo_copy_count: geoCopyCount },
});
throw new ConflictException({
error_code: RestitutionErrorCode.INSUFFICIENT_GEO_COPIES,
message: `Insufficient geo copies: ${geoCopyCount} < 2`,
details: { geo_copy_count: geoCopyCount },
});
}
const config = this.getConfig();
const restitutedAt = new Date();
const restitutionDeadline = new Date(
restitutedAt.getTime() + config.restitutionMaxDuration * 24 * 60 * 60 * 1000,
);
await queryRunner.query(
`UPDATE vault_secure.documents
SET status = $1, restituted_at = $2, restitution_deadline = $3, updated_at = NOW()
WHERE id = $4 AND status = $5`,
[DocumentStatus.RESTITUTED, restitutedAt, restitutionDeadline, documentId, DocumentStatus.SEALED],
);
await this.appendJournalEntryInTransaction(queryRunner, documentId, JournalEventType.RESTITUTION, {
actor_id: actorId,
security_level: securityLevel,
transition: 'SEALED_TO_RESTITUTED',
restituted_at: restitutedAt.toISOString(),
restitution_deadline: restitutionDeadline.toISOString(),
});
await queryRunner.commitTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RESTITUTE,
entityId: documentId,
entityType: 'document',
metadata: {
transition: 'SEALED_TO_RESTITUTED',
restituted_at: restitutedAt.toISOString(),
restitution_deadline: restitutionDeadline.toISOString(),
},
});
const updated = this.buildDocumentFromRow(row);
updated.status = DocumentStatus.RESTITUTED;
updated.restitutedAt = restitutedAt;
updated.restitutionDeadline = restitutionDeadline;
return updated;
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
}
async returnFromRestitution(
documentId: string,
actorId: string,
securityLevel: string,
): Promise<DocumentSecure> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.startTransaction();
const rows = (await queryRunner.query(
`SELECT
d.id, d.status, d.user_id, d.legal_lock, d.geo_copy_count,
d.restituted_at, d.restitution_deadline,
d.sealed_at, d.retention_until, d.expired_at,
d.deleted_at, d.ovh_path, d.created_at, d.updated_at
FROM vault_secure.documents d
WHERE d.id = $1
FOR UPDATE`,
[documentId],
)) as Array<Record<string, unknown>>;
if (rows.length === 0) {
await queryRunner.rollbackTransaction();
throw new NotFoundException({
error_code: 'DOCUMENT_NOT_FOUND',
message: `Document ${documentId} not found`,
});
}
const row = rows[0];
const status = row.status as DocumentStatus;
const userId = row.user_id as string;
if (userId !== actorId) {
await queryRunner.rollbackTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RESTITUTION_REFUSED,
entityId: documentId,
entityType: 'document',
metadata: { reason: 'NOT_DOCUMENT_OWNER', operation: 'return_from_restitution' },
});
throw new ForbiddenException({
error_code: 'NOT_DOCUMENT_OWNER',
message: 'Only the document owner can return from restitution',
});
}
if (status !== DocumentStatus.RESTITUTED && status !== DocumentStatus.SEALED) {
await queryRunner.rollbackTransaction();
throw new ConflictException({
error_code: RestitutionErrorCode.INVALID_SOURCE_STATE,
message: `Cannot return from restitution: document in state ${status}`,
details: { current_state: status },
});
}
if (status === DocumentStatus.SEALED) {
await queryRunner.rollbackTransaction();
return this.buildDocumentFromRow(row);
}
await queryRunner.query(
`UPDATE vault_secure.documents
SET status = $1, updated_at = NOW()
WHERE id = $2 AND status = $3`,
[DocumentStatus.SEALED, documentId, DocumentStatus.RESTITUTED],
);
await this.appendJournalEntryInTransaction(queryRunner, documentId, JournalEventType.RETURN_FROM_RESTITUTION, {
actor_id: actorId,
security_level: securityLevel,
transition: 'RESTITUTED_TO_SEALED',
});
await queryRunner.commitTransaction();
await this.auditLogService.logAsync({
actorId,
actionType: AuditActionType.DOCUMENT_RETURN_FROM_RESTITUTION,
entityId: documentId,
entityType: 'document',
metadata: { transition: 'RESTITUTED_TO_SEALED' },
});
const updated = this.buildDocumentFromRow(row);
updated.status = DocumentStatus.SEALED;
updated.restitutedAt = null;
updated.restitutionDeadline = null;
return updated;
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
}
private async appendJournalEntryInTransaction(
queryRunner: QueryRunner,
archiveId: string,
eventType: JournalEventType,
payload: Record<string, unknown>,
): Promise<void> {
const canonicalPayload: string = canonicalize(payload) ?? '{}';
const payloadDigest = createHash('sha3-256').update(canonicalPayload).digest('hex');
await queryRunner.query(
`INSERT INTO vault_integrity.integrity_journal_entries
(archive_id, event_type, payload, payload_digest, timestamp)
VALUES ($1, $2, $3::jsonb, $4, NOW())`,
[archiveId, eventType, JSON.stringify(payload), payloadDigest],
);
}
private buildDocumentFromRow(row: Record<string, unknown>): DocumentSecure {
const doc = new DocumentSecure();
doc.id = row.id as string;
doc.userId = row.user_id as string;
doc.status = row.status as DocumentStatus;
doc.legalLock = row.legal_lock as boolean;
doc.geoCopyCount = row.geo_copy_count as number;
doc.restitutedAt = row.restituted_at as Date | null;
doc.restitutionDeadline = row.restitution_deadline as Date | null;
doc.sealedAt = row.sealed_at as Date | null;
doc.retentionUntil = row.retention_until as Date | null;
doc.expiredAt = row.expired_at as Date | null;
doc.deletedAt = row.deleted_at as Date | null;
doc.ovhPath = row.ovh_path as string;
doc.createdAt = row.created_at as Date;
doc.updatedAt = row.updated_at as Date;
return doc;
}
}
2. RestitutionController (src/modules/documents/controllers/restitution.controller.ts)¶
import {
Controller, Post, Param, ParseUUIDPipe, HttpCode, HttpStatus, UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@common/guards/jwt-auth.guard';
import { CurrentUser } from '@common/decorators/current-user.decorator';
import { AuthorizationGuard } from '@modules/auth/guards/authorization.guard';
import { Roles } from '@modules/auth/decorators/roles.decorator';
import { RestitutionService } from '../services/restitution.service';
import { DocumentSecure } from '../entities/document-secure.entity';
@ApiTags('documents')
@Controller('documents')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, AuthorizationGuard)
@Roles('user')
export class RestitutionController {
constructor(private readonly restitutionService: RestitutionService) {}
@Post(':id/restitute')
@HttpCode(HttpStatus.OK)
async restitute(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: { sub: string; security_level?: string },
): Promise<DocumentSecure> {
const securityLevel = user.security_level ?? 'standard';
return this.restitutionService.restitute(id, user.sub, securityLevel);
}
@Post(':id/return-from-restitution')
@HttpCode(HttpStatus.OK)
async returnFromRestitution(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: { sub: string; security_level?: string },
): Promise<DocumentSecure> {
const securityLevel = user.security_level ?? 'standard';
return this.restitutionService.returnFromRestitution(id, user.sub, securityLevel);
}
}
3. RestitutionSlaScheduler (src/modules/documents/services/restitution-sla.scheduler.ts)¶
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { DocumentStatus } from '../entities/document-secure.entity';
import { IntegrityJournalService } from '../../integrity/services/integrity-journal.service';
import { JournalEventType } from '../../integrity/enums/journal-event-type.enum';
import { AuditLogService } from '../../audit/services/audit-log.service';
import { AuditActionType } from '../../audit/types/audit-action.types';
import { createHash } from 'node:crypto';
import canonicalize from 'canonicalize';
@Injectable()
export class RestitutionSlaScheduler implements OnModuleInit {
private readonly logger = new Logger(RestitutionSlaScheduler.name);
constructor(
private readonly dataSource: DataSource,
private readonly integrityJournalService: IntegrityJournalService,
private readonly auditLogService: AuditLogService,
) {}
onModuleInit(): void {
this.logger.log('RestitutionSlaScheduler initialized');
}
@Cron(CronExpression.EVERY_HOUR)
async checkRestitutionSla(): Promise<void> {
// [Full code — see source file]
}
private async emitOverdueEvents(...): Promise<boolean> { /* ... */ }
private async emitAlertEvent(...): Promise<boolean> { /* ... */ }
private async emitEventIfNotExists(...): Promise<boolean> { /* ... */ }
}
4. Migration DDL (src/database/migrations/1741300000000-PD279-AddRestitutedStatus.ts)¶
// [Full migration code — see source file]
// Phase 1: ALTER TYPE ADD VALUE IF NOT EXISTS 'RESTITUTED'
// Phase 2: ADD COLUMN geo_copy_count INTEGER NOT NULL DEFAULT 0
// Phase 3: ADD COLUMN restituted_at TIMESTAMPTZ NULL, restitution_deadline TIMESTAMPTZ NULL
// Phase 4: CHECK CONSTRAINT restitution_deadline >= restituted_at
// Phase 5: Partial index on (status, restitution_deadline) WHERE status = 'RESTITUTED'
// Down: precondition COUNT(RESTITUTED)=0, then drop index/constraint/columns
5. RestitutionConfig (src/modules/documents/config/restitution.config.ts)¶
// Joi schema: RESTITUTION_MAX_DURATION_DAYS integer [1, 30] default 30
// Fixed thresholds: alert 80%, escalation 100%
// IMPORTANT: Number.parseInt('3.5', 10) returns 3 — decimals silently truncated
Grille d'analyse¶
Pour chaque fichier, analyser :
- Conformité aux invariants : Chaque INV-279-XX est-il respecté ?
- Qualité du code : Lisibilité, nommage, patterns NestJS, SOLID
- Gestion des erreurs : Exception types, error_code, messages
- Atomicité transactionnelle : QueryRunner lifecycle, rollback paths
- Sécurité : Injection SQL, ownership bypass, timing attacks, error leakage
- Pattern cohérence : Alignement avec PD-250/PD-251 patterns existants
- Code contracts : Forbidden clauses respectées ?
Points d'attention spécifiques¶
- L'ordre des gardes dans
restitute()correspond-il exactement à §5.3 ? - Le
returnFromRestitution()NE vérifie PASgeo_copy_count(INV-279-03) ? - L'attestation lifecycle_log est-elle DANS la transaction (pas post-commit) ?
- L'idempotence ne crée-t-elle PAS de nouvelle attestation ?
- La config Joi accepte
3.5viaparseInttruncation — est-ce un bug ?
Format de sortie attendu¶
## Verdict : OK / OK AVEC RESERVES / NON CONFORME
### Points conformes
- [liste]
### Ecarts identifiés
| ID | Fichier | Ligne | Gravité | Description | Invariant |
|----|---------|-------|---------|-------------|-----------|
| ... | ... | ... | BLOQUANT/MAJEUR/MINEUR | ... | INV-279-XX |
### Recommandations
- [liste]