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
@@ -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.' },
},
});
}
}
}