feat(api): effacement reel et export complet des donnees
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
parent
5a2fb7db8c
commit
917220c419
@ -1,169 +1,183 @@
|
||||
/**
|
||||
* GDPR Controller
|
||||
*
|
||||
* Endpoints for GDPR compliance (data export, deletion, consent)
|
||||
* Droits des personnes (RGPD) : accès et portabilité, effacement, consentement.
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
Body,
|
||||
UseGuards,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response, Request } from 'express';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||
import { UserPayload } from '../decorators/current-user.decorator';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { GDPRService, GDPRDataExport, GDPRErasureReport } from '../services/gdpr.service';
|
||||
import { UpdateConsentDto, ConsentResponseDto, WithdrawConsentDto } from '../dto/consent.dto';
|
||||
import { DeleteAccountDto } from '../dto/delete-account.dto';
|
||||
import { RetentionService, RetentionReport } from '../services/retention.service';
|
||||
import { RETENTION_RULES } from '@domain/services/data-retention';
|
||||
|
||||
@ApiTags('GDPR')
|
||||
@Controller('gdpr')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@ApiBearerAuth()
|
||||
export class GDPRController {
|
||||
constructor(private readonly gdprService: GDPRService) {}
|
||||
constructor(
|
||||
private readonly gdprService: GDPRService,
|
||||
private readonly retentionService: RetentionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export user data (GDPR Right to Data Portability)
|
||||
*/
|
||||
/** Export de portabilité au format JSON (art. 20). */
|
||||
@Get('export')
|
||||
@ApiOperation({
|
||||
summary: 'Export all user data',
|
||||
description: 'Export all personal data in JSON format (GDPR Article 20)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Data export successful',
|
||||
})
|
||||
@ApiOperation({ summary: 'Exporter ses données personnelles (JSON)' })
|
||||
@ApiResponse({ status: 200, description: 'Export produit' })
|
||||
async exportData(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
const data = await this.gdprService.exportUserData(user.id);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Set headers for file download
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.json"`
|
||||
);
|
||||
|
||||
res.json(exportData);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.json"`);
|
||||
res.json(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export user data as CSV
|
||||
* Même export, en tableur.
|
||||
*
|
||||
* Il ne reprenait que le profil et le consentement cookies, ce qui donnait
|
||||
* deux exports au contenu différent selon le format demandé. Il aplatit
|
||||
* désormais l'export complet.
|
||||
*/
|
||||
@Get('export/csv')
|
||||
@ApiOperation({
|
||||
summary: 'Export user data as CSV',
|
||||
description: 'Export personal data in CSV format for easy viewing',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'CSV export successful',
|
||||
})
|
||||
@ApiOperation({ summary: 'Exporter ses données personnelles (CSV)' })
|
||||
@ApiResponse({ status: 200, description: 'Export produit' })
|
||||
async exportDataCSV(@CurrentUser() user: UserPayload, @Res() res: Response): Promise<void> {
|
||||
const exportData = await this.gdprService.exportUserData(user.id);
|
||||
const data = await this.gdprService.exportUserData(user.id);
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Convert to CSV (simplified version)
|
||||
let csv = 'Category,Field,Value\n';
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="xpeditis-donnees-${day}.csv"`);
|
||||
// BOM : sans lui Excel lit l'UTF-8 comme du latin-1 et casse les accents.
|
||||
res.send('' + toCsv(data));
|
||||
}
|
||||
|
||||
// User data
|
||||
Object.entries(exportData.userData).forEach(([key, value]) => {
|
||||
csv += `User Data,${key},"${value}"\n`;
|
||||
});
|
||||
|
||||
// Cookie consent data
|
||||
if (exportData.cookieConsent) {
|
||||
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
|
||||
csv += `Cookie Consent,${key},"${value}"\n`;
|
||||
/**
|
||||
* Effacement (art. 17).
|
||||
*
|
||||
* Renvoie le détail de ce qui a été effacé et de ce qui a été anonymisé.
|
||||
* L'endpoint répondait 204 : la personne obtenait une page blanche pour
|
||||
* seule réponse à une demande d'effacement, sans moyen de vérifier ce qui
|
||||
* avait effectivement été traité.
|
||||
*/
|
||||
@Delete('delete-account')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Effacer son compte et ses données' })
|
||||
@ApiResponse({ status: 200, description: 'Effacement appliqué' })
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: DeleteAccountDto
|
||||
): Promise<GDPRErasureReport> {
|
||||
// Confirmation par saisie de l'adresse : l'effacement est irréversible.
|
||||
// `new Error` remontait ici en « Internal server error » — une erreur de
|
||||
// saisie affichée comme une panne du service.
|
||||
if (body.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
throw new BadRequestException({
|
||||
code: 'email_mismatch',
|
||||
message: "L'adresse saisie ne correspond pas à celle du compte.",
|
||||
});
|
||||
}
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="xpeditis-data-export-${user.id}-${Date.now()}.csv"`
|
||||
);
|
||||
|
||||
res.send(csv);
|
||||
return this.gdprService.deleteUserData(user.id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Right to Erasure)
|
||||
* Politique de conservation appliquée (art. 13.2.a).
|
||||
*
|
||||
* L'information sur les durées doit être accessible à la personne, pas
|
||||
* seulement écrite dans une politique de confidentialité : elle est servie
|
||||
* ici depuis la règle réellement appliquée par le code.
|
||||
*/
|
||||
@Delete('delete-account')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: 'Delete user account and data',
|
||||
description: 'Permanently delete or anonymize user data (GDPR Article 17)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'Account deletion initiated',
|
||||
})
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: { reason?: string; confirmEmail: string }
|
||||
): Promise<void> {
|
||||
// Verify email confirmation (security measure)
|
||||
if (body.confirmEmail !== user.email) {
|
||||
throw new Error('Email confirmation does not match');
|
||||
}
|
||||
|
||||
await this.gdprService.deleteUserData(user.id, body.reason);
|
||||
@Get('retention')
|
||||
@ApiOperation({ summary: 'Durées de conservation appliquées' })
|
||||
@ApiResponse({ status: 200, description: 'Politique de conservation' })
|
||||
getRetentionPolicy(): { rules: typeof RETENTION_RULES } {
|
||||
return { rules: RETENTION_RULES };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record consent
|
||||
* Journal des demandes de droits, pour la console de conformité.
|
||||
*
|
||||
* Réservé aux administrateurs : c'est l'élément qu'on présente à une
|
||||
* autorité de contrôle pour démontrer que les demandes sont traitées
|
||||
* (art. 5.2). Les effacements y figurent sous une adresse anonymisée.
|
||||
*/
|
||||
@Get('admin/requests')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Journal des demandes de droits (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Demandes récentes' })
|
||||
async listRightsRequests(): Promise<{ requests: Record<string, unknown>[] }> {
|
||||
return { requests: await this.gdprService.listRightsRequests() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la purge supprimerait, sans rien supprimer.
|
||||
*
|
||||
* Une purge est irréversible : la console la montre avant de l'autoriser.
|
||||
*/
|
||||
@Get('admin/retention/preview')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Aperçu de la purge de conservation (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Lignes arrivées à échéance' })
|
||||
async previewRetention(): Promise<RetentionReport> {
|
||||
return this.retentionService.preview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche la purge immédiatement.
|
||||
*
|
||||
* Le POST est délibéré : la purge supprime définitivement des lignes, elle
|
||||
* ne peut pas être déclenchée par une simple navigation.
|
||||
*/
|
||||
@Post('admin/retention/purge')
|
||||
@Roles('admin')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Appliquer les durées de conservation (administration)' })
|
||||
@ApiResponse({ status: 200, description: 'Purge appliquée' })
|
||||
async runRetention(): Promise<RetentionReport> {
|
||||
return this.retentionService.purge();
|
||||
}
|
||||
|
||||
/** Recueil du consentement cookies (art. 7). */
|
||||
@Post('consent')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Record user consent',
|
||||
description: 'Record consent for cookies (GDPR Article 7)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent recorded',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Enregistrer ses préférences de cookies' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async recordConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: UpdateConsentDto,
|
||||
@Req() req: Request
|
||||
): Promise<ConsentResponseDto> {
|
||||
// Add IP and user agent from request if not provided
|
||||
const consentData: UpdateConsentDto = {
|
||||
return this.gdprService.recordConsent(user.id, {
|
||||
...body,
|
||||
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
|
||||
userAgent: body.userAgent || req.headers['user-agent'],
|
||||
};
|
||||
|
||||
return this.gdprService.recordConsent(user.id, consentData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw consent
|
||||
*/
|
||||
/** Retrait du consentement (art. 7.3). */
|
||||
@Post('consent/withdraw')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Withdraw consent',
|
||||
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent withdrawn',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Retirer un consentement' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async withdrawConsent(
|
||||
@CurrentUser() user: UserPayload,
|
||||
@Body() body: WithdrawConsentDto
|
||||
@ -171,20 +185,51 @@ export class GDPRController {
|
||||
return this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consent status
|
||||
*/
|
||||
@Get('consent')
|
||||
@ApiOperation({
|
||||
summary: 'Get current consent status',
|
||||
description: 'Retrieve current consent preferences',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Consent status retrieved',
|
||||
type: ConsentResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: 'Consulter ses préférences de cookies' })
|
||||
@ApiResponse({ status: 200, type: ConsentResponseDto })
|
||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
|
||||
return this.gdprService.getConsentStatus(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Échappement CSV : guillemets doublés, valeur toujours encadrée. */
|
||||
const cell = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '""';
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aplatit l'export en trois colonnes (section, champ, valeur).
|
||||
*
|
||||
* Un CSV par section serait plus lisible mais imposerait une archive ; la
|
||||
* personne qui demande un CSV veut ouvrir un fichier, pas un zip.
|
||||
*/
|
||||
function toCsv(data: GDPRDataExport): string {
|
||||
const lines = ['Section,Champ,Valeur'];
|
||||
|
||||
const flat = (section: string, record: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
lines.push([cell(section), cell(key), cell(value)].join(','));
|
||||
}
|
||||
};
|
||||
|
||||
flat('Compte', data.userData);
|
||||
if (data.organisation) flat('Organisation', data.organisation);
|
||||
if (data.cookieConsent) flat('Consentement cookies', data.cookieConsent);
|
||||
|
||||
const collections: [string, Record<string, unknown>[]][] = [
|
||||
['Réservations', data.bookings],
|
||||
['Notifications', data.notifications],
|
||||
['Conversations assistant', data.assistantConversations],
|
||||
["Clés d'API", data.apiKeys],
|
||||
['Journal d activite', data.activityLog],
|
||||
];
|
||||
|
||||
for (const [section, rows] of collections) {
|
||||
rows.forEach((row, index) => flat(`${section} ${index + 1}`, row));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal file
27
apps/backend/src/application/dto/delete-account.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Demande d'effacement (RGPD art. 17).
|
||||
*
|
||||
* Le corps de la requête n'était pas validé : `confirmEmail` arrivait en
|
||||
* `any`, et une valeur absente déclenchait une comparaison sur `undefined`
|
||||
* remontée en erreur 500.
|
||||
*/
|
||||
export class DeleteAccountDto {
|
||||
@ApiProperty({
|
||||
example: 'personne@example.com',
|
||||
description: "Adresse du compte, ressaisie pour confirmer un acte irréversible",
|
||||
})
|
||||
@IsEmail({}, { message: 'Une adresse email valide est requise pour confirmer.' })
|
||||
confirmEmail: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "Je n'utilise plus le service",
|
||||
description: "Motif facultatif. La personne n'a pas à le justifier (art. 17.1).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@ -6,26 +6,26 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { GDPRController } from '../controllers/gdpr.controller';
|
||||
import { GDPRService } from '../services/gdpr.service';
|
||||
import { RetentionService } from '../services/retention.service';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
UserOrmEntity,
|
||||
BookingOrmEntity,
|
||||
AuditLogOrmEntity,
|
||||
NotificationOrmEntity,
|
||||
CookieConsentOrmEntity,
|
||||
]),
|
||||
// Les autres tables touchees par l'effacement (csv_bookings, audit_logs,
|
||||
// notifications, trade_conversations, password_reset_tokens) sont lues en
|
||||
// SQL via DataSource : la moitie d'entre elles n'a pas d'entite ORM, et
|
||||
// BookingOrmEntity pointait vers une table `bookings` qui n'existe pas.
|
||||
TypeOrmModule.forFeature([UserOrmEntity, CookieConsentOrmEntity]),
|
||||
// Les demandes de droits sont journalisees : l'article 5.2 impose de
|
||||
// pouvoir demontrer qu'elles ont ete traitees.
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [GDPRController],
|
||||
providers: [GDPRService],
|
||||
exports: [GDPRService],
|
||||
providers: [GDPRService, RetentionService],
|
||||
exports: [GDPRService, RetentionService],
|
||||
})
|
||||
export class GDPRModule {}
|
||||
|
||||
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal file
249
apps/backend/src/application/services/gdpr.service.spec.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { GDPRService } from './gdpr.service';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { AuditService } from './audit.service';
|
||||
import { RETENTION_RULES, ANONYMISED } from '@domain/services/data-retention';
|
||||
import { AuditAction } from '@domain/entities/audit-log.entity';
|
||||
|
||||
/**
|
||||
* Ces tests portent sur une promesse faite à une personne : « vos données sont
|
||||
* effacées ». La version précédente la faisait sans rien effacer. Ils vérifient
|
||||
* donc d'abord ce qui est réellement exécuté en base, table par table.
|
||||
*/
|
||||
|
||||
interface ExecutedQuery {
|
||||
sql: string;
|
||||
parameters: unknown[];
|
||||
}
|
||||
|
||||
const USER_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const buildUser = (): UserOrmEntity =>
|
||||
({
|
||||
id: USER_ID,
|
||||
organizationId: 'org-1',
|
||||
email: 'jean@example.com',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Durand',
|
||||
phoneNumber: '+33600000000',
|
||||
passwordHash: 'argon2-hash',
|
||||
totpSecret: 'TOTPSECRET',
|
||||
role: 'USER',
|
||||
preferredLanguage: 'fr',
|
||||
isEmailVerified: true,
|
||||
isActive: true,
|
||||
lastLoginAt: new Date('2026-09-01T10:00:00Z'),
|
||||
createdAt: new Date('2026-01-01T10:00:00Z'),
|
||||
updatedAt: new Date('2026-09-01T10:00:00Z'),
|
||||
}) as unknown as UserOrmEntity;
|
||||
|
||||
/** Nombre de lignes renvoyé par le pilote PostgreSQL pour chaque écriture. */
|
||||
const ROWS_TOUCHED = 3;
|
||||
|
||||
function buildService(options: { user?: UserOrmEntity | null } = {}) {
|
||||
const executed: ExecutedQuery[] = [];
|
||||
|
||||
const manager = {
|
||||
// Forme réelle du pilote pour UPDATE et DELETE : [lignes, nombre].
|
||||
query: jest.fn(async (sql: string, parameters: unknown[]) => {
|
||||
executed.push({ sql, parameters });
|
||||
return [[], ROWS_TOUCHED];
|
||||
}),
|
||||
} as unknown as EntityManager;
|
||||
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (callback: (m: EntityManager) => Promise<void>) => callback(manager)),
|
||||
query: jest.fn(async (sql: string, parameters: unknown[]) => {
|
||||
executed.push({ sql, parameters });
|
||||
return [];
|
||||
}),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const user = options.user === undefined ? buildUser() : options.user;
|
||||
|
||||
const userRepository = {
|
||||
findOne: jest.fn(async () => user),
|
||||
} as unknown as Repository<UserOrmEntity>;
|
||||
|
||||
const consentRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
create: jest.fn((value: Partial<CookieConsentOrmEntity>) => ({ ...value })),
|
||||
save: jest.fn(async (value: CookieConsentOrmEntity) => value),
|
||||
} as unknown as Repository<CookieConsentOrmEntity>;
|
||||
|
||||
const audit = { log: jest.fn(async () => undefined) } as unknown as AuditService;
|
||||
|
||||
const service = new GDPRService(userRepository, consentRepository, dataSource, audit);
|
||||
|
||||
return { service, executed, manager, dataSource, consentRepository, audit };
|
||||
}
|
||||
|
||||
/** Toutes les instructions écrites contre une table donnée. */
|
||||
const statementsFor = (executed: ExecutedQuery[], table: string, verb: 'DELETE' | 'UPDATE') =>
|
||||
executed.filter(query => query.sql.includes(verb) && query.sql.includes(table));
|
||||
|
||||
describe('GDPRService — effacement (art. 17)', () => {
|
||||
it('applique à chaque table le traitement décrit par la politique de conservation', async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID, 'Fin de collaboration');
|
||||
|
||||
// La politique et le code ne peuvent pas diverger sans faire échouer ce
|
||||
// test : c'est la politique qui pilote l'assertion, pas une liste recopiée.
|
||||
for (const rule of RETENTION_RULES) {
|
||||
if (rule.onErasure === 'delete') {
|
||||
expect(statementsFor(executed, rule.table, 'DELETE').length).toBeGreaterThan(0);
|
||||
}
|
||||
if (rule.onErasure === 'anonymise') {
|
||||
expect(statementsFor(executed, rule.table, 'UPDATE').length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('ne supprime jamais la ligne du compte : les réservations la référencent en cascade', async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
expect(statementsFor(executed, 'FROM users', 'DELETE')).toHaveLength(0);
|
||||
expect(statementsFor(executed, 'csv_bookings', 'DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("remplace l'identité et rend le compte inutilisable", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
const [update] = statementsFor(executed, 'UPDATE users', 'UPDATE');
|
||||
expect(update.sql).toContain('is_active = false');
|
||||
expect(update.sql).toContain('totp_secret = NULL');
|
||||
expect(update.parameters[1]).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`);
|
||||
// Mot de passe remplacé par une valeur aléatoire : la colonne est NOT NULL,
|
||||
// et une constante partagée signerait tous les comptes effacés.
|
||||
expect(update.parameters[3]).toMatch(/^erased-/);
|
||||
});
|
||||
|
||||
it('compte les lignes réellement touchées, pas la forme du résultat', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const report = await service.deleteUserData(USER_ID);
|
||||
|
||||
// Le pilote renvoie `[lignes, nombre]` : mesurer la longueur du tableau
|
||||
// renverrait 2 partout, quel que soit le contenu de la base.
|
||||
expect(report.deleted.notifications).toBe(ROWS_TOUCHED);
|
||||
expect(report.anonymised.user).toBe(ROWS_TOUCHED);
|
||||
expect(Object.values(report.deleted)).not.toContain(2);
|
||||
});
|
||||
|
||||
it("journalise l'effacement sans y réinscrire l'identité effacée", async () => {
|
||||
const { service, audit } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID, 'Fin de collaboration');
|
||||
|
||||
const [entry] = (audit.log as jest.Mock).mock.calls[0];
|
||||
expect(entry.action).toBe(AuditAction.GDPR_ERASURE_EXECUTED);
|
||||
// Journaliser avant l'effacement effacerait la trace ; y écrire l'adresse
|
||||
// réelle réintroduirait l'identité qu'on vient de supprimer.
|
||||
expect(entry.userEmail).toBe(`${ANONYMISED}+${USER_ID}@invalid.local`);
|
||||
expect(entry.userEmail).not.toContain('jean@example.com');
|
||||
});
|
||||
|
||||
it('opère dans une transaction', async () => {
|
||||
const { service, dataSource } = buildService();
|
||||
|
||||
await service.deleteUserData(USER_ID);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("n'écrit rien si le compte n'existe pas", async () => {
|
||||
const { service, executed } = buildService({ user: null });
|
||||
|
||||
await expect(service.deleteUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(executed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GDPRService — portabilité (art. 20)', () => {
|
||||
it("couvre l'ensemble des données rattachées au compte", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
const data = await service.exportUserData(USER_ID);
|
||||
|
||||
const read = executed.map(query => query.sql).join(' ');
|
||||
for (const table of [
|
||||
'organizations',
|
||||
'csv_bookings',
|
||||
'notifications',
|
||||
'trade_conversations',
|
||||
'api_keys',
|
||||
'audit_logs',
|
||||
]) {
|
||||
expect(read).toContain(table);
|
||||
}
|
||||
expect(data.userId).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it("n'expose aucun secret d'authentification", async () => {
|
||||
const { service, executed } = buildService();
|
||||
|
||||
const data = await service.exportUserData(USER_ID);
|
||||
|
||||
const serialised = JSON.stringify(data);
|
||||
expect(serialised).not.toContain('argon2-hash');
|
||||
expect(serialised).not.toContain('TOTPSECRET');
|
||||
// Le condensat d'une clé d'API reste un secret d'accès.
|
||||
expect(executed.map(query => query.sql).join(' ')).not.toContain('key_hash');
|
||||
});
|
||||
|
||||
it("refuse d'exporter pour un compte inconnu", async () => {
|
||||
const { service } = buildService({ user: null });
|
||||
|
||||
await expect(service.exportUserData(USER_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GDPRService — consentement (art. 7)', () => {
|
||||
it('force les cookies essentiels et horodate le recueil', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const consent = await service.recordConsent(USER_ID, {
|
||||
essential: false,
|
||||
functional: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
expect(consent.essential).toBe(true);
|
||||
expect(consent.functional).toBe(true);
|
||||
expect(consent.consentDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('retire tout ce qui est facultatif quand aucune catégorie n’est précisée', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
const consent = await service.withdrawConsent(USER_ID);
|
||||
|
||||
expect(consent).toMatchObject({ functional: false, analytics: false, marketing: false });
|
||||
expect(consent.essential).toBe(true);
|
||||
});
|
||||
|
||||
it('ne retire que la catégorie visée', async () => {
|
||||
const { service, consentRepository } = buildService();
|
||||
(consentRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
});
|
||||
|
||||
const consent = await service.withdrawConsent(USER_ID, 'marketing');
|
||||
|
||||
expect(consent.marketing).toBe(false);
|
||||
expect(consent.analytics).toBe(true);
|
||||
expect(consent.functional).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,26 +1,53 @@
|
||||
/**
|
||||
* GDPR Compliance Service
|
||||
* Droits des personnes (RGPD).
|
||||
*
|
||||
* Handles data export, deletion, and consent management
|
||||
* with full database persistence
|
||||
* Portabilité (art. 20), effacement (art. 17), preuve du consentement (art. 7).
|
||||
*
|
||||
* Les requêtes sont écrites en SQL plutôt qu'en repositories : la moitié des
|
||||
* tables concernées (`trade_conversations`, `trade_messages`,
|
||||
* `password_reset_tokens`) n'a pas d'entité ORM, et un effacement doit couvrir
|
||||
* la base réelle, pas la partie qui a été modélisée.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
|
||||
import { ANONYMISED, anonymisedEmail } from '@domain/services/data-retention';
|
||||
|
||||
export interface GDPRDataExport {
|
||||
exportDate: string;
|
||||
userId: string;
|
||||
userData: any;
|
||||
cookieConsent: any;
|
||||
message: string;
|
||||
userData: Record<string, unknown>;
|
||||
organisation: Record<string, unknown> | null;
|
||||
bookings: Record<string, unknown>[];
|
||||
notifications: Record<string, unknown>[];
|
||||
assistantConversations: Record<string, unknown>[];
|
||||
apiKeys: Record<string, unknown>[];
|
||||
activityLog: Record<string, unknown>[];
|
||||
cookieConsent: Record<string, unknown> | null;
|
||||
notice: string;
|
||||
}
|
||||
|
||||
/** Ce qui a été effacé ou anonymisé, rendu à la personne comme preuve. */
|
||||
export interface GDPRErasureReport {
|
||||
userId: string;
|
||||
erasedAt: string;
|
||||
deleted: Record<string, number>;
|
||||
anonymised: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Borne de l'export : seuls les journaux d'activité peuvent atteindre des
|
||||
* volumes qui transformeraient l'export en vidage de base.
|
||||
*/
|
||||
const MAX_LOG_ROWS = 5000;
|
||||
|
||||
@Injectable()
|
||||
export class GDPRService {
|
||||
private readonly logger = new Logger(GDPRService.name);
|
||||
@ -29,41 +56,119 @@ export class GDPRService {
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(CookieConsentOrmEntity)
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export all user data (GDPR Article 20 - Right to Data Portability)
|
||||
* Export de portabilité (art. 20).
|
||||
*
|
||||
* L'export précédent ne contenait que le profil et le consentement cookies,
|
||||
* en renvoyant la personne vers « les endpoints respectifs » pour le reste.
|
||||
* Ce n'était pas un export : l'art. 20 porte sur l'ensemble des données
|
||||
* fournies par la personne, pas sur l'échantillon le plus simple à produire.
|
||||
*
|
||||
* Restent volontairement dehors le hachage du mot de passe et le secret TOTP :
|
||||
* ce sont des secrets d'authentification, les livrer affaiblirait le compte
|
||||
* sans rien apporter à la portabilité.
|
||||
*/
|
||||
async exportUserData(userId: string): Promise<GDPRDataExport> {
|
||||
this.logger.log(`Exporting data for user ${userId}`);
|
||||
|
||||
// Fetch user data
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
// Fetch consent data
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
// Sanitize user data (remove password hash)
|
||||
const sanitizedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
// Password hash explicitly excluded for security
|
||||
};
|
||||
const [organisation] = await this.dataSource.query(
|
||||
`SELECT id, name, type, siren, siret, eori, contact_email, contact_phone,
|
||||
address_street, address_city, address_postal_code, address_country
|
||||
FROM organizations WHERE id = $1`,
|
||||
[user.organizationId]
|
||||
);
|
||||
|
||||
const exportData: GDPRDataExport = {
|
||||
const bookings = await this.dataSource.query(
|
||||
`SELECT id, booking_number, carrier_name, origin, destination, volume_cbm, weight_kg,
|
||||
pallet_count, container_type, status, price_eur, price_usd, primary_currency,
|
||||
freight_total, freight_currency, fob_total, fob_currency, commission_amount_eur,
|
||||
transit_days, notes, rejection_reason, requested_at, responded_at, created_at
|
||||
FROM csv_bookings WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const notifications = await this.dataSource.query(
|
||||
`SELECT type, priority, title, message, read, read_at, action_url, created_at
|
||||
FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const assistantConversations = await this.dataSource.query(
|
||||
`SELECT c.id, c.title, c.created_at,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object(
|
||||
'role', m.role, 'content', m.content, 'createdAt', m.created_at)
|
||||
ORDER BY m.created_at)
|
||||
FROM trade_messages m WHERE m.conversation_id = c.id
|
||||
), '[]'::json) AS messages
|
||||
FROM trade_conversations c WHERE c.user_id = $1 ORDER BY c.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Jamais `key_hash` : seule la trace de l'existence de la clé est utile,
|
||||
// et le condensat resterait un secret d'accès.
|
||||
const apiKeys = await this.dataSource.query(
|
||||
`SELECT name, key_prefix, is_active, last_used_at, expires_at, created_at
|
||||
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const activityLog = await this.dataSource.query(
|
||||
`SELECT action, status, resource_type, resource_name, ip_address, timestamp
|
||||
FROM audit_logs WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2`,
|
||||
[userId, MAX_LOG_ROWS]
|
||||
);
|
||||
|
||||
this.logger.log(`GDPR export produced for user ${userId}`);
|
||||
|
||||
// Trace d'accountability (art. 5.2) : pouvoir démontrer que la demande a
|
||||
// été honorée, et quand.
|
||||
await this.audit.log({
|
||||
action: AuditAction.GDPR_DATA_EXPORTED,
|
||||
status: AuditStatus.SUCCESS,
|
||||
userId,
|
||||
userEmail: user.email,
|
||||
organizationId: user.organizationId,
|
||||
resourceType: 'gdpr_request',
|
||||
metadata: {
|
||||
bookings: bookings.length,
|
||||
notifications: notifications.length,
|
||||
assistantConversations: assistantConversations.length,
|
||||
activityLogEntries: activityLog.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
exportDate: new Date().toISOString(),
|
||||
userId,
|
||||
userData: sanitizedUser,
|
||||
userData: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phoneNumber: user.phoneNumber,
|
||||
role: user.role,
|
||||
preferredLanguage: user.preferredLanguage,
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
isActive: user.isActive,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
},
|
||||
organisation: organisation ?? null,
|
||||
bookings,
|
||||
notifications,
|
||||
assistantConversations,
|
||||
apiKeys,
|
||||
activityLog,
|
||||
cookieConsent: consent
|
||||
? {
|
||||
essential: consent.essential,
|
||||
@ -73,175 +178,205 @@ export class GDPRService {
|
||||
consentDate: consent.consentDate,
|
||||
}
|
||||
: null,
|
||||
message:
|
||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||
notice:
|
||||
"Ensemble des données personnelles rattachées à ce compte. Les secrets d'authentification (mot de passe, second facteur, condensats de clés d'API) en sont exclus par sécurité. Le journal d'activité est limité aux " +
|
||||
`${MAX_LOG_ROWS} entrées les plus récentes.`,
|
||||
};
|
||||
|
||||
this.logger.log(`Data export completed for user ${userId}`);
|
||||
|
||||
return exportData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user data (GDPR Article 17 - Right to Erasure)
|
||||
* Note: This is a simplified version. In production, implement full anonymization logic.
|
||||
* Effacement (art. 17).
|
||||
*
|
||||
* L'implémentation précédente supprimait la ligne de consentement cookies,
|
||||
* écrivait « Full implementation pending » dans les logs, et renvoyait un
|
||||
* succès : la personne était informée que ses données étaient effacées alors
|
||||
* que rien ne l'était.
|
||||
*
|
||||
* Deux traitements, décrits dans `domain/services/data-retention.ts` :
|
||||
* ce qui n'existe que pour le confort du service est supprimé ; ce qui répond
|
||||
* à une obligation de conservation (art. 17.3.b) est anonymisé, et sort donc
|
||||
* du champ des données personnelles.
|
||||
*
|
||||
* La ligne `users` est neutralisée plutôt que supprimée : `csv_bookings`,
|
||||
* `licenses` et `api_keys` la référencent en `ON DELETE CASCADE`, un vrai
|
||||
* DELETE emporterait dix ans de pièces comptables avec lui.
|
||||
*
|
||||
* Le tout en transaction : un effacement à moitié appliqué laisserait un
|
||||
* compte ni actif ni effacé, c'est-à-dire un état que rien ne rattrape.
|
||||
*/
|
||||
async deleteUserData(userId: string, reason?: string): Promise<void> {
|
||||
async deleteUserData(userId: string, reason?: string): Promise<GDPRErasureReport> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
this.logger.warn(`GDPR erasure starting for user ${userId} — reason: ${reason ?? 'unspecified'}`);
|
||||
|
||||
const deleted: Record<string, number> = {};
|
||||
const anonymised: Record<string, number> = {};
|
||||
const email = user.email;
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const rows = (sql: string, parameters: unknown[]) => this.affected(manager, sql, parameters);
|
||||
|
||||
// Supprimé : rien n'impose de le conserver.
|
||||
deleted.notifications = await rows('DELETE FROM notifications WHERE user_id = $1', [userId]);
|
||||
// Les messages suivent par cascade sur `conversation_id`.
|
||||
deleted.assistantConversations = await rows(
|
||||
'DELETE FROM trade_conversations WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
deleted.assistantUsage = await rows('DELETE FROM trade_assistant_usage WHERE user_id = $1', [
|
||||
userId,
|
||||
]);
|
||||
deleted.apiKeys = await rows('DELETE FROM api_keys WHERE user_id = $1', [userId]);
|
||||
deleted.cookieConsent = await rows('DELETE FROM cookie_consents WHERE user_id = $1', [userId]);
|
||||
deleted.passwordResetTokens = await rows(
|
||||
'DELETE FROM password_reset_tokens WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
// Une invitation non consommée porte le nom et l'adresse de la personne
|
||||
// sans qu'aucun compte n'en dépende.
|
||||
deleted.pendingInvitations = await rows(
|
||||
'DELETE FROM invitation_tokens WHERE lower(email) = lower($1) AND is_used = false',
|
||||
[email]
|
||||
);
|
||||
|
||||
// Anonymisé : conservé, sans rattachement à la personne.
|
||||
// Les notes sont un champ libre : c'est le seul endroit d'une
|
||||
// réservation où une donnée personnelle peut avoir été saisie.
|
||||
anonymised.bookings = await rows('UPDATE csv_bookings SET notes = NULL WHERE user_id = $1', [
|
||||
userId,
|
||||
]);
|
||||
anonymised.auditLogs = await rows(
|
||||
`UPDATE audit_logs SET user_email = $2, ip_address = NULL, user_agent = NULL
|
||||
WHERE user_id = $1`,
|
||||
[userId, anonymisedEmail(userId)]
|
||||
);
|
||||
// Un profil transporteur mêle données d'entreprise (conservées) et
|
||||
// coordonnées d'une personne (effacées).
|
||||
anonymised.carrierProfile = await rows(
|
||||
`UPDATE carrier_profiles SET phone = NULL, notification_email = NULL, is_active = false
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Le compte : identité remplacée, accès rendu impossible.
|
||||
// Le mot de passe reçoit une valeur aléatoire plutôt que NULL — la
|
||||
// colonne est NOT NULL, et une valeur constante partagée par tous les
|
||||
// comptes effacés serait un motif reconnaissable.
|
||||
anonymised.user = await rows(
|
||||
`UPDATE users SET
|
||||
email = $2, first_name = $3, last_name = $3, phone_number = NULL,
|
||||
password_hash = $4, totp_secret = NULL,
|
||||
is_active = false, is_email_verified = false, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[userId, anonymisedEmail(userId), ANONYMISED, `erased-${uuidv4()}`]
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Initiating data deletion for user ${userId}. Reason: ${reason || 'User request'}`
|
||||
`GDPR erasure completed for user ${userId}: ${JSON.stringify({ deleted, anonymised })}`
|
||||
);
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
// Écrite après la transaction, et avec l'adresse anonymisée : journaliser
|
||||
// avant l'effacement ferait disparaître la trace par l'anonymisation des
|
||||
// journaux, et y inscrire l'adresse réelle réintroduirait l'identité qu'on
|
||||
// vient d'effacer. Ce qu'il faut pouvoir démontrer, c'est que la demande a
|
||||
// été traitée — pas de qui elle émanait.
|
||||
await this.audit.log({
|
||||
action: AuditAction.GDPR_ERASURE_EXECUTED,
|
||||
status: AuditStatus.SUCCESS,
|
||||
userId,
|
||||
userEmail: anonymisedEmail(userId),
|
||||
organizationId: user.organizationId,
|
||||
resourceType: 'gdpr_request',
|
||||
metadata: { reason: reason ?? null, deleted, anonymised },
|
||||
});
|
||||
|
||||
try {
|
||||
// Delete consent data first (will cascade with user deletion)
|
||||
await this.consentRepository.delete({ userId });
|
||||
|
||||
// IMPORTANT: In production, implement full data anonymization
|
||||
// For now, we just mark the account for deletion
|
||||
// Real implementation should:
|
||||
// 1. Anonymize bookings (keep for legal retention)
|
||||
// 2. Delete notifications
|
||||
// 3. Anonymize audit logs
|
||||
// 4. Anonymize user record
|
||||
|
||||
this.logger.warn(`User ${userId} marked for deletion. Full implementation pending.`);
|
||||
this.logger.log(`Data deletion initiated for user ${userId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Data deletion failed for user ${userId}: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
return { userId, erasedAt: new Date().toISOString(), deleted, anonymised };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
||||
* Nombre de lignes réellement touchées.
|
||||
*
|
||||
* Le pilote PostgreSQL de TypeORM renvoie `[lignes, nombre]` pour un UPDATE
|
||||
* ou un DELETE, et la seule liste de lignes pour un SELECT. Compter la
|
||||
* longueur du résultat donnerait donc « 2 » à chaque effacement, quel que
|
||||
* soit le nombre réel — un rapport de conformité faux.
|
||||
*/
|
||||
private async affected(
|
||||
manager: EntityManager,
|
||||
sql: string,
|
||||
parameters: unknown[]
|
||||
): Promise<number> {
|
||||
const result = await manager.query(sql, parameters);
|
||||
return Array.isArray(result) && typeof result[1] === 'number' ? result[1] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal des demandes de droits, pour la console de conformité.
|
||||
*
|
||||
* Lu en SQL depuis `audit_logs` plutôt que via le dépôt d'audit : le filtre
|
||||
* porte sur un préfixe d'action, que l'interface de dépôt n'expose pas.
|
||||
*/
|
||||
async listRightsRequests(limit = 100): Promise<Record<string, unknown>[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT action, status, user_id, user_email, organization_id, metadata, timestamp
|
||||
FROM audit_logs WHERE action LIKE 'gdpr\\_%' ORDER BY timestamp DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le consentement et sa date (art. 7.1 — preuve du consentement).
|
||||
*
|
||||
* Sans entrée d'audit : la ligne de consentement porte déjà l'horodatage,
|
||||
* l'adresse IP et le navigateur, c'est-à-dire exactement la preuve attendue.
|
||||
* Un second enregistrement n'ajouterait rien qu'une écriture par visite.
|
||||
*/
|
||||
async recordConsent(userId: string, consentData: UpdateConsentDto): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Recording consent for user ${userId}`);
|
||||
const existing = await this.consentRepository.findOne({ where: { userId } });
|
||||
const consent = existing ?? this.consentRepository.create({ id: uuidv4(), userId });
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
consent.essential = true; // Sans elles le service ne fonctionne pas : pas de choix à recueillir.
|
||||
consent.functional = consentData.functional ?? false;
|
||||
consent.analytics = consentData.analytics ?? false;
|
||||
consent.marketing = consentData.marketing ?? false;
|
||||
consent.ipAddress = consentData.ipAddress ?? consent.ipAddress;
|
||||
consent.userAgent = consentData.userAgent ?? consent.userAgent;
|
||||
consent.consentDate = new Date();
|
||||
|
||||
// Check if consent already exists
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (consent) {
|
||||
// Update existing consent
|
||||
consent.essential = true; // Always true
|
||||
consent.functional = consentData.functional;
|
||||
consent.analytics = consentData.analytics;
|
||||
consent.marketing = consentData.marketing;
|
||||
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
|
||||
consent.userAgent = consentData.userAgent || consent.userAgent;
|
||||
consent.consentDate = new Date();
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`Consent updated for user ${userId}`);
|
||||
} else {
|
||||
// Create new consent record
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true, // Always true
|
||||
functional: consentData.functional,
|
||||
analytics: consentData.analytics,
|
||||
marketing: consentData.marketing,
|
||||
ipAddress: consentData.ipAddress,
|
||||
userAgent: consentData.userAgent,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`New consent created for user ${userId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
};
|
||||
await this.consentRepository.save(consent);
|
||||
return this.toConsentDto(consent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||
* Retrait du consentement (art. 7.3) : aussi simple à retirer qu'à donner.
|
||||
* Sans catégorie précisée, tout ce qui est facultatif est retiré.
|
||||
*/
|
||||
async withdrawConsent(
|
||||
userId: string,
|
||||
consentType: 'functional' | 'analytics' | 'marketing'
|
||||
consentType?: 'functional' | 'analytics' | 'marketing'
|
||||
): Promise<ConsentResponseDto> {
|
||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||
const current = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Find consent record
|
||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!consent) {
|
||||
// Create default consent with withdrawn type
|
||||
consent = this.consentRepository.create({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
essential: true,
|
||||
functional: consentType === 'functional' ? false : false,
|
||||
analytics: consentType === 'analytics' ? false : false,
|
||||
marketing: consentType === 'marketing' ? false : false,
|
||||
consentDate: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Update specific consent type
|
||||
consent[consentType] = false;
|
||||
consent.consentDate = new Date();
|
||||
}
|
||||
|
||||
await this.consentRepository.save(consent);
|
||||
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
marketing: consent.marketing,
|
||||
consentDate: consent.consentDate,
|
||||
updatedAt: consent.updatedAt,
|
||||
const next: UpdateConsentDto = {
|
||||
essential: true,
|
||||
functional: consentType ? consentType !== 'functional' && (current?.functional ?? false) : false,
|
||||
analytics: consentType ? consentType !== 'analytics' && (current?.analytics ?? false) : false,
|
||||
marketing: consentType ? consentType !== 'marketing' && (current?.marketing ?? false) : false,
|
||||
};
|
||||
|
||||
return this.recordConsent(userId, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current consent status
|
||||
*/
|
||||
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
|
||||
// Verify user exists
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Find consent record
|
||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
||||
return consent ? this.toConsentDto(consent) : null;
|
||||
}
|
||||
|
||||
if (!consent) {
|
||||
// No consent recorded yet - return null to indicate user should provide consent
|
||||
return null;
|
||||
}
|
||||
|
||||
private toConsentDto(consent: CookieConsentOrmEntity): ConsentResponseDto {
|
||||
return {
|
||||
userId,
|
||||
userId: consent.userId,
|
||||
essential: consent.essential,
|
||||
functional: consent.functional,
|
||||
analytics: consent.analytics,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user