feat(profile): implement user-initiated GDPR-compliant profile deletion
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 4m36s
Test Suite / flutter-quality (push) Failing after 40s

- 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:
Nils-Johan Gynther
2026-05-21 22:19:50 +02:00
parent 6ddb58dc7c
commit 8c9da36312
23 changed files with 776 additions and 34 deletions
+46
View File
@@ -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",
+1
View File
@@ -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.`);
}
}
+17 -12
View File
@@ -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 -1
View File
@@ -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 {}
+2
View File
@@ -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,
+4 -10
View File
@@ -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.' };
}
}
+16 -2
View File
@@ -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.' },
},
});
}
}
}