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