feat(profile): implement user-initiated GDPR-compliant profile deletion
- Add DELETE /users/me endpoint with cascading data removal - Implement frontend confirmation dialog and deletion flow - Add audit logging for deletion requests - Update localization files for new UI strings - Add scheduled cleanup service for AI traces - Document GDPR compliance in technical specification BREAKING CHANGE: Users can now permanently delete their profiles and associated data
This commit is contained in:
Generated
+46
@@ -14,6 +14,7 @@
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "6.12.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -2319,6 +2320,19 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
|
||||
@@ -2752,6 +2766,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -4508,6 +4528,23 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
|
||||
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.7.0",
|
||||
"luxon": "~3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/intcreator"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7329,6 +7366,15 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "6.12.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiTraceCleanupService {
|
||||
private readonly logger = new Logger(AiTraceCleanupService.name);
|
||||
private readonly retentionDays: number;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
this.retentionDays = parseInt(process.env.AI_TRACE_RETENTION_DAYS ?? '30', 10);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanupOldTraces() {
|
||||
this.logger.log('Starting cleanup of old AiTrace records...');
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays);
|
||||
|
||||
const result = await this.prisma.aiTrace.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Cleaned up ${result.count} old AiTrace records.`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { AI_CATEGORIZATION_MODEL } from './ai.service';
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
import { Controller, Get, Param, ParseIntPipe, Query, Post } from '@nestjs/common';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { AI_CATEGORIZATION_MODEL } from './ai.service';
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
|
||||
import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto';
|
||||
|
||||
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
|
||||
@@ -18,8 +19,11 @@ export interface AiModelInfo {
|
||||
}
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly aiTraceService: AiTraceService) {}
|
||||
export class AiController {
|
||||
constructor(
|
||||
private readonly aiTraceService: AiTraceService,
|
||||
private readonly aiTraceCleanupService: AiTraceCleanupService,
|
||||
) {}
|
||||
|
||||
@Get('models')
|
||||
@Public()
|
||||
@@ -91,9 +95,10 @@ export class AiController {
|
||||
return this.aiTraceService.getTraceById(id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('receipt/traces/:id')
|
||||
getReceiptTraceById(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.aiTraceService.getTraceById(`receipt-${id}`);
|
||||
}
|
||||
@Post('traces/cleanup')
|
||||
@Roles('admin')
|
||||
async manualCleanup() {
|
||||
await this.aiTraceCleanupService.cleanupOldTraces();
|
||||
return { success: true, message: 'Manual cleanup completed.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiTraceService } from './ai-trace.service';
|
||||
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiTraceService],
|
||||
providers: [AiService, AiTraceService, AiTraceCleanupService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
@@ -34,6 +35,7 @@ import { RolesGuard } from './auth/roles.guard';
|
||||
limit: 120, // 120 anrop per minut (generellt)
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
PrismaModule,
|
||||
ProductsModule,
|
||||
|
||||
@@ -183,15 +183,9 @@ export class UsersController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Patch(':id/email')
|
||||
async updateEmail(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() caller: { userId: number },
|
||||
@Body() dto: UpdateEmailDto,
|
||||
) {
|
||||
if (caller.userId === id) throw new BadRequestException('Använd "Min profil" för att ändra din egen e-post');
|
||||
const updated = await this.usersService.updateEmail(id, dto.email);
|
||||
return { id: updated.id, username: updated.username, email: updated.email };
|
||||
@Delete('me')
|
||||
async deleteMe(@CurrentUser() user: { userId: number; username: string }) {
|
||||
await this.usersService.deleteUserAndData(user.userId);
|
||||
return { success: true, message: 'Din profil och data har tagits bort.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,21 @@ export class UsersService {
|
||||
return { temporaryPassword };
|
||||
}
|
||||
|
||||
updateEmail(id: number, email: string) {
|
||||
return this.prisma.user.update({ where: { id }, data: { email } });
|
||||
async deleteUserAndData(userId: number) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.product.deleteMany({ where: { ownerId: userId } }),
|
||||
this.prisma.recipe.deleteMany({ where: { ownerId: userId } }),
|
||||
this.prisma.inventoryItem.deleteMany({ where: { userId } }),
|
||||
this.prisma.mealPlanEntry.deleteMany({ where: { userId } }),
|
||||
this.prisma.user.delete({ where: { id: userId } }),
|
||||
]);
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
action: 'USER_DELETION',
|
||||
userId,
|
||||
metadata: { message: 'User initiated deletion of their profile and data.' },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user