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
+154
View File
@@ -0,0 +1,154 @@
# Plan: Implementera automatiserad datarensning för AiTrace
## Mål
Implementera en automatiserad rensning av gamla `AiTrace`-poster för att säkerställa att känsliga data inte lagras längre än nödvändigt och för att följa GDPR-krav.
## Bakgrund
- `AiTrace`-tabellen lagrar känsliga data (t.ex. maskerade promptar och AI-svar) utan någon retention-policy.
- Risk för att data ackumuleras obehindrat, vilket kan leda till lagringsproblem och GDPR-brott.
- Enligt GDPR bör känsliga data rensas efter en viss tid om de inte längre behövs för felsökning eller analys.
## Krav
1. Rensa `AiTrace`-poster äldre än 30 dagar.
2. Schemalägg rensningen att köra dagligen vid midnatt.
3. Logga antalet rensade poster för övervakning.
4. Säkerställa att rensningen inte påverkar systemets prestanda.
## Implementeringsplan
### Steg 1: Installera beroenden
Installera `NestJS Schedule`-modulen för att möjliggöra schemalagda jobb.
**Kommando:**
```bash
npm install @nestjs/schedule
```
### Steg 2: Konfigurera ScheduleModule
Lägg till `ScheduleModule` i `AppModule` för att aktivera schemalagda jobb.
**Fil:** `backend/src/app.module.ts`
**Ändring:**
```typescript
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [ScheduleModule.forRoot()],
})
export class AppModule {}
```
### Steg 3: Skapa AiTraceCleanupService
Skapa en ny tjänst för att hantera rensningen av `AiTrace`-poster.
**Fil:** `backend/src/ai/ai-trace-cleanup.service.ts`
**Innehåll:**
```typescript
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);
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupOldTraces() {
this.logger.log('Starting cleanup of old AiTrace records...');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.prisma.aiTrace.deleteMany({
where: {
createdAt: {
lt: thirtyDaysAgo,
},
},
});
this.logger.log(`Cleaned up ${result.count} old AiTrace records.`);
}
}
```
### Steg 4: Registrera AiTraceCleanupService
Lägg till `AiTraceCleanupService` i `AiTraceModule` för att aktivera tjänsten.
**Fil:** `backend/src/ai/ai-trace.module.ts`
**Ändring:**
```typescript
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
@Module({
providers: [AiTraceService, AiTraceCleanupService],
})
export class AiTraceModule {}
```
### Steg 5: Testa rensningen manuellt
Skapa en testmetod för att manuellt köra rensningen och verifiera att den fungerar som förväntat.
**Fil:** `backend/src/ai/ai-trace.controller.ts`
**Ändring:**
```typescript
import { AiTraceCleanupService } from './ai-trace-cleanup.service';
@Controller('ai/traces')
export class AiTraceController {
constructor(
private readonly aiTraceService: AiTraceService,
private readonly aiTraceCleanupService: AiTraceCleanupService,
) {}
@Post('cleanup')
@Roles('admin')
async manualCleanup() {
await this.aiTraceCleanupService.cleanupOldTraces();
return { success: true, message: 'Manual cleanup completed.' };
}
}
```
### Steg 6: Verifiera implementationen
1. Skapa några testposter i `AiTrace`-tabellen med olika `createdAt`-datum.
2. Kör den manuella rensningen via `POST /api/ai/traces/cleanup`.
3. Verifiera att endast poster äldre än 30 dagar har rensats.
4. Kontrollera loggarna för att säkerställa att antalet rensade poster loggas korrekt.
### Steg 7: Dokumentera ändringarna
Uppdatera `TEKNISK_BESKRIVNING.md` med information om den automatiserade rensningen.
**Fil:** `TEKNISK_BESKRIVNING.md`
**Ändring:**
```markdown
## Automatiserad datarensning för AiTrace
För att säkerställa att känsliga data inte lagras längre än nödvändigt, har en automatiserad rensning implementerats:
- **Rensningsintervall**: Dagligen vid midnatt.
- **Retention-period**: 30 dagar.
- **Loggning**: Antalet rensade poster loggas för övervakning.
### Implementation
- **Tjänst**: `AiTraceCleanupService` i `backend/src/ai/ai-trace-cleanup.service.ts`.
- **Schemaläggning**: Använder `@nestjs/schedule` för att köra rensningen dagligen.
- **Manuell rensning**: Tillgänglig via `POST /api/ai/traces/cleanup` (kräver admin-behörighet).
### GDPR-efterlevnad
- Rensningen säkerställer att känsliga data raderas efter 30 dagar, vilket uppfyller GDPR-krav på dataminimering.
- Användare kan begära att deras data raderas tidigare via admin-panelen.
```
## Frågor och överväganden
1. **Retention-period**: Är 30 dagar en lämplig period, eller bör den justeras baserat påelsökningsbehov?
2. **Loggning**: Bör loggarna för rensningen sparas längre för revisionsändamål?
3. **Prestanda**: Bör rensningen köra under lågtrafikperioder för att minimera påverkan på systemet?
## Nästa steg
1. Implementera planen enligt ovan.
2. Testa rensningen i en testmiljö.
3. Verifiera att loggarna är korrekta och att rensningen fungerar som förväntat.
4. Dokumentera ändringarna i `TEKNISK_BESKRIVNING.md`.
5. Informera teamet om den nya funktionaliteten och dess påverkan på GDPR-efterlevnaden.
+215
View File
@@ -0,0 +1,215 @@
# Plan: Implementera användarinitierad radering av personuppgifter
## Mål
Skapa en tydlig och stegvis plan för hur användare kan ta bort sina personuppgifter från sin profil på plattformen, inklusive teknisk implementation och användarflöde.
## Bakgrund
- GDPR kräver att användare ska kunna begära radering av sina personuppgifter.
- Nuvarande plattform saknar ett tydligt användarflöde för att initiera radering av personuppgifter.
- Användare bör kunna ta bort sin profil och associerade data på ett säkert och transparent sätt.
## Krav
1. Lägg till en "Ta bort min profil"-knapp i användarens profilinställningar.
2. Implementera en bekräftelsedialog för att förhindra oavsiktlig radering.
3. Skapa en backend-endpoint för att hantera raderingsbegäran.
4. Se till att all användardata (profil, produkter, recept, etc.) raderas eller anonymiseras.
5. Logga raderingsbegäran för revisionsändamål.
6. Skicka ett bekräftelsemeddelande till användaren efter radering.
## Implementeringsplan
### Steg 1: Lägg till "Ta bort min profil"-knapp i Flutter UI
Lägg till en knapp i användarens profilinställningar som låter användaren initiera radering av sin profil.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
ElevatedButton(
onPressed: () {
_showDeleteProfileConfirmation();
},
child: Text('Ta bort min profil'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
)
```
### Steg 2: Implementera bekräftelsedialog
Skapa en dialog som kräver att användaren bekräftar raderingen.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
Future<void> _showDeleteProfileConfirmation() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Bekräfta radering'),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text('Är du säker på att du vill ta bort din profil?'),
Text('Alla dina data kommer att raderas permanent.'),
],
),
),
actions: <Widget>[
TextButton(
child: Text('Avbryt'),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: Text('Ta bort'),
onPressed: () {
_deleteProfile();
Navigator.of(context).pop();
},
),
],
);
},
);
}
```
### Steg 3: Skapa backend-endpoint för radering
Implementera en endpoint som hanterar raderingsbegäran och raderar all associerad data.
**Fil:** `backend/src/users/users.controller.ts`
**Ändring:**
```typescript
@Delete('me')
@UseGuards(JwtAuthGuard)
async deleteProfile(@CurrentUser() user: UserEntity) {
await this.usersService.deleteUserAndData(user.id);
return { success: true, message: 'Din profil och data har tagits bort.' };
}
```
### Steg 4: Implementera raderingslogik i UsersService
Skapa en metod som raderar användaren och all associerad data.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
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 } }),
]);
this.logger.log(`User ${userId} and associated data deleted.`);
}
```
### Steg 5: Logga raderingsbegäran
Lägg till loggning för att spåra raderingsbegäran för revisionsändamål.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
async logDeletionRequest(userId: number, userEmail: string) {
await this.prisma.auditLog.create({
data: {
action: 'USER_DELETION',
userId,
email: userEmail,
metadata: { message: 'User initiated deletion of their profile and data.' },
},
});
}
```
### Steg 6: Skicka bekräftelsemeddelande
Skicka ett e-postmeddelande till användaren för att bekräfta raderingen.
**Fil:** `backend/src/users/users.service.ts`
**Ändring:**
```typescript
async sendDeletionConfirmationEmail(email: string) {
await this.emailService.sendEmail({
to: email,
subject: 'Bekräftelse på radering av din profil',
text: 'Din profil och alla associerade data har tagits bort från vår plattform.',
});
}
```
### Steg 7: Uppdatera Flutter för att anropa backend-endpoint
Implementera metoden för att anropa backend-endpoint för radering.
**Fil:** `flutter/lib/features/profile/presentation/profile_screen.dart`
**Ändring:**
```dart
Future<void> _deleteProfile() async {
try {
final response = await ApiService.delete('/users/me');
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Din profil har tagits bort.'));
);
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ett fel uppstod vid radering av din profil.'));
);
}
}
```
### Steg 8: Testa implementeringen
1. Skapa en testanvändare i systemet.
2. Navigera till profilinställningar och klicka på "Ta bort min profil".
3. Bekräfta raderingen i dialogrutan.
4. Verifiera att användaren och all associerad data har tagits bort från databasen.
5. Kontrollera att ett bekräftelsemeddelande har skickats till användarens e-post.
6. Verifiera att raderingsbegäran har loggats i `AuditLog`.
### Steg 9: Dokumentera ändringarna
Uppdatera `TEKNISK_BESKRIVNING.md` med information om den nya funktionaliteten.
**Fil:** `TEKNISK_BESKRIVNING.md`
**Ändring:**
```markdown
## Användarinitierad radering av personuppgifter
För att uppfylla GDPR-krav har en funktion implementerats som låter användare ta bort sin profil och associerade data:
- **Användarflöde**: Användaren kan initiera radering via en knapp i profilinställningarna.
- **Bekräftelse**: En dialog kräver bekräftelse för att förhindra oavsiktlig radering.
- **Backend-endpoint**: `DELETE /users/me` hanterar raderingsbegäran.
- **Data som raderas**: Profil, produkter, recept, inventarieposter och matplaner.
- **Loggning**: Raderingsbegäran loggas i `AuditLog` för revisionsändamål.
- **Bekräftelse**: Ett e-postmeddelande skickas till användaren efter radering.
### Implementation
- **Frontend**: `flutter/lib/features/profile/presentation/profile_screen.dart`
- **Backend**: `backend/src/users/users.controller.ts` och `backend/src/users/users.service.ts`
- **Loggning**: `AuditLog`-poster skapas för varje raderingsbegäran.
- **E-postbekräftelse**: Skickas via `EmailService`.
### GDPR-efterlevnad
- Användare har full kontroll över sina personuppgifter.
- Raderingsprocessen är transparent och dokumenterad.
- All data raderas eller anonymiseras enligt GDPR-krav.
```
## Frågor och överväganden
1. **Dataretention**: Bör vissa data (t.ex. transaktionshistorik) sparas för revisionsändamål även efter radering?
2. **Anonymisering**: Bör vi anonymisera data istället för att radera dem helt?
3. **Ångerperiod**: Bör vi implementera en ångerperiod där användaren kan återställa sin profil inom en viss tid?
## Nästa steg
1. Implementera planen enligt ovan.
2. Testa funktionaliteten i en testmiljö.
3. Verifiera att all data raderas korrekt och att loggning fungerar.
4. Dokumentera ändringarna i `TEKNISK_BESKRIVNING.md`.
5. Informera teamet om den nya funktionaliteten och dess påverkan på GDPR-efterlevnaden.
+21 -1
View File
@@ -1,4 +1,24 @@
# Nyheter och förbättringar (2026-05-21)
## Användarinitierad radering av personuppgifter
För att uppfylla GDPR-krav har en funktion implementerats som låter användare ta bort sin profil och associerade data:
- **Användarflöde**: Användaren kan initiera radering via en knapp i profilinställningarna.
- **Bekräftelse**: En dialog kräver bekräftelse för att förhindra oavsiktlig radering.
- **Backend-endpoint**: `DELETE /users/me` hanterar raderingsbegäran.
- **Data som raderas**: Profil, produkter, recept, inventarieposter och matplaner.
- **Loggning**: Raderingsbegäran loggas i `AuditLog` för revisionsändamål.
- **Bekräftelse**: Ett e-postmeddelande skickas till användaren efter radering.
### Implementation
- **Frontend**: `flutter/lib/features/profile/presentation/profile_screen.dart`
- **Backend**: `backend/src/users/users.controller.ts` och `backend/src/users/users.service.ts`
- **Loggning**: `AuditLog`-poster skapas för varje raderingsbegäran.
- **E-postbekräftelse**: Skickas via `EmailService`.
### GDPR-efterlevnad
- Användare har full kontroll över sina personuppgifter.
- Raderingsprocessen är transparent och dokumenterad.
- All data raderas eller anonymiseras enligt GDPR-krav.
- **AI observability utökad för flyer:** `AiFlyerParserService.parseWithAI(...)` returnerar nu både `items` och `trace` med `prompt`, `rawOutput`, `chunkCount`, `retryCount`. Detta gör att admin kan felsöka varför en flyer fick varningar/fel utan att gå via debugfiler.
- **Persistenta flyer-traces i databasen:** `FlyerImportService.parseAndMatch(...)` sparar nu en `AiTrace`-rad (`source=flyer`) efter parse/match med fälten `sessionId`, `model`, `prompt`, `rawOutput`, `normalizedOutput`, `status`, `error`, `durationMs`.
+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.' },
},
});
}
}
}
@@ -1 +1 @@
{"version":2,"files":[{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","hash":"9d94bd45c82cddfabd1e14499720021e"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\l10n.yaml","hash":"3a6f56d787f3093703fe91c15fc15342"},{"path":"C:\\Users\\Nils-JohanGynther\\AppData\\Local\\Programs\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","hash":"33a276900ad78ff1cd267a3483f69235"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","hash":"f87ad39cbf2f19cd354eb22755efcf19"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_en.arb","hash":"1ad259fa8842a160661d3624c7ed2a58"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart","hash":"314556ec3609e717e3a60c3f364c41bf"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_sv.arb","hash":"bf89bd544fcfb2c6cdafbd916963b516"}]}
{"version":2,"files":[{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","hash":"9d94bd45c82cddfabd1e14499720021e"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\l10n.yaml","hash":"3a6f56d787f3093703fe91c15fc15342"},{"path":"C:\\Users\\Nils-JohanGynther\\AppData\\Local\\Programs\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","hash":"33a276900ad78ff1cd267a3483f69235"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","hash":"f87ad39cbf2f19cd354eb22755efcf19"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_en.arb","hash":"8670b7167dca7f07152aa2ffe5da2ec9"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart","hash":"314556ec3609e717e3a60c3f364c41bf"},{"path":"C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_sv.arb","hash":"191938461cea6a0535c94bf41e05a003"}]}
@@ -1 +0,0 @@
{"inputs":["C:\\Users\\Nils-JohanGynther\\AppData\\Local\\Programs\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\localizations.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\l10n.yaml","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_en.arb","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\app_sv.arb"],"outputs":["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart"]}
@@ -1 +1 @@
["C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_en.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations_sv.dart","C:\\Users\\Nils-JohanGynther\\dev\\recipe-app\\flutter\\lib\\l10n\\generated\\app_localizations.dart"]
[]
@@ -42,6 +42,14 @@ class ProfileRepository {
return UserProfile.fromJson(data);
}
Future<void> deleteMe() async {
final token = await _ref.read(authStateProvider.future);
await guardedApiCall(
_ref,
() => _apiClient.deleteJson(UserApiPaths.me, token: token),
);
}
Future<void> refreshCategories() async {
final token = await _ref.read(authStateProvider.future);
await guardedApiCall(
@@ -20,6 +20,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = true;
bool _isSaving = false;
bool _isDeleting = false;
String? _error;
UserProfile? _profile;
@@ -96,6 +97,61 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
context.go('/login');
}
Future<void> _showDeleteProfileConfirmation() async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text(context.l10n.profileDeleteConfirmTitle),
content: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text(context.l10n.profileDeleteConfirmMessage),
],
),
),
actions: <Widget>[
TextButton(
child: Text(context.l10n.noLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: Text(context.l10n.deleteAction),
onPressed: () {
Navigator.of(context).pop();
_deleteProfile();
},
),
],
);
},
);
}
Future<void> _deleteProfile() async {
setState(() => _isDeleting = true);
try {
await ref.read(profileRepositoryProvider).deleteMe();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.profileDeletedMessage)),
);
await ref.read(authStateProvider.notifier).logout();
if (!mounted) return;
context.go('/login');
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
} finally {
if (mounted) setState(() => _isDeleting = false);
}
}
Widget _buildProfileForm(BuildContext context, ThemeData theme) {
return Form(
key: _formKey,
@@ -286,6 +342,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
label: Text(context.l10n.logoutAction),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isDeleting ? null : _showDeleteProfileConfirmation,
icon: _isDeleting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_forever_outlined),
label: Text(context.l10n.profileDeleteAction),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
),
),
],
),
),
@@ -0,0 +1,68 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that looks up messages for specific locales by
// delegating to the appropriate library.
// Ignore issues from commonly used lints in this file.
// ignore_for_file:implementation_imports, file_names, unnecessary_new
// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering
// ignore_for_file:argument_type_not_assignable, invalid_assignment
// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases
// ignore_for_file:comment_references
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
import 'package:intl/src/intl_helpers.dart';
import 'messages_en.dart' as messages_en;
typedef Future<dynamic> LibraryLoader();
Map<String, LibraryLoader> _deferredLibraries = {
'en': () => new SynchronousFuture(null),
};
MessageLookupByLibrary? _findExact(String localeName) {
switch (localeName) {
case 'en':
return messages_en.messages;
default:
return null;
}
}
/// User programs should call this before using [localeName] for messages.
Future<bool> initializeMessages(String localeName) {
var availableLocale = Intl.verifiedLocale(
localeName,
(locale) => _deferredLibraries[locale] != null,
onFailure: (_) => null,
);
if (availableLocale == null) {
return new SynchronousFuture(false);
}
var lib = _deferredLibraries[availableLocale];
lib == null ? new SynchronousFuture(false) : lib();
initializeInternalMessageLookup(() => new CompositeMessageLookup());
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
return new SynchronousFuture(true);
}
bool _messagesExistFor(String locale) {
try {
return _findExact(locale) != null;
} catch (e) {
return false;
}
}
MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) {
var actualLocale = Intl.verifiedLocale(
locale,
_messagesExistFor,
onFailure: (_) => null,
);
if (actualLocale == null) return null;
return _findExact(actualLocale);
}
@@ -0,0 +1,25 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a en locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes
// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = new MessageLookup();
typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'en';
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{};
}
+80
View File
@@ -0,0 +1,80 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'intl/messages_all.dart';
// **************************************************************************
// Generator: Flutter Intl IDE plugin
// Made by Localizely
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars
// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each
// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes
class S {
S();
static S? _current;
static S get current {
assert(
_current != null,
'No instance of S was loaded. Try to initialize the S delegate before accessing S.current.',
);
return _current!;
}
static const AppLocalizationDelegate delegate = AppLocalizationDelegate();
static Future<S> load(Locale locale) {
final name = (locale.countryCode?.isEmpty ?? false)
? locale.languageCode
: locale.toString();
final localeName = Intl.canonicalizedLocale(name);
return initializeMessages(localeName).then((_) {
Intl.defaultLocale = localeName;
final instance = S();
S._current = instance;
return instance;
});
}
static S of(BuildContext context) {
final instance = S.maybeOf(context);
assert(
instance != null,
'No instance of S present in the widget tree. Did you add S.delegate in localizationsDelegates?',
);
return instance!;
}
static S? maybeOf(BuildContext context) {
return Localizations.of<S>(context, S);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
const AppLocalizationDelegate();
List<Locale> get supportedLocales {
return const <Locale>[Locale.fromSubtags(languageCode: 'en')];
}
@override
bool isSupported(Locale locale) => _isSupported(locale);
@override
Future<S> load(Locale locale) => S.load(locale);
@override
bool shouldReload(AppLocalizationDelegate old) => false;
bool _isSupported(Locale locale) {
for (var supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale.languageCode) {
return true;
}
}
return false;
}
}
+5 -4
View File
@@ -517,9 +517,10 @@
"adminInlineCategory": "Category (inline)",
"adminNoCategory": "No category",
"adminRestoreAction": "Restore",
"required": "Required",
"logoutAction": "Log out",
"adminAiDescription": "Overview of AI features exposed by the backend.",
"adminPagePrefix": "Page: ",
"profileDeleteConfirmTitle": "Confirm deletion",
"profileDeleteConfirmMessage": "Are you sure you want to delete your profile? All your data will be permanently deleted.",
"profileDeleteAction": "Delete my profile",
"profileDeletedMessage": "Your profile has been deleted."
}
"profileDatabaseDescription": "The database tab covers your main areas for inventory and products."
}
+4 -1
View File
@@ -521,5 +521,8 @@
"logoutAction": "Logga ut",
"adminAiDescription": "Översikt över AI-funktioner som backend exponerar.",
"adminPagePrefix": "Sida: ",
"profileDatabaseDescription": "Databasfliken samlar dina huvudområden för lager och produkter."
"profileDeleteConfirmTitle": "Bekräfta radering",
"profileDeleteConfirmMessage": "Är du säker på att du vill ta bort din profil? Alla dina data kommer att raderas permanent.",
"profileDeleteAction": "Ta bort min profil",
"profileDeletedMessage": "Din profil har tagits bort."
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -21,6 +21,7 @@ dependencies:
shared_preferences: ^2.5.5
file_picker: ^11.0.2
web: ^1.1.1
intl_utils: ^2.8.14
dev_dependencies:
flutter_test: