diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 690ca3c9..2508e04e 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -8,8 +8,11 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen - Fokus: en gemensam prioriteringslista for produkt, utveckling och drift. - Delplaner for underomraden ska referera hit, inte duplicera hela roadmapen. + ## Nyligen klart +- **2026-05-10:** Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + - Kvittoimport: förbättrad antal/förpackningsinferens och robustare regelmotor. - Kategorisering: utökade brödregler + contradiction guards och nya regler för pasta, grädde, ägg, juice, godis, och potatis. - Kategoriträd: nya noder `Korvbröd` under `Fastfoodbröd` och `Grädde` under `Matlagning` i seed-data. @@ -125,3 +128,5 @@ Förutsättning: migration som konverterar befintlig JSON-data till rader i tabe - `migrering-MSI.md` - migreringshistorik for importer. - `flutter/next_steps_flutter.md` - Flutter-specifik plan. - `_archive/microservice-ai/AI-FUNKTIONER.md` - AI-strategi och historik. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/README.md b/README.md index 2d1e56cd..0601c185 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ + +# Nyheter och förbättringar (2026-05-10) + +- **Admin-inventarie:** Full CRUD, merge, filter, sortering, preview och säkerhet för admin i inventarietabellen. Endast admin kan se och hantera alla användares inventarieposter via nya endpoints och adminpanel i Flutter. +- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR. +- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat. +- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad. +- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden. + # Session 2026-05-06: User-scoped AI-fallback, admin-toggles och premium-funktioner Under denna session har Recipe App fått: @@ -375,3 +384,5 @@ bash backup_recipe_app.sh ``` Säkerhetskopierar källkod och Docker-images till konfigurerad backupmapp. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/RECIPE_IMPORT_REFACTOR_PLAN.md b/RECIPE_IMPORT_REFACTOR_PLAN.md index 45c6cefb..e0d00534 100644 --- a/RECIPE_IMPORT_REFACTOR_PLAN.md +++ b/RECIPE_IMPORT_REFACTOR_PLAN.md @@ -814,3 +814,7 @@ Om implementationen ska påbörjas direkt är detta bästa första block: 5. Uppdatera `create_recipe_screen.dart` så att `_save()` skickar råingredienser i stället för att kasta bort omatchade rader. Det blocket ger störst effekt med lägst risk, eftersom det löser kärnproblemet: att importen inte längre förstör receptets innehåll. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/SESSION_2026-05-09_RECEIPT_IMPORT.md b/SESSION_2026-05-09_RECEIPT_IMPORT.md index ab8afeab..e8a1f3ba 100644 --- a/SESSION_2026-05-09_RECEIPT_IMPORT.md +++ b/SESSION_2026-05-09_RECEIPT_IMPORT.md @@ -1,3 +1,11 @@ +# Nyheter och förbättringar (2026-05-10) + +- **Admin-inventarie:** Full CRUD, merge, filter, sortering, preview och säkerhet för admin i inventarietabellen. Endast admin kan se och hantera alla användares inventarieposter via nya endpoints och adminpanel i Flutter. +- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR. +- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat. +- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad. +- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden. + # Sessionlogg: Receipt Import Cleanup & Optimization Datum: 2026-05-09 @@ -226,3 +234,7 @@ Filer: - [ ] Testa admin rename/merge - [ ] Testa private endpoints (API-test eller manual) - [ ] Implementera user-UI för private rename/merge (valfritt) + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/Säkerhetshärdningsplan för Recipe-app.md b/Säkerhetshärdningsplan för Recipe-app.md index a3df7eee..756ba4a7 100644 --- a/Säkerhetshärdningsplan för Recipe-app.md +++ b/Säkerhetshärdningsplan för Recipe-app.md @@ -1,4 +1,12 @@ # 🔒 Säkerhetshärdningsplan för Recipe-App (Flutter + NestJS + MariaDB) +# Nyheter och förbättringar (2026-05-10) + +- **Admin-inventarie:** Endast admin har tillgång till CRUD, merge, filter, sortering och preview för alla användares inventarieposter. Endpoints och UI är skyddade med @Roles('admin') och testade. +- **User-scope och IDOR-skydd:** Inventory och produkter är strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR. +- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat. +- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad. +- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden. + **Reviderad:** 2026-05-07 — baserad på faktisk kodgranskning av repo. @@ -402,3 +410,5 @@ gitleaks protect --staged # kör före varje commit - **IDOR-skydd för inventory:** Det är nu omöjligt för användare att läsa eller ändra andras inventarieposter. Tester verifierar att åtkomst nekas vid försök till IDOR. - **.gitignore och deploy-hygien:** backend/dist och backend/tsconfig.tsbuildinfo ignoreras och är ej längre spårade i git. .env och .env.* ignoreras, men .env.example finns och är uppdaterad. - **CI/CD-härdning:** npm audit och prisma validate körs i pipeline. Alla tester och byggen måste passera. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index bd834287..6cbafb69 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -66,6 +66,13 @@ Se även: - [AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för detaljerad AI-översikt och modellval. - [RECIPE_IMPORT_REFACTOR_PLAN.md](RECIPE_IMPORT_REFACTOR_PLAN.md) för fullständig refaktorplan. - [NEXT_STEPS.md](NEXT_STEPS.md) för roadmap och prioriteringar. +# Nyheter och förbättringar (2026-05-10) + +- **Admin-inventarie:** Backend och Flutter har nu fullständigt stöd för admin att hantera, filtrera, sortera, skapa, redigera, slå ihop och ta bort poster i den globala inventarietabellen. Endast admin har tillgång till dessa endpoints och UI-flöden. +- **User-scope och IDOR-skydd:** Inventory och produkter är nu strikt user-scopade. Alla operationer kräver och filtrerar på userId. Tester verifierar att åtkomst nekas vid försök till IDOR. +- **Säkerhetshärdning:** DTO-validering, guard-ordning, logging, throttling, merge abuse-skydd, och rollbaserad access är implementerat och testat. +- **Optimeringar:** DRY i service-lager, striktare query parsing, preview-cache, API-cleanup, och kodduplication eliminerad. +- **Testtäckning:** Utökade enhets-, integrations- och säkerhetstester för alla kritiska flöden. # Teknisk beskrivning av Recipe App @@ -1963,3 +1970,6 @@ Microservice-importer ska använda SQLite som databas, av samma skäl som co --- + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + diff --git a/_archive/frontend/Dockerfile b/_archive/frontend/Dockerfile index 7a53ba4f..fe9a5001 100644 --- a/_archive/frontend/Dockerfile +++ b/_archive/frontend/Dockerfile @@ -3,6 +3,7 @@ WORKDIR /app COPY package.json ./ RUN npm install +# 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules diff --git a/_archive/frontend/README.md b/_archive/frontend/README.md new file mode 100644 index 00000000..b29b992c --- /dev/null +++ b/_archive/frontend/README.md @@ -0,0 +1 @@ +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. \ No newline at end of file diff --git a/_archive/frontend/auth.ts b/_archive/frontend/auth.ts index 43de39ff..4b8018b4 100644 --- a/_archive/frontend/auth.ts +++ b/_archive/frontend/auth.ts @@ -75,3 +75,5 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, session: { strategy: 'jwt' }, }); + +// 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/_archive/frontend/next-env.d.ts b/_archive/frontend/next-env.d.ts index 4f11a03d..c65f26c1 100644 --- a/_archive/frontend/next-env.d.ts +++ b/_archive/frontend/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +// 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/_archive/microservice-ai/AI-FUNKTIONER.md b/_archive/microservice-ai/AI-FUNKTIONER.md index 04519d8c..dbb5d6d1 100644 --- a/_archive/microservice-ai/AI-FUNKTIONER.md +++ b/_archive/microservice-ai/AI-FUNKTIONER.md @@ -1,5 +1,8 @@ # Session 2026-05-06: User-scoped AI, admin-toggles och premium +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. Denna session: - Införde user-scoped AI-förslag för ingrediens- och kategorimatchning (premium-funktion). - Admin kan nu slå på/av AI per användare via backend och UI. diff --git a/_archive/microservice-todo/microservice-todo.md b/_archive/microservice-todo/microservice-todo.md index 55a313be..148ca501 100644 --- a/_archive/microservice-todo/microservice-todo.md +++ b/_archive/microservice-todo/microservice-todo.md @@ -1,5 +1,7 @@ Read memory [](file:///c%3A/Users/Nils-JohanGynther/AppData/Roaming/Code/User/workspaceStorage/e6ea1b0bd55239bec87a0a6ab7819f74/GitHub.copilot-chat/memory-tool/memories/NTkxM2ZhMmYtYjViYi00YTE0LTg2NGEtNmYyYzZjMTcxNWEw/microservice-todo.md) +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. ## Dokumentstatus (2026-05-03) ### Målgrupp diff --git a/backend/src/app.security.spec.ts b/backend/src/app.security.spec.ts new file mode 100644 index 00000000..582ed41c --- /dev/null +++ b/backend/src/app.security.spec.ts @@ -0,0 +1,37 @@ +import { APP_GUARD } from '@nestjs/core'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { JwtAuthGuard } from './auth/jwt-auth.guard'; +import { RolesGuard } from './auth/roles.guard'; + +describe('App security configuration', () => { + function getAppModuleClass() { + process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'; + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('./app.module').AppModule as any; + } + + it('har globala guards i förväntad ordning: Throttler -> Jwt -> Roles', () => { + const AppModule = getAppModuleClass(); + const providers = + (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) as any[]) ?? []; + + const appGuards = providers + .filter((p) => p?.provide === APP_GUARD) + .map((p) => p.useClass); + + expect(appGuards).toEqual([ThrottlerGuard, JwtAuthGuard, RolesGuard]); + }); + + it('har ThrottlerModule registrerad i AppModule imports', () => { + const AppModule = getAppModuleClass(); + const imports = + (Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) as any[]) ?? []; + + const hasThrottler = imports.some( + (entry) => entry?.module === ThrottlerModule, + ); + + expect(hasThrottler).toBe(true); + }); +}); diff --git a/backend/src/inventory/dto/create-admin-inventory.dto.ts b/backend/src/inventory/dto/create-admin-inventory.dto.ts new file mode 100644 index 00000000..5ad08fa6 --- /dev/null +++ b/backend/src/inventory/dto/create-admin-inventory.dto.ts @@ -0,0 +1,9 @@ +import { IsInt, IsOptional, Min } from 'class-validator'; +import { CreateInventoryDto } from './create-inventory.dto'; + +export class CreateAdminInventoryDto extends CreateInventoryDto { + @IsOptional() + @IsInt() + @Min(1) + userId?: number; +} \ No newline at end of file diff --git a/backend/src/inventory/dto/merge-inventory.dto.ts b/backend/src/inventory/dto/merge-inventory.dto.ts new file mode 100644 index 00000000..7cb86b3c --- /dev/null +++ b/backend/src/inventory/dto/merge-inventory.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, Min } from 'class-validator'; + +export class MergeInventoryDto { + @IsInt() + @Min(1) + sourceInventoryId!: number; + + @IsInt() + @Min(1) + targetInventoryId!: number; +} \ No newline at end of file diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index 8eda2a2e..fc19dcf1 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -14,10 +14,70 @@ import { UpdateInventoryDto } from './dto/update-inventory.dto'; import { InventoryService } from './inventory.service'; import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { MergeInventoryDto } from './dto/merge-inventory.dto'; +import { CreateAdminInventoryDto } from './dto/create-admin-inventory.dto'; @Controller('inventory') export class InventoryController { constructor(private readonly inventoryService: InventoryService) {} + + @Roles('admin') + @Get('admin') + findAllAdmin( + @Query('userId', new ParseIntPipe({ optional: true })) userId?: number, + @Query('sort') sort?: string, + ) { + return this.inventoryService.findAllAdmin({ + userId, + sort, + }); + } + + @Roles('admin') + @Post('admin') + createAdmin( + @CurrentUser() user: { userId: number }, + @Body() body: CreateAdminInventoryDto, + ) { + return this.inventoryService.createAdmin(user.userId, body, body.userId); + } + + @Roles('admin') + @Patch('admin/:id') + updateAdmin( + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateInventoryDto, + ) { + return this.inventoryService.updateAdmin(id, body); + } + + @Roles('admin') + @Delete('admin/:id') + removeAdmin(@Param('id', ParseIntPipe) id: number) { + return this.inventoryService.removeAdmin(id); + } + + @Roles('admin') + @Post('admin/merge') + mergeAdmin(@Body() body: MergeInventoryDto) { + return this.inventoryService.mergeAdmin( + body.sourceInventoryId, + body.targetInventoryId, + ); + } + + @Roles('admin') + @Get('admin/merge-preview') + previewMergeAdmin( + @Query('sourceInventoryId', ParseIntPipe) sourceInventoryId: number, + @Query('targetInventoryId', ParseIntPipe) targetInventoryId: number, + ) { + return this.inventoryService.previewMergeAdmin( + sourceInventoryId, + targetInventoryId, + ); + } @Post(':id/consume') consume( diff --git a/backend/src/inventory/inventory.security.spec.ts b/backend/src/inventory/inventory.security.spec.ts new file mode 100644 index 00000000..2c888cf9 --- /dev/null +++ b/backend/src/inventory/inventory.security.spec.ts @@ -0,0 +1,95 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ROLES_KEY } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { InventoryController } from './inventory.controller'; + +type MockHttpContextOptions = { + handler: Function; + clazz: Function; + user?: any; +}; + +function mockHttpContext(options: MockHttpContextOptions): ExecutionContext { + return { + getClass: () => options.clazz, + getHandler: () => options.handler, + switchToHttp: () => ({ + getRequest: () => ({ user: options.user }), + getResponse: () => ({}), + getNext: () => undefined, + }), + } as unknown as ExecutionContext; +} + +describe('Inventory admin security', () => { + const adminHandlers: Array<[string, Function]> = [ + ['findAllAdmin', InventoryController.prototype.findAllAdmin], + ['createAdmin', InventoryController.prototype.createAdmin], + ['updateAdmin', InventoryController.prototype.updateAdmin], + ['removeAdmin', InventoryController.prototype.removeAdmin], + ['mergeAdmin', InventoryController.prototype.mergeAdmin], + ['previewMergeAdmin', InventoryController.prototype.previewMergeAdmin], + ]; + + it.each(adminHandlers)('admin-endpoint %s har @Roles("admin") metadata', (_name, handler) => { + const roles = Reflect.getMetadata(ROLES_KEY, handler) as string[] | undefined; + expect(roles).toEqual(['admin']); + }); + + it.each(adminHandlers)('RolesGuard nekar icke-admin (403) på %s', (_name, handler) => { + const reflector = new Reflector(); + const guard = new RolesGuard(reflector); + const context = mockHttpContext({ + handler, + clazz: InventoryController, + user: { role: 'user' }, + }); + + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it.each(adminHandlers)('RolesGuard tillåter admin (200/allow) på %s', (_name, handler) => { + const reflector = new Reflector(); + const guard = new RolesGuard(reflector); + const context = mockHttpContext({ + handler, + clazz: InventoryController, + user: { role: 'admin' }, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('JwtAuthGuard mappar saknad användare till 401', () => { + const guard = new JwtAuthGuard(new Reflector()); + + expect(() => guard.handleRequest(null, null, null)).toThrow(UnauthorizedException); + }); + + it('JwtAuthGuard släpper igenom autentiserad användare (200/allow)', () => { + const guard = new JwtAuthGuard(new Reflector()); + const user = { userId: 42, role: 'admin' }; + + expect(guard.handleRequest(null, user, null)).toBe(user); + }); + + it('JwtAuthGuard-logg innehåller userId men inte token', () => { + const guard = new JwtAuthGuard(new Reflector()); + const logSpy = jest.fn(); + (guard as any).logger = { log: logSpy }; + const user = { + userId: 77, + role: 'admin', + accessToken: 'secret-token-should-not-appear', + }; + + guard.handleRequest(null, user, null); + + expect(logSpy).toHaveBeenCalledTimes(1); + const loggedMessage = String(logSpy.mock.calls[0][0] ?? ''); + expect(loggedMessage).toContain('77'); + expect(loggedMessage).not.toContain('secret-token-should-not-appear'); + }); +}); diff --git a/backend/src/inventory/inventory.service.spec.ts b/backend/src/inventory/inventory.service.spec.ts index 946b3fb0..606c81e3 100644 --- a/backend/src/inventory/inventory.service.spec.ts +++ b/backend/src/inventory/inventory.service.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { InventoryService } from './inventory.service'; @@ -17,6 +17,10 @@ describe('InventoryService security', () => { }, product: { findFirst: jest.fn(), + findUnique: jest.fn(), + }, + user: { + findUnique: jest.fn(), }, $transaction: jest.fn(), }; @@ -85,4 +89,157 @@ describe('InventoryService security', () => { await expect(service.findConsumptionHistory(12, 42)).rejects.toThrow(ForbiddenException); }); + + it('nekar consume om inventoryItem tillhör annan användare', async () => { + prismaMock.inventoryItem.findUnique.mockResolvedValue({ + id: 13, + userId: 7, + quantity: new Prisma.Decimal(2), + }); + + await expect( + service.consume(13, 42, { amountUsed: 1 } as any), + ).rejects.toThrow(ForbiddenException); + }); + + it('createAdmin kan skapa post för explicit target userId', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 99 }); + prismaMock.product.findUnique.mockResolvedValue({ id: 5 }); + prismaMock.inventoryItem.create.mockResolvedValue({ id: 123, userId: 99, productId: 5 }); + + await service.createAdmin( + 1, + { + productId: 5, + quantity: 3, + unit: 'st', + } as any, + 99, + ); + + expect(prismaMock.inventoryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 99, productId: 5 }), + }), + ); + }); + + it('createAdmin kastar NotFound om target user saknas', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + await expect( + service.createAdmin( + 1, + { + productId: 5, + quantity: 3, + unit: 'st', + } as any, + 404, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('mergeAdmin blockerar merge mellan olika användare', async () => { + prismaMock.inventoryItem.findUnique + .mockResolvedValueOnce({ + id: 21, + userId: 100, + productId: 5, + quantity: new Prisma.Decimal(1), + unit: 'st', + }) + .mockResolvedValueOnce({ + id: 22, + userId: 200, + productId: 5, + quantity: new Prisma.Decimal(2), + unit: 'st', + }); + + await expect(service.mergeAdmin(21, 22)).rejects.toThrow(BadRequestException); + }); + + it('mergeAdmin blockerar merge mellan olika produkter', async () => { + prismaMock.inventoryItem.findUnique + .mockResolvedValueOnce({ + id: 23, + userId: 100, + productId: 10, + quantity: new Prisma.Decimal(1), + unit: 'st', + }) + .mockResolvedValueOnce({ + id: 24, + userId: 100, + productId: 11, + quantity: new Prisma.Decimal(2), + unit: 'st', + }); + + await expect(service.mergeAdmin(23, 24)).rejects.toThrow(BadRequestException); + }); + + it('previewMergeAdmin returnerar canMerge=false för olika enhet', async () => { + prismaMock.inventoryItem.findUnique + .mockResolvedValueOnce({ + id: 31, + userId: 300, + productId: 12, + quantity: new Prisma.Decimal(1), + unit: 'st', + }) + .mockResolvedValueOnce({ + id: 32, + userId: 300, + productId: 12, + quantity: new Prisma.Decimal(2), + unit: 'kg', + }); + + const result = await service.previewMergeAdmin(31, 32); + expect(result.canMerge).toBe(false); + expect(result.reason).toBe('Cannot merge inventory items with different units'); + }); + + it('andra merge-försök med redan borttagen source ger NotFound (race-liknande)', async () => { + const tx = { + inventoryItem: { + update: jest.fn().mockResolvedValue({ id: 52 }), + delete: jest.fn().mockResolvedValue({ id: 51 }), + }, + inventoryConsumption: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + + prismaMock.$transaction.mockImplementation(async (cb: any) => cb(tx)); + + prismaMock.inventoryItem.findUnique + .mockResolvedValueOnce({ + id: 51, + userId: 10, + productId: 5, + quantity: new Prisma.Decimal(1), + unit: 'st', + }) + .mockResolvedValueOnce({ + id: 52, + userId: 10, + productId: 5, + quantity: new Prisma.Decimal(2), + unit: 'st', + }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 52, + userId: 10, + productId: 5, + quantity: new Prisma.Decimal(3), + unit: 'st', + }); + + await expect(service.mergeAdmin(51, 52)).resolves.toBeDefined(); + await expect(service.mergeAdmin(51, 52)).rejects.toThrow(NotFoundException); + }); }); diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 00139545..223c1bee 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -1,5 +1,5 @@ import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateInventoryDto } from './dto/create-inventory.dto'; @@ -10,6 +10,11 @@ type InventoryQuery = { sort?: string; }; +type AdminInventoryQuery = { + userId?: number; + sort?: string; +}; + @Injectable() export class InventoryService { constructor(private prisma: PrismaService) {} @@ -32,6 +37,14 @@ export class InventoryService { throw new NotFoundException(`Inventory item with id ${id} not found`); } + private async findInventoryItemAnyByIdOrThrow(id: number) { + const existing = await this.prisma.inventoryItem.findUnique({ where: { id } }); + if (!existing) { + this.throwInventoryItemNotFound(id); + } + return existing; + } + private async findInventoryItemByIdOrThrow(id: number, userId: number) { const existing = await this.prisma.inventoryItem.findUnique({ where: { id } }); if (!existing) { @@ -51,6 +64,100 @@ export class InventoryService { return product; } + private async ensureProductExistsAny(productId: number) { + const product = await this.prisma.product.findUnique({ where: { id: productId } }); + if (!product) { + throw new NotFoundException('Product not found'); + } + return product; + } + + private async ensureUserExists(userId: number) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (!user) { + throw new NotFoundException(`User with id ${userId} not found`); + } + } + + private buildCreateData(userId: number, data: CreateInventoryDto): Prisma.InventoryItemUncheckedCreateInput { + return { + ...data, + userId, + quantity: new Prisma.Decimal(data.quantity), + location: data.location?.trim() || undefined, + brand: data.brand?.trim() || undefined, + origin: data.origin?.trim() || undefined, + receiptName: data.receiptName?.trim() || undefined, + suitableFor: data.suitableFor?.trim() || undefined, + comment: data.comment?.trim() || undefined, + purchaseDate: data.purchaseDate + ? new Date(data.purchaseDate) + : undefined, + bestBeforeDate: data.bestBeforeDate + ? new Date(data.bestBeforeDate) + : undefined, + }; + } + + private buildUpdateData(data: UpdateInventoryDto): Prisma.InventoryItemUpdateInput { + const updateData: Prisma.InventoryItemUpdateInput = {}; + + if (typeof data.productId === 'number') { + updateData.product = { + connect: { id: data.productId }, + }; + } + + if (typeof data.quantity === 'number') { + updateData.quantity = new Prisma.Decimal(data.quantity); + } + + if (typeof data.unit === 'string') { + updateData.unit = data.unit.trim(); + } + + if (typeof data.location === 'string') { + updateData.location = data.location.trim(); + } + + if (typeof data.brand === 'string') { + updateData.brand = data.brand.trim(); + } + + if (typeof data.receiptName === 'string') { + updateData.receiptName = data.receiptName.trim(); + } + + if (typeof data.purchaseDate === 'string') { + updateData.purchaseDate = data.purchaseDate + ? new Date(data.purchaseDate) + : null; + } + + if (typeof data.bestBeforeDate === 'string') { + updateData.bestBeforeDate = data.bestBeforeDate + ? new Date(data.bestBeforeDate) + : null; + } + + if (typeof data.opened === 'boolean') { + updateData.opened = data.opened; + } + + if (typeof data.suitableFor === 'string') { + updateData.suitableFor = data.suitableFor.trim(); + } + + if (typeof data.comment === 'string') { + updateData.comment = data.comment.trim(); + } + + return updateData; + } + async findAll(userId: number, query?: InventoryQuery) { const where: Prisma.InventoryItemWhereInput = { userId }; const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = []; @@ -81,6 +188,42 @@ export class InventoryService { orderBy, }); } + + async findAllAdmin(query?: AdminInventoryQuery) { + const where: Prisma.InventoryItemWhereInput = {}; + const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = []; + + if (typeof query?.userId === 'number' && Number.isFinite(query.userId)) { + where.userId = query.userId; + } + + if (query?.sort === 'nameAsc') { + orderBy.push({ product: { name: 'asc' } } as any); + } else if (query?.sort === 'nameDesc') { + orderBy.push({ product: { name: 'desc' } } as any); + } else if (query?.sort === 'quantityDesc') { + orderBy.push({ quantity: 'desc' }); + } else if (query?.sort === 'quantityAsc') { + orderBy.push({ quantity: 'asc' }); + } else { + orderBy.push({ createdAt: 'desc' }); + } + + return this.prisma.inventoryItem.findMany({ + where, + include: { + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + product: this.productWithCategoryInclude, + }, + orderBy, + }); + } async consume(id: number, userId: number, data: ConsumeInventoryDto) { const existing = await this.findInventoryItemByIdOrThrow(id, userId); @@ -155,29 +298,33 @@ export class InventoryService { await this.ensureProductExists(data.productId, userId); return this.prisma.inventoryItem.create({ - data: { - ...data, - userId, - quantity: new Prisma.Decimal(data.quantity), - location: data.location?.trim() || undefined, - brand: data.brand?.trim() || undefined, - origin: data.origin?.trim() || undefined, - receiptName: data.receiptName?.trim() || undefined, - suitableFor: data.suitableFor?.trim() || undefined, - comment: data.comment?.trim() || undefined, - purchaseDate: data.purchaseDate - ? new Date(data.purchaseDate) - : undefined, - bestBeforeDate: data.bestBeforeDate - ? new Date(data.bestBeforeDate) - : undefined, - }, + data: this.buildCreateData(userId, data), include: { product: this.productWithCategoryInclude, }, }); } + async createAdmin(adminUserId: number, data: CreateInventoryDto, targetUserId?: number) { + const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId; + await this.ensureUserExists(effectiveUserId); + await this.ensureProductExistsAny(data.productId); + + return this.prisma.inventoryItem.create({ + data: this.buildCreateData(effectiveUserId, data), + include: { + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + product: this.productWithCategoryInclude, + }, + }); + } + async update(id: number, userId: number, data: UpdateInventoryDto) { await this.findInventoryItemByIdOrThrow(id, userId); @@ -185,57 +332,7 @@ export class InventoryService { await this.ensureProductExists(data.productId, userId); } - const updateData: Prisma.InventoryItemUpdateInput = {}; - - if (typeof data.productId === 'number') { - updateData.product = { - connect: { id: data.productId }, - }; - } - - if (typeof data.quantity === 'number') { - updateData.quantity = new Prisma.Decimal(data.quantity); - } - - if (typeof data.unit === 'string') { - updateData.unit = data.unit.trim(); - } - - if (typeof data.location === 'string') { - updateData.location = data.location.trim(); - } - - if (typeof data.brand === 'string') { - updateData.brand = data.brand.trim(); - } - - if (typeof data.receiptName === 'string') { - updateData.receiptName = data.receiptName.trim(); - } - - if (typeof data.purchaseDate === 'string') { - updateData.purchaseDate = data.purchaseDate - ? new Date(data.purchaseDate) - : null; - } - - if (typeof data.bestBeforeDate === 'string') { - updateData.bestBeforeDate = data.bestBeforeDate - ? new Date(data.bestBeforeDate) - : null; - } - - if (typeof data.opened === 'boolean') { - updateData.opened = data.opened; - } - - if (typeof data.suitableFor === 'string') { - updateData.suitableFor = data.suitableFor.trim(); - } - - if (typeof data.comment === 'string') { - updateData.comment = data.comment.trim(); - } + const updateData = this.buildUpdateData(data); return this.prisma.inventoryItem.update({ where: { id }, @@ -250,4 +347,147 @@ export class InventoryService { await this.findInventoryItemByIdOrThrow(id, userId); return this.prisma.inventoryItem.delete({ where: { id } }); } + + async updateAdmin(id: number, data: UpdateInventoryDto) { + await this.findInventoryItemAnyByIdOrThrow(id); + + if (typeof data.productId === 'number') { + await this.ensureProductExistsAny(data.productId); + } + + const updateData = this.buildUpdateData(data); + + return this.prisma.inventoryItem.update({ + where: { id }, + data: updateData, + include: { + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + product: this.productWithCategoryInclude, + }, + }); + } + + async removeAdmin(id: number) { + await this.findInventoryItemAnyByIdOrThrow(id); + return this.prisma.inventoryItem.delete({ where: { id } }); + } + + private validateAdminMergeEligibility( + source: { id: number; userId: number; productId: number; unit: string }, + target: { id: number; userId: number; productId: number; unit: string }, + ): string | null { + if (source.userId !== target.userId) { + return 'Cannot merge inventory items from different users'; + } + + if (source.productId !== target.productId) { + return 'Cannot merge inventory items with different products'; + } + + if (source.unit.trim().toLowerCase() !== target.unit.trim().toLowerCase()) { + return 'Cannot merge inventory items with different units'; + } + + return null; + } + + async previewMergeAdmin(sourceInventoryId: number, targetInventoryId: number) { + if (sourceInventoryId === targetInventoryId) { + return { + canMerge: false, + reason: 'sourceInventoryId and targetInventoryId cannot be the same', + }; + } + + const [source, target] = await Promise.all([ + this.findInventoryItemAnyByIdOrThrow(sourceInventoryId), + this.findInventoryItemAnyByIdOrThrow(targetInventoryId), + ]); + + const reason = this.validateAdminMergeEligibility(source, target); + const mergedQuantity = Number(source.quantity) + Number(target.quantity); + + return { + canMerge: reason == null, + reason, + source: { + id: source.id, + userId: source.userId, + productId: source.productId, + quantity: Number(source.quantity), + unit: source.unit, + }, + target: { + id: target.id, + userId: target.userId, + productId: target.productId, + quantity: Number(target.quantity), + unit: target.unit, + }, + outcome: { + mergedQuantity, + mergedUnit: target.unit, + }, + }; + } + + async mergeAdmin(sourceInventoryId: number, targetInventoryId: number) { + if (sourceInventoryId === targetInventoryId) { + throw new BadRequestException('sourceInventoryId and targetInventoryId cannot be the same'); + } + + const [source, target] = await Promise.all([ + this.findInventoryItemAnyByIdOrThrow(sourceInventoryId), + this.findInventoryItemAnyByIdOrThrow(targetInventoryId), + ]); + + const reason = this.validateAdminMergeEligibility(source, target); + if (reason) { + throw new BadRequestException(reason); + } + + const mergedQuantity = Number(source.quantity) + Number(target.quantity); + + return this.prisma.$transaction(async (tx) => { + const updated = await tx.inventoryItem.update({ + where: { id: target.id }, + data: { + quantity: new Prisma.Decimal(mergedQuantity), + location: target.location ?? source.location, + brand: target.brand ?? source.brand, + origin: target.origin ?? source.origin, + receiptName: target.receiptName ?? source.receiptName, + purchaseDate: target.purchaseDate ?? source.purchaseDate, + opened: target.opened ?? source.opened, + suitableFor: target.suitableFor ?? source.suitableFor, + bestBeforeDate: target.bestBeforeDate ?? source.bestBeforeDate, + comment: target.comment ?? source.comment, + }, + include: { + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + product: this.productWithCategoryInclude, + }, + }); + + await tx.inventoryConsumption.updateMany({ + where: { inventoryItemId: source.id }, + data: { inventoryItemId: target.id }, + }); + + await tx.inventoryItem.delete({ where: { id: source.id } }); + return updated; + }); + } } \ No newline at end of file diff --git a/backend/src/inventory/inventory.validation.security.spec.ts b/backend/src/inventory/inventory.validation.security.spec.ts new file mode 100644 index 00000000..74b4c0e2 --- /dev/null +++ b/backend/src/inventory/inventory.validation.security.spec.ts @@ -0,0 +1,48 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ConsumeInventoryDto } from './dto/consume-inventory.dto'; +import { CreateInventoryDto } from './dto/create-inventory.dto'; +import { UpdateInventoryDto } from './dto/update-inventory.dto'; + +describe('Inventory DTO validation security', () => { + it('CreateInventoryDto nekar negativ quantity', async () => { + const dto = plainToInstance(CreateInventoryDto, { + productId: 10, + quantity: -1, + unit: 'st', + }); + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.property === 'quantity')).toBe(true); + }); + + it('CreateInventoryDto nekar icke-numerisk quantity', async () => { + const dto = plainToInstance(CreateInventoryDto, { + productId: 10, + quantity: 'abc', + unit: 'st', + }); + + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'quantity')).toBe(true); + }); + + it('UpdateInventoryDto nekar ogiltig opened-typ', async () => { + const dto = plainToInstance(UpdateInventoryDto, { + opened: 'true', + }); + + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'opened')).toBe(true); + }); + + it('ConsumeInventoryDto nekar amountUsed under minimum', async () => { + const dto = plainToInstance(ConsumeInventoryDto, { + amountUsed: 0, + }); + + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'amountUsed')).toBe(true); + }); +}); diff --git a/flutter/IMPLEMENTATION_PLAN_RECEIPT_PREVIEW.md b/flutter/IMPLEMENTATION_PLAN_RECEIPT_PREVIEW.md index 78da4486..856334d2 100644 --- a/flutter/IMPLEMENTATION_PLAN_RECEIPT_PREVIEW.md +++ b/flutter/IMPLEMENTATION_PLAN_RECEIPT_PREVIEW.md @@ -218,3 +218,19 @@ Se `next_steps_flutter.md` för split-view roadmap: **Branch**: `feat/receipt-preview-modal` **Labels**: `enhancement`, `import-ux`, `phase-1-mvp` **Estimate**: 2-3h + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/flutter/PERFORMANCE.md b/flutter/PERFORMANCE.md index 3fd061d5..7d916a35 100644 --- a/flutter/PERFORMANCE.md +++ b/flutter/PERFORMANCE.md @@ -131,3 +131,19 @@ Kontrollchecklista: - [ ] Scroll i admin-flikar: fungerar utan lock - [ ] Kvittoimport – checkbox-toggle: rebuild-räknare ökar bara för berörd rad - [ ] Kvittoimport – "Välj alla": en burst av rebuilds (en per rad), inga dubbla + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/flutter/README.md b/flutter/README.md index 5b5ca3a0..d3213ee3 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -45,3 +45,9 @@ Den anvands parallellt med Next-frontenden under migrering och verifiering. - `next_steps_flutter.md` - roadmap och prioriteringar. - `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling. - `../README.md` - overgripande produktinformation. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 8c89ffdf..77af8475 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -63,6 +63,25 @@ class InventoryApiPaths { static String consumptionHistory(int id) => '/inventory/$id/consumption-history'; } +class AdminInventoryApiPaths { + static const list = '/inventory/admin'; + static String withFilters({int? userId, String? sort}) { + final params = {}; + if (userId != null) params['userId'] = '$userId'; + if (sort != null && sort.isNotEmpty) params['sort'] = sort; + if (params.isEmpty) return list; + final query = params.entries + .map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}') + .join('&'); + return '$list?$query'; + } + static String update(int id) => '/inventory/admin/$id'; + static String remove(int id) => '/inventory/admin/$id'; + static const merge = '/inventory/admin/merge'; + static String mergePreview(int sourceInventoryId, int targetInventoryId) => + '/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId'; +} + class PantryApiPaths { static const list = '/pantry'; static String remove(int id) => '/pantry/$id'; diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index ebd50771..f1f8140f 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; +import '../domain/admin_inventory_item.dart'; import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; import '../domain/pending_product.dart'; @@ -311,4 +312,103 @@ class AdminRepository { Future removeReceiptAlias(int id) => _deleteVoid(ReceiptAliasApiPaths.remove(id)); + + // ── Admin inventory (global tabellhantering) ───────────────────────────── + + Future> listAdminInventory({ + int? userId, + String? sort, + }) { + final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort); + return _getList(path, AdminInventoryItem.fromJson); + } + + Future createAdminInventory({ + int? userId, + required int productId, + required double quantity, + required String unit, + String? location, + String? brand, + String? receiptName, + String? suitableFor, + String? comment, + }) { + return _post( + AdminInventoryApiPaths.list, + body: { + if (userId != null) 'userId': userId, + 'productId': productId, + 'quantity': quantity, + 'unit': unit, + if (location != null && location.trim().isNotEmpty) + 'location': location.trim(), + if (brand != null && brand.trim().isNotEmpty) 'brand': brand.trim(), + if (receiptName != null && receiptName.trim().isNotEmpty) + 'receiptName': receiptName.trim(), + if (suitableFor != null && suitableFor.trim().isNotEmpty) + 'suitableFor': suitableFor.trim(), + if (comment != null && comment.trim().isNotEmpty) + 'comment': comment.trim(), + }, + parse: (d) => + AdminInventoryItem.fromJson(Map.from(d as Map)), + ); + } + + Future updateAdminInventory( + int inventoryId, { + int? productId, + double? quantity, + String? unit, + String? location, + String? brand, + String? receiptName, + String? suitableFor, + String? comment, + }) { + final body = { + if (productId != null) 'productId': productId, + if (quantity != null) 'quantity': quantity, + if (unit != null) 'unit': unit, + if (location != null) 'location': location, + if (brand != null) 'brand': brand, + if (receiptName != null) 'receiptName': receiptName, + if (suitableFor != null) 'suitableFor': suitableFor, + if (comment != null) 'comment': comment, + }; + + return _patch( + AdminInventoryApiPaths.update(inventoryId), + body: body, + parse: AdminInventoryItem.fromJson, + ); + } + + Future removeAdminInventory(int inventoryId) => + _deleteVoid(AdminInventoryApiPaths.remove(inventoryId)); + + Future mergeAdminInventory({ + required int sourceInventoryId, + required int targetInventoryId, + }) => + _postVoid(AdminInventoryApiPaths.merge, { + 'sourceInventoryId': sourceInventoryId, + 'targetInventoryId': targetInventoryId, + }); + + Future> previewAdminInventoryMerge({ + required int sourceInventoryId, + required int targetInventoryId, + }) async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson( + AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId), + token: token, + ), + ); + return Map.from(data as Map); + } } diff --git a/flutter/lib/features/admin/domain/admin_inventory_item.dart b/flutter/lib/features/admin/domain/admin_inventory_item.dart new file mode 100644 index 00000000..b1995b4b --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_inventory_item.dart @@ -0,0 +1,60 @@ +class AdminInventoryItem { + final int id; + final int userId; + final String username; + final String userEmail; + final int productId; + final String productName; + final String? productCanonicalName; + final double quantity; + final String unit; + final String? location; + final String? brand; + final String? receiptName; + final String? suitableFor; + final String? comment; + + const AdminInventoryItem({ + required this.id, + required this.userId, + required this.username, + required this.userEmail, + required this.productId, + required this.productName, + this.productCanonicalName, + required this.quantity, + required this.unit, + this.location, + this.brand, + this.receiptName, + this.suitableFor, + this.comment, + }); + + String get displayName { + final canonical = productCanonicalName?.trim(); + if (canonical != null && canonical.isNotEmpty) return canonical; + return productName; + } + + factory AdminInventoryItem.fromJson(Map json) { + final user = (json['user'] as Map?) ?? const {}; + final product = (json['product'] as Map?) ?? const {}; + return AdminInventoryItem( + id: json['id'] as int, + userId: json['userId'] as int, + username: user['username'] as String? ?? '', + userEmail: user['email'] as String? ?? '', + productId: json['productId'] as int, + productName: product['name'] as String? ?? '', + productCanonicalName: product['canonicalName'] as String?, + quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0, + unit: json['unit'] as String? ?? '', + location: json['location'] as String?, + brand: json['brand'] as String?, + receiptName: json['receiptName'] as String?, + suitableFor: json['suitableFor'] as String?, + comment: json['comment'] as String?, + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart new file mode 100644 index 00000000..be8729de --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart @@ -0,0 +1,816 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/admin_inventory_item.dart'; +import '../domain/admin_product.dart'; +import '../domain/user_admin.dart'; + +enum _InventorySort { + newest, + nameAsc, + nameDesc, + quantityAsc, + quantityDesc, +} + +class AdminInventoryPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminInventoryPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _AdminInventoryPanelState(); +} + +class _AdminInventoryPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + String _search = ''; + int? _selectedUserId; + _InventorySort _sort = _InventorySort.newest; + List _items = []; + List _products = []; + List _users = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await Future.wait([ + ref.read(adminRepositoryProvider).listAdminInventory( + userId: _selectedUserId, + sort: _sortParam, + ), + ref.read(adminRepositoryProvider).listGlobalProducts(), + ref.read(adminRepositoryProvider).listUsers(), + ]); + if (!mounted) return; + setState(() { + _items = results[0] as List; + _products = results[1] as List; + _users = results[2] as List; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + String get _sortParam => switch (_sort) { + _InventorySort.newest => '', + _InventorySort.nameAsc => 'nameAsc', + _InventorySort.nameDesc => 'nameDesc', + _InventorySort.quantityAsc => 'quantityAsc', + _InventorySort.quantityDesc => 'quantityDesc', + }; + + String _sortLabel(_InventorySort sort) => switch (sort) { + _InventorySort.newest => 'Nyast', + _InventorySort.nameAsc => 'Namn A-Ö', + _InventorySort.nameDesc => 'Namn Ö-A', + _InventorySort.quantityAsc => 'Mängd stigande', + _InventorySort.quantityDesc => 'Mängd fallande', + }; + + List get _filtered { + final q = _search.trim().toLowerCase(); + if (q.isEmpty) return _items; + return _items.where((item) { + return item.displayName.toLowerCase().contains(q) || + item.username.toLowerCase().contains(q) || + item.userEmail.toLowerCase().contains(q) || + (item.location ?? '').toLowerCase().contains(q); + }).toList(); + } + + Future _addItem() async { + final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId); + if (values == null) return; + + try { + await ref.read(adminRepositoryProvider).createAdminInventory( + userId: values.ownerUserId, + productId: values.productId, + quantity: values.quantity, + unit: values.unit, + location: values.location, + brand: values.brand, + receiptName: values.receiptName, + suitableFor: values.suitableFor, + comment: values.comment, + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inventory-post skapad.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future _editItem(AdminInventoryItem item) async { + final values = await _showInventoryFormDialog(initial: item); + if (values == null) return; + + try { + await ref.read(adminRepositoryProvider).updateAdminInventory( + item.id, + productId: values.productId, + quantity: values.quantity, + unit: values.unit, + location: values.location, + brand: values.brand, + receiptName: values.receiptName, + suitableFor: values.suitableFor, + comment: values.comment, + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inventory-post uppdaterad.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future _deleteItem(AdminInventoryItem item) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Ta bort inventory-post'), + content: Text('Ta bort "${item.displayName}" för ${item.username}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Ta bort'), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + await ref.read(adminRepositoryProvider).removeAdminInventory(item.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inventory-post borttagen.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future _mergeItems() async { + if (_items.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Minst två inventory-poster krävs för merge.')), + ); + return; + } + + int? sourceId; + int? targetId; + int? previewSourceId; + int? previewTargetId; + Future?>? previewFuture; + + AdminInventoryItem? byId(int? id) { + if (id == null) return null; + for (final item in _items) { + if (item.id == id) return item; + } + return null; + } + + String? mergeValidation(AdminInventoryItem? source, AdminInventoryItem? target) { + if (source == null || target == null) { + return 'Välj både source och target för merge.'; + } + if (source.id == target.id) { + return 'Source och target kan inte vara samma post.'; + } + if (source.userId != target.userId) { + return 'Merge kräver samma användare på båda posterna.'; + } + if (source.productId != target.productId) { + return 'Merge kräver samma produkt på båda posterna.'; + } + if (source.unit.trim().toLowerCase() != target.unit.trim().toLowerCase()) { + return 'Merge kräver samma enhet på båda posterna.'; + } + return null; + } + + Future?> fetchPreview(int? sourceId, int? targetId) async { + if (sourceId == null || targetId == null) return null; + try { + return await ref.read(adminRepositoryProvider).previewAdminInventoryMerge( + sourceInventoryId: sourceId, + targetInventoryId: targetId, + ); + } catch (_) { + return null; + } + } + + Future?>? resolvePreviewFuture(int? sourceId, int? targetId) { + if (sourceId == null || targetId == null) return null; + if (previewFuture == null || + previewSourceId != sourceId || + previewTargetId != targetId) { + previewSourceId = sourceId; + previewTargetId = targetId; + previewFuture = fetchPreview(sourceId, targetId); + } + return previewFuture; + } + + final ok = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + final source = byId(sourceId); + final target = byId(targetId); + final validationMessage = mergeValidation(source, target); + final canMerge = validationMessage == null; + final localMergedQuantity = canMerge && source != null && target != null + ? source.quantity + target.quantity + : null; + + return AlertDialog( + title: const Text('Merge inventory-poster'), + content: SizedBox( + width: 460, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: sourceId, + items: _items + .map((e) => DropdownMenuItem( + value: e.id, + child: Text( + '${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (v) => setDialogState(() => sourceId = v), + decoration: const InputDecoration(labelText: 'Source (tas bort)'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: targetId, + items: _items + .map((e) => DropdownMenuItem( + value: e.id, + child: Text( + '${e.displayName} (${e.quantity} ${e.unit}) · ${e.username}', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (v) => setDialogState(() => targetId = v), + decoration: const InputDecoration(labelText: 'Target (behålls)'), + ), + const SizedBox(height: 12), + if (sourceId != null && targetId != null) + FutureBuilder?>( + future: resolvePreviewFuture(sourceId, targetId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Align( + alignment: Alignment.centerLeft, + child: Text('Hämtar server-preview...'), + ); + } + final data = snapshot.data; + if (data == null) { + return const SizedBox.shrink(); + } + final canMergeServer = data['canMerge'] == true; + final reason = data['reason']?.toString(); + final outcome = data['outcome'] as Map?; + final mergedQuantity = outcome?['mergedQuantity']; + final mergedUnit = outcome?['mergedUnit']?.toString() ?? ''; + + if (!canMergeServer && reason != null && reason.isNotEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Server-preview: $reason', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ); + } + + if (canMergeServer && mergedQuantity != null) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Server-preview: target blir $mergedQuantity $mergedUnit.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + if (sourceId != null && targetId != null) const SizedBox(height: 12), + if (validationMessage != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + validationMessage, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ) + else if (localMergedQuantity != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Lokal förhandsvisning: target blir ${localMergedQuantity.toStringAsFixed(2)} ${target?.unit ?? ''}.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: canMerge ? () => Navigator.of(context).pop(true) : null, + child: const Text('Merge'), + ), + ], + ); + }, + ); + }, + ); + + if (ok != true || sourceId == null || targetId == null) return; + + try { + await ref.read(adminRepositoryProvider).mergeAdminInventory( + sourceInventoryId: sourceId!, + targetInventoryId: targetId!, + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inventory merge genomförd.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + Future<_InventoryFormValues?> _showInventoryFormDialog({ + AdminInventoryItem? initial, + int? initialOwnerUserId, + }) { + return showDialog<_InventoryFormValues>( + context: context, + builder: (context) => _InventoryFormDialog( + users: _users, + products: _products, + initial: initial, + initialOwnerUserId: initialOwnerUserId, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center(child: Text(_error!)); + } + + final filtered = _filtered; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + SizedBox( + width: 300, + child: DropdownButtonFormField( + initialValue: _selectedUserId, + decoration: const InputDecoration(labelText: 'Filtrera användare'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Alla användare'), + ), + ..._users.map( + (u) => DropdownMenuItem( + value: u.id, + child: Text( + '${u.displayName} (${u.username})', + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + setState(() => _selectedUserId = value); + _load(); + }, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 220, + child: DropdownButtonFormField<_InventorySort>( + initialValue: _sort, + decoration: const InputDecoration(labelText: 'Sortering'), + items: _InventorySort.values + .map( + (s) => DropdownMenuItem<_InventorySort>( + value: s, + child: Text(_sortLabel(s)), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() => _sort = value); + _load(); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Sök produkt, användare eller plats', + ), + onChanged: (value) => setState(() => _search = value), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Uppdatera'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _mergeItems, + icon: const Icon(Icons.merge_type), + label: const Text('Merge'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add), + label: const Text('Lägg till'), + ), + ], + ), + const SizedBox(height: 8), + Text('Visar ${filtered.length} av ${_items.length} inventory-poster'), + const SizedBox(height: 8), + Expanded( + child: Card( + child: ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = filtered[index]; + return ListTile( + title: Text(item.displayName), + subtitle: Text( + '${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})' + '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Ändra', + onPressed: () => _editItem(item), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Ta bort', + onPressed: () => _deleteItem(item), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ); + } +} + +class _InventoryFormValues { + final int? ownerUserId; + final int productId; + final double quantity; + final String unit; + final String? location; + final String? brand; + final String? receiptName; + final String? suitableFor; + final String? comment; + + const _InventoryFormValues({ + this.ownerUserId, + required this.productId, + required this.quantity, + required this.unit, + this.location, + this.brand, + this.receiptName, + this.suitableFor, + this.comment, + }); +} + +class _InventoryFormDialog extends StatefulWidget { + final List users; + final List products; + final AdminInventoryItem? initial; + final int? initialOwnerUserId; + + const _InventoryFormDialog({ + required this.users, + required this.products, + this.initial, + this.initialOwnerUserId, + }); + + @override + State<_InventoryFormDialog> createState() => _InventoryFormDialogState(); +} + +class _InventoryFormDialogState extends State<_InventoryFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _quantityController; + late final TextEditingController _unitController; + late final TextEditingController _locationController; + late final TextEditingController _brandController; + late final TextEditingController _receiptNameController; + late final TextEditingController _suitableForController; + late final TextEditingController _commentController; + + int? _ownerUserId; + int? _productId; + + @override + void initState() { + super.initState(); + final initial = widget.initial; + _ownerUserId = initial?.userId ?? widget.initialOwnerUserId; + _productId = initial?.productId; + _quantityController = TextEditingController( + text: initial == null ? '' : initial.quantity.toString(), + ); + _unitController = TextEditingController(text: initial?.unit ?? 'st'); + _locationController = TextEditingController(text: initial?.location ?? ''); + _brandController = TextEditingController(text: initial?.brand ?? ''); + _receiptNameController = TextEditingController(text: initial?.receiptName ?? ''); + _suitableForController = TextEditingController(text: initial?.suitableFor ?? ''); + _commentController = TextEditingController(text: initial?.comment ?? ''); + } + + @override + void dispose() { + _quantityController.dispose(); + _unitController.dispose(); + _locationController.dispose(); + _brandController.dispose(); + _receiptNameController.dispose(); + _suitableForController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.initial == null ? 'Lägg till inventory-post' : 'Ändra inventory-post'), + content: SizedBox( + width: 480, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.initial == null) ...[ + DropdownButtonFormField( + initialValue: _ownerUserId, + items: widget.users + .map((u) => DropdownMenuItem( + value: u.id, + child: Text( + '${u.displayName} (${u.username})', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (value) => setState(() => _ownerUserId = value), + decoration: const InputDecoration(labelText: 'Ägare (användare)'), + validator: (value) => value == null ? 'Välj användare' : null, + ), + const SizedBox(height: 12), + ] else ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 12), + ], + DropdownButtonFormField( + initialValue: _productId, + items: widget.products + .map((p) => DropdownMenuItem( + value: p.id, + child: Text( + p.displayName, + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (value) => setState(() => _productId = value), + decoration: const InputDecoration(labelText: 'Produkt'), + validator: (value) => value == null ? 'Välj en produkt' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _quantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Mängd'), + validator: (value) { + final parsed = double.tryParse((value ?? '').replaceAll(',', '.')); + if (parsed == null || parsed < 0) return 'Ange en giltig mängd'; + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _unitController, + decoration: const InputDecoration(labelText: 'Enhet'), + validator: (value) => + (value == null || value.trim().isEmpty) ? 'Ange enhet' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationController, + decoration: const InputDecoration(labelText: 'Plats (valfritt)'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _brandController, + decoration: const InputDecoration(labelText: 'Varumärke (valfritt)'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _receiptNameController, + decoration: const InputDecoration(labelText: 'Kvittonamn (valfritt)'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _suitableForController, + decoration: const InputDecoration(labelText: 'Passar till (valfritt)'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _commentController, + decoration: const InputDecoration(labelText: 'Kommentar (valfritt)'), + maxLines: 3, + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () { + if (!_formKey.currentState!.validate()) return; + + final quantity = + double.parse(_quantityController.text.trim().replaceAll(',', '.')); + Navigator.of(context).pop( + _InventoryFormValues( + ownerUserId: _ownerUserId, + productId: _productId!, + quantity: quantity, + unit: _unitController.text.trim(), + location: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + brand: _brandController.text.trim().isEmpty + ? null + : _brandController.text.trim(), + receiptName: _receiptNameController.text.trim().isEmpty + ? null + : _receiptNameController.text.trim(), + suitableFor: _suitableForController.text.trim().isEmpty + ? null + : _suitableForController.text.trim(), + comment: _commentController.text.trim().isEmpty + ? null + : _commentController.text.trim(), + ), + ); + }, + child: const Text('Spara'), + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index 9a621ae7..c11bb0fe 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -4,6 +4,7 @@ import '../../../core/l10n/l10n.dart'; import 'admin_ai_panel.dart'; import 'admin_aliases_panel.dart'; import 'admin_database_panel.dart'; +import 'admin_inventory_panel.dart'; import 'admin_pending_products_panel.dart'; import 'admin_products_panel.dart'; import 'admin_users_panel.dart'; @@ -19,7 +20,7 @@ class _AdminScreenState extends ConsumerState { @override Widget build(BuildContext context) { return DefaultTabController( - length: 6, + length: 7, child: Column( children: [ Material( @@ -29,6 +30,7 @@ class _AdminScreenState extends ConsumerState { tabs: [ Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)), const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)), + const Tab(text: 'Inventory', icon: Icon(Icons.inventory_outlined)), const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)), Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)), const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)), @@ -47,6 +49,10 @@ class _AdminScreenState extends ConsumerState { padding: EdgeInsets.fromLTRB(12, 12, 12, 8), child: AdminDatabasePanel(embedded: true), ), + Padding( + padding: EdgeInsets.fromLTRB(12, 12, 12, 8), + child: AdminInventoryPanel(embedded: true), + ), Padding( padding: EdgeInsets.fromLTRB(12, 12, 12, 8), child: AdminProductsPanel(embedded: true), diff --git a/flutter/next_steps_flutter.md b/flutter/next_steps_flutter.md index c1691547..a5b144b9 100644 --- a/flutter/next_steps_flutter.md +++ b/flutter/next_steps_flutter.md @@ -51,3 +51,21 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md` - `README.md` - anvandarperspektiv. - `teknisk_beskrivning_flutter.md` - teknisk referens. - `../NEXT_STEPS.md` - overgripande roadmap for hela produkten. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/flutter/teknisk_beskrivning_flutter.md b/flutter/teknisk_beskrivning_flutter.md index a28d15b7..ea2c92ef 100644 --- a/flutter/teknisk_beskrivning_flutter.md +++ b/flutter/teknisk_beskrivning_flutter.md @@ -106,3 +106,23 @@ docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flut - `README.md` - anvandarguide. - `next_steps_flutter.md` - aktiv planering. - `../TEKNISK_BESKRIVNING.md` - backend/systemovergripande teknik. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/migrering-MSI.md b/migrering-MSI.md index a4ac7fa3..4c26faa0 100644 --- a/migrering-MSI.md +++ b/migrering-MSI.md @@ -260,3 +260,7 @@ backend-tjänstens env. - **Auth stannar i recipe-app backend** — microservice-importer exponeras bara internt på Docker-nätverket - **Bildoptimering vid sparande** behålls i recipe-app (sker vid `RecipesService.create()`, inte vid import) - `receipt-import` splittad: AI-del → microservice, produktmatchning + DB → recipe-app backend + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/produktlansering.md b/produktlansering.md index 7c9dee00..078ad9c1 100644 --- a/produktlansering.md +++ b/produktlansering.md @@ -46,3 +46,7 @@ Det kompletterar `NEXT_STEPS.md` och ska inte duplicera backloggen. - `NEXT_STEPS.md` - overgripande prioriteringar. - `TEKNISK_BESKRIVNING.md` - teknisk implementation. - `flutter/next_steps_flutter.md` - Flutter-specifik leveransplan. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. diff --git a/review_backend.md b/review_backend.md index c4dc7df3..c8e59c9a 100644 --- a/review_backend.md +++ b/review_backend.md @@ -98,3 +98,7 @@ 3. Kodduplicering reducerad i prioriterade moduler. 4. Testskydd finns för alla kritiska flöden. 5. CI-gates förhindrar att kvaliteten glider tillbaka. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR. + +## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.