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,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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user