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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>) => 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' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class CancelUserDeletionCommand {
|
||||||
|
constructor(public readonly userId: string) {}
|
||||||
|
}
|
||||||
@@ -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<CancelUserDeletionCommand> {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class ExportUserDataCommand {
|
||||||
|
constructor(public readonly userId: string) {}
|
||||||
|
}
|
||||||
@@ -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<ExportUserDataCommand> {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class ForceDeleteUserCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly adminId: string,
|
||||||
|
public readonly reason: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<ForceDeleteUserCommand> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export class ProcessScheduledDeletionsCommand {}
|
||||||
@@ -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<ProcessScheduledDeletionsCommand> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class RequestUserDeletionCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly reason?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<RequestUserDeletionCommand> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
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 { 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 { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.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 { 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 { 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';
|
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 { ZaloOAuthStrategy } from './infrastructure/strategies/zalo-oauth.strategy';
|
||||||
import { AuthController } from './presentation/controllers/auth.controller';
|
import { AuthController } from './presentation/controllers/auth.controller';
|
||||||
import { OAuthController } from './presentation/controllers/oauth.controller';
|
import { OAuthController } from './presentation/controllers/oauth.controller';
|
||||||
|
import { UserDataController } from './presentation/controllers/user-data.controller';
|
||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
RegisterUserHandler,
|
RegisterUserHandler,
|
||||||
LoginUserHandler,
|
LoginUserHandler,
|
||||||
RefreshTokenHandler,
|
RefreshTokenHandler,
|
||||||
VerifyKycHandler,
|
VerifyKycHandler,
|
||||||
|
RequestUserDeletionHandler,
|
||||||
|
CancelUserDeletionHandler,
|
||||||
|
ForceDeleteUserHandler,
|
||||||
|
ProcessScheduledDeletionsHandler,
|
||||||
|
ExportUserDataHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
||||||
@@ -47,7 +58,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler];
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController, OAuthController],
|
controllers: [AuthController, OAuthController, UserDataController],
|
||||||
providers: [
|
providers: [
|
||||||
// Repositories
|
// Repositories
|
||||||
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
|
{ provide: USER_REPOSITORY, useClass: PrismaUserRepository },
|
||||||
|
|||||||
@@ -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<UserDataExport> {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -41,9 +41,11 @@ model User {
|
|||||||
role UserRole @default(BUYER)
|
role UserRole @default(BUYER)
|
||||||
kycStatus KYCStatus @default(NONE)
|
kycStatus KYCStatus @default(NONE)
|
||||||
kycData Json?
|
kycData Json?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
deletionScheduledAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
agent Agent?
|
agent Agent?
|
||||||
listings Listing[]
|
listings Listing[]
|
||||||
@@ -59,6 +61,8 @@ model User {
|
|||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([kycStatus])
|
@@index([kycStatus])
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@index([deletionScheduledAt])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
|
||||||
// --- Compound indexes (query optimization) ---
|
// --- Compound indexes (query optimization) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user