From e03c4699d02a2cdcfe6f1075c6849d58f09e50fa Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 05:43:54 +0700 Subject: [PATCH] feat(api): implement GDPR-compliant user data deletion - Add deletedAt/deletionScheduledAt fields to User model with indexes - Implement 5 CQRS command handlers: - RequestUserDeletion: 30-day soft-delete grace period - CancelUserDeletion: restore within grace period - ForceDeleteUser: admin immediate deletion with PII anonymization - ProcessScheduledDeletions: cron-ready batch processor - ExportUserData: GDPR Article 20 data portability - Cascade strategy: anonymize PII, expire listings, cancel subscriptions, delete reviews/inquiries/searches/notifications, preserve payments for audit - Add UserDataController with DELETE /users/me, POST /users/me/cancel-deletion, GET /users/me/export, DELETE /users/:id/force (admin) - 22 unit tests covering all handlers (160 files, 853 tests passing) - Migration: 20260410000000_add_user_soft_delete_fields Co-Authored-By: Paperclip --- .../cancel-user-deletion.handler.spec.ts | 67 ++++++++ .../export-user-data.handler.spec.ts | 88 +++++++++++ .../force-delete-user.handler.spec.ts | 145 ++++++++++++++++++ ...rocess-scheduled-deletions.handler.spec.ts | 89 +++++++++++ .../request-user-deletion.handler.spec.ts | 91 +++++++++++ .../cancel-user-deletion.command.ts | 3 + .../cancel-user-deletion.handler.ts | 27 ++++ .../export-user-data.command.ts | 3 + .../export-user-data.handler.ts | 78 ++++++++++ .../force-delete-user.command.ts | 7 + .../force-delete-user.handler.ts | 84 ++++++++++ .../process-scheduled-deletions.command.ts | 1 + .../process-scheduled-deletions.handler.ts | 48 ++++++ .../request-user-deletion.command.ts | 6 + .../request-user-deletion.handler.ts | 43 ++++++ apps/api/src/modules/auth/auth.module.ts | 13 +- .../controllers/user-data.controller.ts | 87 +++++++++++ .../presentation/dto/force-delete-user.dto.ts | 9 ++ .../presentation/dto/request-deletion.dto.ts | 10 ++ .../migration.sql | 9 ++ prisma/schema.prisma | 10 +- 21 files changed, 914 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/auth/application/__tests__/cancel-user-deletion.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/__tests__/export-user-data.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/__tests__/force-delete-user.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/__tests__/process-scheduled-deletions.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/__tests__/request-user-deletion.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.command.ts create mode 100644 apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts create mode 100644 apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.command.ts create mode 100644 apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts create mode 100644 apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.command.ts create mode 100644 apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.handler.ts create mode 100644 apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.command.ts create mode 100644 apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts create mode 100644 apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.command.ts create mode 100644 apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts create mode 100644 apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts create mode 100644 apps/api/src/modules/auth/presentation/dto/force-delete-user.dto.ts create mode 100644 apps/api/src/modules/auth/presentation/dto/request-deletion.dto.ts create mode 100644 prisma/migrations/20260410000000_add_user_soft_delete_fields/migration.sql diff --git a/apps/api/src/modules/auth/application/__tests__/cancel-user-deletion.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/cancel-user-deletion.handler.spec.ts new file mode 100644 index 0000000..e176cba --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/cancel-user-deletion.handler.spec.ts @@ -0,0 +1,67 @@ +import { NotFoundException, ValidationException } from '@modules/shared'; +import { CancelUserDeletionCommand } from '../commands/cancel-user-deletion/cancel-user-deletion.command'; +import { CancelUserDeletionHandler } from '../commands/cancel-user-deletion/cancel-user-deletion.handler'; + +describe('CancelUserDeletionHandler', () => { + let handler: CancelUserDeletionHandler; + + const mockPrisma = { + user: { findUnique: vi.fn(), update: vi.fn() }, + }; + + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new CancelUserDeletionHandler(mockPrisma as any, mockLogger as any); + }); + + it('cancels pending deletion and reactivates user', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: null, + deletionScheduledAt: new Date(), + }); + mockPrisma.user.update.mockResolvedValue({}); + + const result = await handler.execute(new CancelUserDeletionCommand('user-1')); + + expect(result).toEqual({ message: 'Đã hủy yêu cầu xóa tài khoản' }); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { deletionScheduledAt: null, isActive: true }, + }); + }); + + it('throws NotFoundException if user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + handler.execute(new CancelUserDeletionCommand('missing')), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ValidationException if user already permanently deleted', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: new Date(), + deletionScheduledAt: null, + }); + + await expect( + handler.execute(new CancelUserDeletionCommand('user-1')), + ).rejects.toThrow(ValidationException); + }); + + it('throws ValidationException if no pending deletion', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: null, + deletionScheduledAt: null, + }); + + await expect( + handler.execute(new CancelUserDeletionCommand('user-1')), + ).rejects.toThrow(ValidationException); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/export-user-data.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/export-user-data.handler.spec.ts new file mode 100644 index 0000000..3a53042 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/export-user-data.handler.spec.ts @@ -0,0 +1,88 @@ +import { NotFoundException } from '@modules/shared'; +import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command'; +import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler'; + +describe('ExportUserDataHandler', () => { + let handler: ExportUserDataHandler; + + const mockPrisma = { + user: { findUnique: vi.fn() }, + agent: { findUnique: vi.fn() }, + listing: { findMany: vi.fn() }, + payment: { findMany: vi.fn() }, + subscription: { findFirst: vi.fn() }, + review: { findMany: vi.fn() }, + inquiry: { findMany: vi.fn() }, + savedSearch: { findMany: vi.fn() }, + transaction: { findMany: vi.fn() }, + }; + + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + const sampleUser = { + id: 'user-1', + email: 'test@example.com', + phone: '0912345678', + fullName: 'Nguyen Van A', + role: 'BUYER', + kycStatus: 'VERIFIED', + createdAt: new Date('2025-01-01'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any); + }); + + it('exports all user data including relations', async () => { + mockPrisma.user.findUnique.mockResolvedValue(sampleUser); + mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' }); + mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]); + mockPrisma.payment.findMany.mockResolvedValue([{ id: 'pay-1', amountVND: 500000 }]); + mockPrisma.subscription.findFirst.mockResolvedValue({ id: 'sub-1', status: 'ACTIVE' }); + mockPrisma.review.findMany.mockResolvedValue([{ id: 'review-1' }]); + mockPrisma.inquiry.findMany.mockResolvedValue([{ id: 'inquiry-1' }]); + mockPrisma.savedSearch.findMany.mockResolvedValue([{ id: 'ss-1' }]); + mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]); + + const result = await handler.execute(new ExportUserDataCommand('user-1')); + + expect(result.user).toEqual(sampleUser); + expect(result.agent).toEqual({ id: 'agent-1', userId: 'user-1' }); + expect(result.listings).toHaveLength(1); + expect(result.payments).toHaveLength(1); + expect(result.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' }); + expect(result.reviews).toHaveLength(1); + expect(result.inquiries).toHaveLength(1); + expect(result.savedSearches).toHaveLength(1); + expect(result.transactions).toHaveLength(1); + }); + + it('throws NotFoundException if user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + handler.execute(new ExportUserDataCommand('missing')), + ).rejects.toThrow(NotFoundException); + }); + + it('includes exportedAt timestamp', async () => { + mockPrisma.user.findUnique.mockResolvedValue(sampleUser); + mockPrisma.agent.findUnique.mockResolvedValue(null); + mockPrisma.listing.findMany.mockResolvedValue([]); + mockPrisma.payment.findMany.mockResolvedValue([]); + mockPrisma.subscription.findFirst.mockResolvedValue(null); + mockPrisma.review.findMany.mockResolvedValue([]); + mockPrisma.inquiry.findMany.mockResolvedValue([]); + mockPrisma.savedSearch.findMany.mockResolvedValue([]); + mockPrisma.transaction.findMany.mockResolvedValue([]); + + const before = new Date().toISOString(); + const result = await handler.execute(new ExportUserDataCommand('user-1')); + const after = new Date().toISOString(); + + expect(result.exportedAt).toBeDefined(); + expect(result.exportedAt >= before).toBe(true); + expect(result.exportedAt <= after).toBe(true); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/force-delete-user.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/force-delete-user.handler.spec.ts new file mode 100644 index 0000000..dcc34bc --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/force-delete-user.handler.spec.ts @@ -0,0 +1,145 @@ +import { NotFoundException } from '@modules/shared'; +import { ForceDeleteUserCommand } from '../commands/force-delete-user/force-delete-user.command'; +import { ForceDeleteUserHandler } from '../commands/force-delete-user/force-delete-user.handler'; + +describe('ForceDeleteUserHandler', () => { + let handler: ForceDeleteUserHandler; + + const mockPrisma = { + user: { findUnique: vi.fn(), update: vi.fn() }, + refreshToken: { deleteMany: vi.fn() }, + oAuthAccount: { deleteMany: vi.fn() }, + agent: { updateMany: vi.fn() }, + listing: { updateMany: vi.fn() }, + subscription: { updateMany: vi.fn() }, + review: { deleteMany: vi.fn() }, + inquiry: { deleteMany: vi.fn() }, + savedSearch: { deleteMany: vi.fn() }, + notificationLog: { deleteMany: vi.fn() }, + $transaction: vi.fn((fn: (tx: any) => Promise) => fn(mockPrisma)), + }; + + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new ForceDeleteUserHandler(mockPrisma as any, mockLogger as any); + }); + + it('anonymizes user PII and cascades deletion in transaction', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: null }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.oAuthAccount.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.agent.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.subscription.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.review.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.inquiry.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.savedSearch.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.notificationLog.deleteMany.mockResolvedValue({ count: 0 }); + + const result = await handler.execute( + new ForceDeleteUserCommand('user-1', 'admin-1', 'violation'), + ); + + expect(result).toEqual({ message: 'Tài khoản đã bị xóa vĩnh viễn' }); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + expect(mockPrisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'user-1' }, + data: expect.objectContaining({ + email: 'deleted_user-1@removed.local', + fullName: 'Người dùng đã xóa', + passwordHash: null, + avatarUrl: null, + isActive: false, + deletedAt: expect.any(Date), + deletionScheduledAt: null, + }), + }), + ); + }); + + it('returns early message if user already deleted', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: new Date() }); + + const result = await handler.execute( + new ForceDeleteUserCommand('user-1', 'admin-1', 'cleanup'), + ); + + expect(result).toEqual({ message: 'Tài khoản đã bị xóa' }); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException if user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + handler.execute(new ForceDeleteUserCommand('missing', 'admin-1', 'reason')), + ).rejects.toThrow(NotFoundException); + }); + + it('expires active listings', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: null }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.oAuthAccount.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.agent.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.listing.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.subscription.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.review.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.inquiry.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.savedSearch.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.notificationLog.deleteMany.mockResolvedValue({ count: 0 }); + + await handler.execute(new ForceDeleteUserCommand('user-1', 'admin-1', 'reason')); + + expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({ + where: { sellerId: 'user-1', status: { in: ['ACTIVE', 'PENDING_REVIEW'] } }, + data: { status: 'EXPIRED' }, + }); + }); + + it('cancels active subscription', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: null }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.oAuthAccount.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.agent.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.subscription.updateMany.mockResolvedValue({ count: 1 }); + mockPrisma.review.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.inquiry.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.savedSearch.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.notificationLog.deleteMany.mockResolvedValue({ count: 0 }); + + await handler.execute(new ForceDeleteUserCommand('user-1', 'admin-1', 'reason')); + + expect(mockPrisma.subscription.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', status: 'ACTIVE' }, + data: { status: 'CANCELLED', cancelledAt: expect.any(Date) }, + }); + }); + + it('deletes reviews, inquiries, saved searches, notifications', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 'user-1', deletedAt: null }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.oAuthAccount.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.agent.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.subscription.updateMany.mockResolvedValue({ count: 0 }); + mockPrisma.review.deleteMany.mockResolvedValue({ count: 3 }); + mockPrisma.inquiry.deleteMany.mockResolvedValue({ count: 5 }); + mockPrisma.savedSearch.deleteMany.mockResolvedValue({ count: 2 }); + mockPrisma.notificationLog.deleteMany.mockResolvedValue({ count: 10 }); + + await handler.execute(new ForceDeleteUserCommand('user-1', 'admin-1', 'reason')); + + expect(mockPrisma.review.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + expect(mockPrisma.inquiry.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + expect(mockPrisma.savedSearch.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + expect(mockPrisma.notificationLog.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/process-scheduled-deletions.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/process-scheduled-deletions.handler.spec.ts new file mode 100644 index 0000000..c594b69 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/process-scheduled-deletions.handler.spec.ts @@ -0,0 +1,89 @@ +import { ProcessScheduledDeletionsCommand } from '../commands/process-scheduled-deletions/process-scheduled-deletions.command'; +import { ProcessScheduledDeletionsHandler } from '../commands/process-scheduled-deletions/process-scheduled-deletions.handler'; +import { ForceDeleteUserCommand } from '../commands/force-delete-user/force-delete-user.command'; + +describe('ProcessScheduledDeletionsHandler', () => { + let handler: ProcessScheduledDeletionsHandler; + + const mockPrisma = { + user: { findMany: vi.fn() }, + }; + + const mockCommandBus = { execute: vi.fn() }; + + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new ProcessScheduledDeletionsHandler( + mockPrisma as any, + mockCommandBus as any, + mockLogger as any, + ); + }); + + it('processes all users past their deletion date', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'user-1' }, + { id: 'user-2' }, + ]); + mockCommandBus.execute.mockResolvedValue({ message: 'ok' }); + + const result = await handler.execute(); + + expect(result).toEqual({ processedCount: 2 }); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(2); + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + adminId: 'SYSTEM', + reason: 'Scheduled GDPR deletion after 30-day grace period', + }), + ); + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-2', + adminId: 'SYSTEM', + reason: 'Scheduled GDPR deletion after 30-day grace period', + }), + ); + }); + + it('returns processedCount', async () => { + mockPrisma.user.findMany.mockResolvedValue([{ id: 'user-1' }]); + mockCommandBus.execute.mockResolvedValue({ message: 'ok' }); + + const result = await handler.execute(); + + expect(result).toEqual({ processedCount: 1 }); + }); + + it('handles individual failures gracefully (logs error, continues)', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'user-fail' }, + { id: 'user-ok' }, + ]); + mockCommandBus.execute + .mockRejectedValueOnce(new Error('DB down')) + .mockResolvedValueOnce({ message: 'ok' }); + + const result = await handler.execute(); + + expect(result).toEqual({ processedCount: 1 }); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('user-fail'), + undefined, + 'ProcessScheduledDeletionsHandler', + ); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(2); + }); + + it('returns 0 when no users to process', async () => { + mockPrisma.user.findMany.mockResolvedValue([]); + + const result = await handler.execute(); + + expect(result).toEqual({ processedCount: 0 }); + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/request-user-deletion.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/request-user-deletion.handler.spec.ts new file mode 100644 index 0000000..12620bf --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/request-user-deletion.handler.spec.ts @@ -0,0 +1,91 @@ +import { NotFoundException, ValidationException } from '@modules/shared'; +import { RequestUserDeletionCommand } from '../commands/request-user-deletion/request-user-deletion.command'; +import { RequestUserDeletionHandler } from '../commands/request-user-deletion/request-user-deletion.handler'; + +describe('RequestUserDeletionHandler', () => { + let handler: RequestUserDeletionHandler; + + const mockPrisma = { + user: { findUnique: vi.fn(), update: vi.fn() }, + refreshToken: { updateMany: vi.fn() }, + }; + + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new RequestUserDeletionHandler(mockPrisma as any, mockLogger as any); + }); + + it('schedules deletion 30 days from now and deactivates user', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: null, + deletionScheduledAt: null, + }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 2 }); + + const result = await handler.execute(new RequestUserDeletionCommand('user-1')); + + expect(result.scheduledAt).toBeInstanceOf(Date); + const diffDays = Math.round( + (result.scheduledAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); + expect(diffDays).toBe(30); + + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { deletionScheduledAt: expect.any(Date), isActive: false }, + }); + }); + + it('revokes all active refresh tokens', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: null, + deletionScheduledAt: null, + }); + mockPrisma.user.update.mockResolvedValue({}); + mockPrisma.refreshToken.updateMany.mockResolvedValue({ count: 3 }); + + await handler.execute(new RequestUserDeletionCommand('user-1')); + + expect(mockPrisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + it('throws NotFoundException if user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + handler.execute(new RequestUserDeletionCommand('missing')), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ValidationException if user already deleted', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: new Date(), + deletionScheduledAt: null, + }); + + await expect( + handler.execute(new RequestUserDeletionCommand('user-1')), + ).rejects.toThrow(ValidationException); + }); + + it('throws ValidationException if deletion already scheduled', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + deletedAt: null, + deletionScheduledAt: new Date(), + }); + + await expect( + handler.execute(new RequestUserDeletionCommand('user-1')), + ).rejects.toThrow(ValidationException); + }); +}); diff --git a/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.command.ts b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.command.ts new file mode 100644 index 0000000..2c07b51 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.command.ts @@ -0,0 +1,3 @@ +export class CancelUserDeletionCommand { + constructor(public readonly userId: string) {} +} diff --git a/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts new file mode 100644 index 0000000..046149b --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/cancel-user-deletion/cancel-user-deletion.handler.ts @@ -0,0 +1,27 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { NotFoundException, ValidationException } from '@modules/shared'; +import { CancelUserDeletionCommand } from './cancel-user-deletion.command'; + +@CommandHandler(CancelUserDeletionCommand) +export class CancelUserDeletionHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: CancelUserDeletionCommand): Promise<{ message: string }> { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa vĩnh viễn'); + if (!user.deletionScheduledAt) throw new ValidationException('Không có yêu cầu xóa nào đang chờ'); + + await this.prisma.user.update({ + where: { id: command.userId }, + data: { deletionScheduledAt: null, isActive: true }, + }); + + this.logger.log(`User ${command.userId} deletion cancelled`, 'CancelUserDeletionHandler'); + return { message: 'Đã hủy yêu cầu xóa tài khoản' }; + } +} diff --git a/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.command.ts b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.command.ts new file mode 100644 index 0000000..c643b0c --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.command.ts @@ -0,0 +1,3 @@ +export class ExportUserDataCommand { + constructor(public readonly userId: string) {} +} diff --git a/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts new file mode 100644 index 0000000..645f36b --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/export-user-data/export-user-data.handler.ts @@ -0,0 +1,78 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { NotFoundException } from '@modules/shared'; +import { ExportUserDataCommand } from './export-user-data.command'; + +export interface UserDataExport { + user: { + id: string; + email: string | null; + phone: string; + fullName: string; + role: string; + kycStatus: string; + createdAt: Date; + }; + agent: unknown | null; + listings: unknown[]; + payments: unknown[]; + subscription: unknown | null; + reviews: unknown[]; + inquiries: unknown[]; + savedSearches: unknown[]; + transactions: unknown[]; + exportedAt: string; +} + +@CommandHandler(ExportUserDataCommand) +export class ExportUserDataHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: ExportUserDataCommand): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: command.userId }, + select: { + id: true, email: true, phone: true, fullName: true, + role: true, kycStatus: true, createdAt: true, + }, + }); + + if (!user) throw new NotFoundException('User', command.userId); + + const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] = + await Promise.all([ + this.prisma.agent.findUnique({ where: { userId: command.userId } }), + this.prisma.listing.findMany({ + where: { sellerId: command.userId }, + include: { property: { select: { title: true, address: true, district: true, city: true } } }, + }), + this.prisma.payment.findMany({ + where: { userId: command.userId }, + select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true }, + }), + this.prisma.subscription.findFirst({ where: { userId: command.userId } }), + this.prisma.review.findMany({ where: { userId: command.userId } }), + this.prisma.inquiry.findMany({ where: { userId: command.userId } }), + this.prisma.savedSearch.findMany({ where: { userId: command.userId } }), + this.prisma.transaction.findMany({ where: { buyerId: command.userId } }), + ]); + + this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler'); + + return { + user, + agent, + listings, + payments, + subscription, + reviews, + inquiries, + savedSearches, + transactions, + exportedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.command.ts b/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.command.ts new file mode 100644 index 0000000..7c81454 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.command.ts @@ -0,0 +1,7 @@ +export class ForceDeleteUserCommand { + constructor( + public readonly userId: string, + public readonly adminId: string, + public readonly reason: string, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.handler.ts b/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.handler.ts new file mode 100644 index 0000000..d490704 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/force-delete-user/force-delete-user.handler.ts @@ -0,0 +1,84 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { Prisma } from '@prisma/client'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { NotFoundException } from '@modules/shared'; +import { ForceDeleteUserCommand } from './force-delete-user.command'; + +@CommandHandler(ForceDeleteUserCommand) +export class ForceDeleteUserHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: ForceDeleteUserCommand): Promise<{ message: string }> { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) return { message: 'Tài khoản đã bị xóa' }; + + await this.anonymizeAndDelete(command.userId); + + this.logger.log( + `User ${command.userId} force-deleted by admin ${command.adminId}: ${command.reason}`, + 'ForceDeleteUserHandler', + ); + + return { message: 'Tài khoản đã bị xóa vĩnh viễn' }; + } + + private async anonymizeAndDelete(userId: string): Promise { + const now = new Date(); + const anonEmail = `deleted_${userId}@removed.local`; + const anonName = 'Người dùng đã xóa'; + const anonPhone = `deleted_${userId.slice(0, 10)}`; + + await this.prisma.$transaction(async (tx) => { + // 1. Anonymize user PII + await tx.user.update({ + where: { id: userId }, + data: { + email: anonEmail, + phone: anonPhone, + fullName: anonName, + passwordHash: null, + avatarUrl: null, + kycData: Prisma.DbNull, + kycStatus: 'NONE', + isActive: false, + deletedAt: now, + deletionScheduledAt: null, + }, + }); + + // 2. Delete auth tokens (cascaded by DB, but explicit for safety) + await tx.refreshToken.deleteMany({ where: { userId } }); + await tx.oAuthAccount.deleteMany({ where: { userId } }); + + // 3. Anonymize agent if exists + await tx.agent.updateMany({ + where: { userId }, + data: { isVerified: false, agency: null, bio: null, serviceAreas: [] }, + }); + + // 4. Expire active listings + await tx.listing.updateMany({ + where: { sellerId: userId, status: { in: ['ACTIVE', 'PENDING_REVIEW'] } }, + data: { status: 'EXPIRED' }, + }); + + // 5. Cancel active subscription + await tx.subscription.updateMany({ + where: { userId, status: 'ACTIVE' }, + data: { status: 'CANCELLED', cancelledAt: now }, + }); + + // 6. Delete personal data that has no audit requirement + await tx.review.deleteMany({ where: { userId } }); + await tx.inquiry.deleteMany({ where: { userId } }); + await tx.savedSearch.deleteMany({ where: { userId } }); + await tx.notificationLog.deleteMany({ where: { userId } }); + + // 7. Payments & transactions are kept for audit (already anonymized via user PII removal) + }); + } +} diff --git a/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.command.ts b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.command.ts new file mode 100644 index 0000000..731d999 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.command.ts @@ -0,0 +1 @@ +export class ProcessScheduledDeletionsCommand {} diff --git a/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts new file mode 100644 index 0000000..940f338 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/process-scheduled-deletions/process-scheduled-deletions.handler.ts @@ -0,0 +1,48 @@ +import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { ForceDeleteUserCommand } from '../force-delete-user/force-delete-user.command'; +import { ProcessScheduledDeletionsCommand } from './process-scheduled-deletions.command'; + +@CommandHandler(ProcessScheduledDeletionsCommand) +export class ProcessScheduledDeletionsHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly commandBus: CommandBus, + private readonly logger: LoggerService, + ) {} + + async execute(): Promise<{ processedCount: number }> { + const now = new Date(); + + const usersToDelete = await this.prisma.user.findMany({ + where: { + deletionScheduledAt: { lte: now }, + deletedAt: null, + }, + select: { id: true }, + }); + + this.logger.log( + `Processing ${usersToDelete.length} scheduled deletions`, + 'ProcessScheduledDeletionsHandler', + ); + + let processedCount = 0; + for (const user of usersToDelete) { + try { + await this.commandBus.execute( + new ForceDeleteUserCommand(user.id, 'SYSTEM', 'Scheduled GDPR deletion after 30-day grace period'), + ); + processedCount++; + } catch (error) { + this.logger.error( + `Failed to process deletion for user ${user.id}: ${error}`, + undefined, + 'ProcessScheduledDeletionsHandler', + ); + } + } + + return { processedCount }; + } +} diff --git a/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.command.ts b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.command.ts new file mode 100644 index 0000000..3be8197 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.command.ts @@ -0,0 +1,6 @@ +export class RequestUserDeletionCommand { + constructor( + public readonly userId: string, + public readonly reason?: string, + ) {} +} diff --git a/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts new file mode 100644 index 0000000..f9dc590 --- /dev/null +++ b/apps/api/src/modules/auth/application/commands/request-user-deletion/request-user-deletion.handler.ts @@ -0,0 +1,43 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { type LoggerService, type PrismaService } from '@modules/shared'; +import { NotFoundException, ValidationException } from '@modules/shared'; +import { RequestUserDeletionCommand } from './request-user-deletion.command'; + +const DELETION_GRACE_PERIOD_DAYS = 30; + +@CommandHandler(RequestUserDeletionCommand) +export class RequestUserDeletionHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: RequestUserDeletionCommand): Promise<{ scheduledAt: Date }> { + const user = await this.prisma.user.findUnique({ where: { id: command.userId } }); + if (!user) throw new NotFoundException('User', command.userId); + if (user.deletedAt) throw new ValidationException('Tài khoản đã bị xóa'); + if (user.deletionScheduledAt) throw new ValidationException('Yêu cầu xóa đã tồn tại'); + + const scheduledAt = new Date(); + scheduledAt.setDate(scheduledAt.getDate() + DELETION_GRACE_PERIOD_DAYS); + + await this.prisma.user.update({ + where: { id: command.userId }, + data: { deletionScheduledAt: scheduledAt, isActive: false }, + }); + + // Revoke all refresh tokens + await this.prisma.refreshToken.updateMany({ + where: { userId: command.userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + this.logger.log( + `User ${command.userId} deletion scheduled for ${scheduledAt.toISOString()}`, + 'RequestUserDeletionHandler', + ); + + return { scheduledAt }; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 55ffc32..b0a9211 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -2,9 +2,14 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { CancelUserDeletionHandler } from './application/commands/cancel-user-deletion/cancel-user-deletion.handler'; +import { ExportUserDataHandler } from './application/commands/export-user-data/export-user-data.handler'; +import { ForceDeleteUserHandler } from './application/commands/force-delete-user/force-delete-user.handler'; import { LoginUserHandler } from './application/commands/login-user/login-user.handler'; +import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler'; import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler'; import { RegisterUserHandler } from './application/commands/register-user/register-user.handler'; +import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler'; import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.handler'; import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler'; @@ -20,12 +25,18 @@ import { LocalStrategy } from './infrastructure/strategies/local.strategy'; import { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy'; import { AuthController } from './presentation/controllers/auth.controller'; import { OAuthController } from './presentation/controllers/oauth.controller'; +import { UserDataController } from './presentation/controllers/user-data.controller'; const CommandHandlers = [ RegisterUserHandler, LoginUserHandler, RefreshTokenHandler, VerifyKycHandler, + RequestUserDeletionHandler, + CancelUserDeletionHandler, + ForceDeleteUserHandler, + ProcessScheduledDeletionsHandler, + ExportUserDataHandler, ]; const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; @@ -47,7 +58,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; }, }), ], - controllers: [AuthController, OAuthController], + controllers: [AuthController, OAuthController, UserDataController], providers: [ // Repositories { provide: USER_REPOSITORY, useClass: PrismaUserRepository }, diff --git a/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts b/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts new file mode 100644 index 0000000..eb40e41 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/controllers/user-data.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command'; +import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command'; +import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler'; +import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command'; +import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command'; +import { type JwtPayload } from '../../infrastructure/services/token.service'; +import { CurrentUser } from '../decorators/current-user.decorator'; +import { Roles } from '../decorators/roles.decorator'; +import { type ForceDeleteUserDto } from '../dto/force-delete-user.dto'; +import { type RequestDeletionDto } from '../dto/request-deletion.dto'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; + +@ApiTags('users') +@Controller('users') +export class UserDataController { + constructor(private readonly commandBus: CommandBus) {} + + @Delete('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Request account deletion (30-day grace period)' }) + @ApiResponse({ status: 200, description: 'Deletion scheduled' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async requestDeletion( + @CurrentUser() user: JwtPayload, + @Body() dto: RequestDeletionDto, + ): Promise<{ scheduledAt: Date; message: string }> { + const result = await this.commandBus.execute( + new RequestUserDeletionCommand(user.sub, dto.reason), + ); + return { ...result, message: 'Tài khoản sẽ bị xóa sau 30 ngày' }; + } + + @Post('me/cancel-deletion') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Cancel pending account deletion' }) + @ApiResponse({ status: 201, description: 'Deletion cancelled' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async cancelDeletion( + @CurrentUser() user: JwtPayload, + ): Promise<{ message: string }> { + return this.commandBus.execute(new CancelUserDeletionCommand(user.sub)); + } + + @Get('me/export') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Export user data (GDPR Article 20)' }) + @ApiResponse({ status: 200, description: 'User data exported as JSON' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async exportData( + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute(new ExportUserDataCommand(user.sub)); + } + + @Delete(':id/force') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN') + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Force-delete user immediately (admin only)' }) + @ApiResponse({ status: 200, description: 'User force-deleted' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — admin only' }) + async forceDelete( + @Param('id') id: string, + @CurrentUser() admin: JwtPayload, + @Body() dto: ForceDeleteUserDto, + ): Promise<{ message: string }> { + return this.commandBus.execute( + new ForceDeleteUserCommand(id, admin.sub, dto.reason), + ); + } +} diff --git a/apps/api/src/modules/auth/presentation/dto/force-delete-user.dto.ts b/apps/api/src/modules/auth/presentation/dto/force-delete-user.dto.ts new file mode 100644 index 0000000..994748e --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/force-delete-user.dto.ts @@ -0,0 +1,9 @@ +import { IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ForceDeleteUserDto { + @ApiProperty({ description: 'Lý do xóa tài khoản' }) + @IsString() + @MinLength(1) + reason!: string; +} diff --git a/apps/api/src/modules/auth/presentation/dto/request-deletion.dto.ts b/apps/api/src/modules/auth/presentation/dto/request-deletion.dto.ts new file mode 100644 index 0000000..3f6ca9b --- /dev/null +++ b/apps/api/src/modules/auth/presentation/dto/request-deletion.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class RequestDeletionDto { + @ApiPropertyOptional({ description: 'Lý do xóa tài khoản', maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/prisma/migrations/20260410000000_add_user_soft_delete_fields/migration.sql b/prisma/migrations/20260410000000_add_user_soft_delete_fields/migration.sql new file mode 100644 index 0000000..adfdc75 --- /dev/null +++ b/prisma/migrations/20260410000000_add_user_soft_delete_fields/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "deletionScheduledAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "User_deletedAt_idx" ON "User"("deletedAt"); + +-- CreateIndex +CREATE INDEX "User_deletionScheduledAt_idx" ON "User"("deletionScheduledAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82b2204..dfe4736 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,9 +41,11 @@ model User { role UserRole @default(BUYER) kycStatus KYCStatus @default(NONE) kycData Json? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isActive Boolean @default(true) + deletedAt DateTime? + deletionScheduledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt agent Agent? listings Listing[] @@ -59,6 +61,8 @@ model User { @@index([role]) @@index([kycStatus]) @@index([isActive]) + @@index([deletedAt]) + @@index([deletionScheduledAt]) @@index([createdAt]) // --- Compound indexes (query optimization) ---