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:
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
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 { 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';
|
||||
@@ -19,7 +20,10 @@ export interface AiModelInfo {
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly aiTraceService: AiTraceService) {}
|
||||
constructor(
|
||||
private readonly aiTraceService: AiTraceService,
|
||||
private readonly aiTraceCleanupService: AiTraceCleanupService,
|
||||
) {}
|
||||
|
||||
@Get('models')
|
||||
@Public()
|
||||
@@ -91,9 +95,10 @@ export class AiController {
|
||||
return this.aiTraceService.getTraceById(id);
|
||||
}
|
||||
|
||||
@Post('traces/cleanup')
|
||||
@Roles('admin')
|
||||
@Get('receipt/traces/:id')
|
||||
getReceiptTraceById(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.aiTraceService.getTraceById(`receipt-${id}`);
|
||||
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.' },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>{};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user