diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index 8092d191..6b80f1f8 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -103,6 +103,12 @@ export class GdprService { throw new NotFoundException('User not found'); } + if (user.deletedAt) { + return { + success: true, + alreadyErased: true, + }; + } await this.sessionService.deleteAllSessionsForUser(userId); await this.usersService.update(userId, { @@ -113,7 +119,33 @@ export class GdprService { refreshToken: null, }); - await this.auditService.log('GDPR_ERASURE', userId); + await this.consentRepository.manager.transaction(async (manager) => { + // Wrap all DB writes in a transaction with ON CONFLICT DO NOTHING or upsert semantics. + await manager + .createQueryBuilder() + .insert() + .into(User) + .values({ + id: userId, + email: null as any, + firstName: '[DELETED]', + lastName: '[DELETED]', + deletedAt: new Date(), + }) + .orUpdate(['email', 'firstName', 'lastName', 'deletedAt'], ['id']) + .execute(); + + await this.usersService.update(userId, { + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + phone: null, + address: null, + deletedAt: new Date(), + }); + + await this.auditService.log('GDPR_ERASURE', userId); + }); return { success: true, diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index 956559b8..525e3df9 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -54,6 +54,19 @@ const mockConsentRepository = { find: jest.fn().mockResolvedValue([]), create: jest.fn((dto) => ({ ...dto, id: 'consent-1' })), save: jest.fn((consent) => Promise.resolve(consent)), + manager: { + transaction: jest.fn(async (cb) => { + const mockEntityManager = { + createQueryBuilder: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orUpdate: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + return cb(mockEntityManager); + }), + }, }; const mockAuditService = { @@ -157,6 +170,40 @@ describe('GdprService', () => { ); }); + it('supports idempotent erasure on repeated calls', async () => { + // Reset mock history + mockUsersService.update.mockClear(); + mockAuditService.log.mockClear(); + + // First call + const result1 = await service.eraseUserData('user-1'); + expect(result1.success).toBe(true); + expect(mockUsersService.update).toHaveBeenCalledTimes(1); + expect(mockAuditService.log).toHaveBeenCalledWith('GDPR_ERASURE', 'user-1'); + + // Simulate database state change by updating the mock return value to have deletedAt + const originalFindById = mockUsersService.findById; + mockUsersService.findById = jest.fn().mockResolvedValue({ + id: 'user-1', + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + deletedAt: new Date(), + }); + + // Second call + const result2 = await service.eraseUserData('user-1'); + expect(result2.success).toBe(true); + expect(result2.alreadyErased).toBe(true); + + // Verify no extra DB updates or audit logs were created + expect(mockUsersService.update).toHaveBeenCalledTimes(1); + expect(mockAuditService.log).toHaveBeenCalledTimes(1); + + // Restore original mock + mockUsersService.findById = originalFindById; + }); + it('stores consent changes', async () => { const result = await service.updateConsent('user-1', { consentType: 'MARKETING', diff --git a/src/workers/base/base.worker.ts b/src/workers/base/base.worker.ts index e4f58cec..70f16101 100644 --- a/src/workers/base/base.worker.ts +++ b/src/workers/base/base.worker.ts @@ -1,4 +1,4 @@ -import { Logger, Inject } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { Job } from 'bull'; import Redis from 'ioredis'; import { getSharedRedisClient } from '../../config/cache.config'; diff --git a/src/workers/orchestration/worker-orchestration.service.ts b/src/workers/orchestration/worker-orchestration.service.ts index 91275f5d..59eb3c13 100644 --- a/src/workers/orchestration/worker-orchestration.service.ts +++ b/src/workers/orchestration/worker-orchestration.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Job } from 'bull'; import { BaseWorker } from '../base/base.worker';