- Add per-collection row cap (default 10k, env EXPORT_ROW_CAP) via Prisma take on all findMany calls - Add total size cap (default 100MB, env EXPORT_SIZE_CAP_MB); throws PayloadTooLargeException (413) when exceeded - Convert response to Node.js Readable stream piped via NestJS StreamableFile to avoid large in-memory buffers - Export ExportUserDataResult interface (stream + truncated flag) from handler - Update controller to set Content-Type/Content-Disposition headers and return StreamableFile - Document EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars in Swagger - Extend tests: row-cap assertion (take arg), size-cap 413 path, stream assertions Fixes GOO-223 (M-1 from GOO-200 audit). Co-Authored-By: Paperclip <noreply@paperclip.ing>
111 lines
4.3 KiB
TypeScript
111 lines
4.3 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Res,
|
|
StreamableFile,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { CommandBus } from '@nestjs/cqrs';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
|
|
import { Response } from 'express';
|
|
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 ExportUserDataResult } 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 { ForceDeleteUserDto } from '../dto/force-delete-user.dto';
|
|
import { 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')
|
|
@ApiProduces('application/json')
|
|
@ApiOperation({
|
|
summary: 'Export user data (GDPR Article 20)',
|
|
description:
|
|
'Streams the full user data export as JSON. ' +
|
|
'Row cap (per collection) defaults to 10 000 rows; size cap defaults to 100 MB. ' +
|
|
'Both are configurable via EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'User data exported as streaming JSON' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({
|
|
status: 413,
|
|
description: 'Export exceeds size cap — contact support for chunked export',
|
|
})
|
|
async exportData(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<StreamableFile> {
|
|
const result: ExportUserDataResult = await this.commandBus.execute(
|
|
new ExportUserDataCommand(user.sub),
|
|
);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="user-data-${user.sub}.json"`,
|
|
);
|
|
return new StreamableFile(result.stream);
|
|
}
|
|
|
|
@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),
|
|
);
|
|
}
|
|
}
|