Implement admin inventory management features including CRUD operations, merging, filtering, sorting, previewing, and security enhancements. Update documentation and add comprehensive test coverage for security and validation.
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -8,8 +8,11 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
|
|||||||
- Fokus: en gemensam prioriteringslista for produkt, utveckling och drift.
|
- Fokus: en gemensam prioriteringslista for produkt, utveckling och drift.
|
||||||
- Delplaner for underomraden ska referera hit, inte duplicera hela roadmapen.
|
- Delplaner for underomraden ska referera hit, inte duplicera hela roadmapen.
|
||||||
|
|
||||||
|
|
||||||
## Nyligen klart
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- `migrering-MSI.md` - migreringshistorik for importer.
|
||||||
- `flutter/next_steps_flutter.md` - Flutter-specifik plan.
|
- `flutter/next_steps_flutter.md` - Flutter-specifik plan.
|
||||||
- `_archive/microservice-ai/AI-FUNKTIONER.md` - AI-strategi och historik.
|
- `_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.
|
||||||
|
|||||||
@@ -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
|
# Session 2026-05-06: User-scoped AI-fallback, admin-toggles och premium-funktioner
|
||||||
|
|
||||||
Under denna session har Recipe App fått:
|
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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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
|
# Sessionlogg: Receipt Import Cleanup & Optimization
|
||||||
|
|
||||||
Datum: 2026-05-09
|
Datum: 2026-05-09
|
||||||
@@ -226,3 +234,7 @@ Filer:
|
|||||||
- [ ] Testa admin rename/merge
|
- [ ] Testa admin rename/merge
|
||||||
- [ ] Testa private endpoints (API-test eller manual)
|
- [ ] Testa private endpoints (API-test eller manual)
|
||||||
- [ ] Implementera user-UI för private rename/merge (valfritt)
|
- [ ] 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.
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
# 🔒 Säkerhetshärdningsplan för Recipe-App (Flutter + NestJS + MariaDB)
|
# 🔒 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.
|
**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.
|
- **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.
|
- **.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.
|
- **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.
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ Se även:
|
|||||||
- [AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för detaljerad AI-översikt och modellval.
|
- [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.
|
- [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.
|
- [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
|
# 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.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
|||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
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
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -75,3 +75,5 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
session: { strategy: 'jwt' },
|
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.
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// 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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Session 2026-05-06: User-scoped AI, admin-toggles och premium
|
# 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:
|
Denna session:
|
||||||
- Införde user-scoped AI-förslag för ingrediens- och kategorimatchning (premium-funktion).
|
- 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.
|
- Admin kan nu slå på/av AI per användare via backend och UI.
|
||||||
|
|||||||
@@ -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)
|
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)
|
## Dokumentstatus (2026-05-03)
|
||||||
|
|
||||||
### Målgrupp
|
### Målgrupp
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class MergeInventoryDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
sourceInventoryId!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
targetInventoryId!: number;
|
||||||
|
}
|
||||||
@@ -14,11 +14,71 @@ import { UpdateInventoryDto } from './dto/update-inventory.dto';
|
|||||||
import { InventoryService } from './inventory.service';
|
import { InventoryService } from './inventory.service';
|
||||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
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')
|
@Controller('inventory')
|
||||||
export class InventoryController {
|
export class InventoryController {
|
||||||
constructor(private readonly inventoryService: InventoryService) {}
|
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')
|
@Post(':id/consume')
|
||||||
consume(
|
consume(
|
||||||
@CurrentUser() user: { userId: number },
|
@CurrentUser() user: { userId: number },
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { InventoryService } from './inventory.service';
|
import { InventoryService } from './inventory.service';
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@ describe('InventoryService security', () => {
|
|||||||
},
|
},
|
||||||
product: {
|
product: {
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
$transaction: jest.fn(),
|
$transaction: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -85,4 +89,157 @@ describe('InventoryService security', () => {
|
|||||||
|
|
||||||
await expect(service.findConsumptionHistory(12, 42)).rejects.toThrow(ForbiddenException);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
import { CreateInventoryDto } from './dto/create-inventory.dto';
|
||||||
@@ -10,6 +10,11 @@ type InventoryQuery = {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminInventoryQuery = {
|
||||||
|
userId?: number;
|
||||||
|
sort?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InventoryService {
|
export class InventoryService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -32,6 +37,14 @@ export class InventoryService {
|
|||||||
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
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) {
|
private async findInventoryItemByIdOrThrow(id: number, userId: number) {
|
||||||
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -51,6 +64,100 @@ export class InventoryService {
|
|||||||
return product;
|
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) {
|
async findAll(userId: number, query?: InventoryQuery) {
|
||||||
const where: Prisma.InventoryItemWhereInput = { userId };
|
const where: Prisma.InventoryItemWhereInput = { userId };
|
||||||
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
||||||
@@ -82,6 +189,42 @@ export class InventoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async consume(id: number, userId: number, data: ConsumeInventoryDto) {
|
||||||
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
|
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
|
||||||
|
|
||||||
@@ -155,29 +298,33 @@ export class InventoryService {
|
|||||||
await this.ensureProductExists(data.productId, userId);
|
await this.ensureProductExists(data.productId, userId);
|
||||||
|
|
||||||
return this.prisma.inventoryItem.create({
|
return this.prisma.inventoryItem.create({
|
||||||
data: {
|
data: this.buildCreateData(userId, 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,
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
product: this.productWithCategoryInclude,
|
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) {
|
async update(id: number, userId: number, data: UpdateInventoryDto) {
|
||||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||||
|
|
||||||
@@ -185,57 +332,7 @@ export class InventoryService {
|
|||||||
await this.ensureProductExists(data.productId, userId);
|
await this.ensureProductExists(data.productId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.InventoryItemUpdateInput = {};
|
const updateData = this.buildUpdateData(data);
|
||||||
|
|
||||||
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 this.prisma.inventoryItem.update({
|
return this.prisma.inventoryItem.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -250,4 +347,147 @@ export class InventoryService {
|
|||||||
await this.findInventoryItemByIdOrThrow(id, userId);
|
await this.findInventoryItemByIdOrThrow(id, userId);
|
||||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -218,3 +218,19 @@ Se `next_steps_flutter.md` för split-view roadmap:
|
|||||||
**Branch**: `feat/receipt-preview-modal`
|
**Branch**: `feat/receipt-preview-modal`
|
||||||
**Labels**: `enhancement`, `import-ux`, `phase-1-mvp`
|
**Labels**: `enhancement`, `import-ux`, `phase-1-mvp`
|
||||||
**Estimate**: 2-3h
|
**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.
|
||||||
|
|||||||
@@ -131,3 +131,19 @@ Kontrollchecklista:
|
|||||||
- [ ] Scroll i admin-flikar: fungerar utan lock
|
- [ ] Scroll i admin-flikar: fungerar utan lock
|
||||||
- [ ] Kvittoimport – checkbox-toggle: rebuild-räknare ökar bara för berörd rad
|
- [ ] 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
|
- [ ] 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.
|
||||||
|
|||||||
@@ -45,3 +45,9 @@ Den anvands parallellt med Next-frontenden under migrering och verifiering.
|
|||||||
- `next_steps_flutter.md` - roadmap och prioriteringar.
|
- `next_steps_flutter.md` - roadmap och prioriteringar.
|
||||||
- `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling.
|
- `teknisk_beskrivning_flutter.md` - teknisk referens for drift/utveckling.
|
||||||
- `../README.md` - overgripande produktinformation.
|
- `../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.
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ class InventoryApiPaths {
|
|||||||
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
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 = <String, String>{};
|
||||||
|
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 {
|
class PantryApiPaths {
|
||||||
static const list = '/pantry';
|
static const list = '/pantry';
|
||||||
static String remove(int id) => '/pantry/$id';
|
static String remove(int id) => '/pantry/$id';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart';
|
|||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../domain/admin_ai_categorize_result.dart';
|
import '../domain/admin_ai_categorize_result.dart';
|
||||||
import '../domain/admin_category_node.dart';
|
import '../domain/admin_category_node.dart';
|
||||||
|
import '../domain/admin_inventory_item.dart';
|
||||||
import '../domain/admin_product.dart';
|
import '../domain/admin_product.dart';
|
||||||
import '../domain/ai_model_info.dart';
|
import '../domain/ai_model_info.dart';
|
||||||
import '../domain/pending_product.dart';
|
import '../domain/pending_product.dart';
|
||||||
@@ -311,4 +312,103 @@ class AdminRepository {
|
|||||||
|
|
||||||
Future<void> removeReceiptAlias(int id) =>
|
Future<void> removeReceiptAlias(int id) =>
|
||||||
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
||||||
|
|
||||||
|
// ── Admin inventory (global tabellhantering) ─────────────────────────────
|
||||||
|
|
||||||
|
Future<List<AdminInventoryItem>> listAdminInventory({
|
||||||
|
int? userId,
|
||||||
|
String? sort,
|
||||||
|
}) {
|
||||||
|
final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort);
|
||||||
|
return _getList(path, AdminInventoryItem.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AdminInventoryItem> createAdminInventory({
|
||||||
|
int? userId,
|
||||||
|
required int productId,
|
||||||
|
required double quantity,
|
||||||
|
required String unit,
|
||||||
|
String? location,
|
||||||
|
String? brand,
|
||||||
|
String? receiptName,
|
||||||
|
String? suitableFor,
|
||||||
|
String? comment,
|
||||||
|
}) {
|
||||||
|
return _post<AdminInventoryItem>(
|
||||||
|
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<String, dynamic>.from(d as Map)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AdminInventoryItem> updateAdminInventory(
|
||||||
|
int inventoryId, {
|
||||||
|
int? productId,
|
||||||
|
double? quantity,
|
||||||
|
String? unit,
|
||||||
|
String? location,
|
||||||
|
String? brand,
|
||||||
|
String? receiptName,
|
||||||
|
String? suitableFor,
|
||||||
|
String? comment,
|
||||||
|
}) {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
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<void> removeAdminInventory(int inventoryId) =>
|
||||||
|
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
|
||||||
|
|
||||||
|
Future<void> mergeAdminInventory({
|
||||||
|
required int sourceInventoryId,
|
||||||
|
required int targetInventoryId,
|
||||||
|
}) =>
|
||||||
|
_postVoid(AdminInventoryApiPaths.merge, {
|
||||||
|
'sourceInventoryId': sourceInventoryId,
|
||||||
|
'targetInventoryId': targetInventoryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> 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<String, dynamic>.from(data as Map);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> json) {
|
||||||
|
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
|
||||||
|
final product = (json['product'] as Map<String, dynamic>?) ?? 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminInventoryPanel> createState() =>
|
||||||
|
_AdminInventoryPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
String _search = '';
|
||||||
|
int? _selectedUserId;
|
||||||
|
_InventorySort _sort = _InventorySort.newest;
|
||||||
|
List<AdminInventoryItem> _items = [];
|
||||||
|
List<AdminProduct> _products = [];
|
||||||
|
List<UserAdmin> _users = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
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<AdminInventoryItem>;
|
||||||
|
_products = results[1] as List<AdminProduct>;
|
||||||
|
_users = results[2] as List<UserAdmin>;
|
||||||
|
});
|
||||||
|
} 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<AdminInventoryItem> 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<void> _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<void> _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<void> _deleteItem(AdminInventoryItem item) async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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<void> _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<Map<String, dynamic>?>? 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<Map<String, dynamic>?> 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<Map<String, dynamic>?>? 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<bool>(
|
||||||
|
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<int>(
|
||||||
|
initialValue: sourceId,
|
||||||
|
items: _items
|
||||||
|
.map((e) => DropdownMenuItem<int>(
|
||||||
|
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<int>(
|
||||||
|
initialValue: targetId,
|
||||||
|
items: _items
|
||||||
|
.map((e) => DropdownMenuItem<int>(
|
||||||
|
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<Map<String, dynamic>?>(
|
||||||
|
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<String, dynamic>?;
|
||||||
|
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<int>(
|
||||||
|
initialValue: _selectedUserId,
|
||||||
|
decoration: const InputDecoration(labelText: 'Filtrera användare'),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Alla användare'),
|
||||||
|
),
|
||||||
|
..._users.map(
|
||||||
|
(u) => DropdownMenuItem<int>(
|
||||||
|
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<UserAdmin> users;
|
||||||
|
final List<AdminProduct> 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<FormState>();
|
||||||
|
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<int>(
|
||||||
|
initialValue: _ownerUserId,
|
||||||
|
items: widget.users
|
||||||
|
.map((u) => DropdownMenuItem<int>(
|
||||||
|
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<int>(
|
||||||
|
initialValue: _productId,
|
||||||
|
items: widget.products
|
||||||
|
.map((p) => DropdownMenuItem<int>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import '../../../core/l10n/l10n.dart';
|
|||||||
import 'admin_ai_panel.dart';
|
import 'admin_ai_panel.dart';
|
||||||
import 'admin_aliases_panel.dart';
|
import 'admin_aliases_panel.dart';
|
||||||
import 'admin_database_panel.dart';
|
import 'admin_database_panel.dart';
|
||||||
|
import 'admin_inventory_panel.dart';
|
||||||
import 'admin_pending_products_panel.dart';
|
import 'admin_pending_products_panel.dart';
|
||||||
import 'admin_products_panel.dart';
|
import 'admin_products_panel.dart';
|
||||||
import 'admin_users_panel.dart';
|
import 'admin_users_panel.dart';
|
||||||
@@ -19,7 +20,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 6,
|
length: 7,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Material(
|
Material(
|
||||||
@@ -29,6 +30,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
||||||
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
|
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)),
|
const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)),
|
||||||
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
||||||
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
||||||
@@ -47,6 +49,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
child: AdminDatabasePanel(embedded: true),
|
child: AdminDatabasePanel(embedded: true),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: AdminInventoryPanel(embedded: true),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
child: AdminProductsPanel(embedded: true),
|
child: AdminProductsPanel(embedded: true),
|
||||||
|
|||||||
@@ -51,3 +51,21 @@ All historik och implementationdetaljer finns i `teknisk_beskrivning_flutter.md`
|
|||||||
- `README.md` - anvandarperspektiv.
|
- `README.md` - anvandarperspektiv.
|
||||||
- `teknisk_beskrivning_flutter.md` - teknisk referens.
|
- `teknisk_beskrivning_flutter.md` - teknisk referens.
|
||||||
- `../NEXT_STEPS.md` - overgripande roadmap for hela produkten.
|
- `../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.
|
||||||
|
|||||||
@@ -106,3 +106,23 @@ docker compose -f compose.yml -f compose.flutter.yml up -d --no-deps recipe-flut
|
|||||||
- `README.md` - anvandarguide.
|
- `README.md` - anvandarguide.
|
||||||
- `next_steps_flutter.md` - aktiv planering.
|
- `next_steps_flutter.md` - aktiv planering.
|
||||||
- `../TEKNISK_BESKRIVNING.md` - backend/systemovergripande teknik.
|
- `../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.
|
||||||
|
|||||||
@@ -260,3 +260,7 @@ backend-tjänstens env.
|
|||||||
- **Auth stannar i recipe-app backend** — microservice-importer exponeras bara internt på Docker-nätverket
|
- **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)
|
- **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
|
- `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.
|
||||||
|
|||||||
@@ -46,3 +46,7 @@ Det kompletterar `NEXT_STEPS.md` och ska inte duplicera backloggen.
|
|||||||
- `NEXT_STEPS.md` - overgripande prioriteringar.
|
- `NEXT_STEPS.md` - overgripande prioriteringar.
|
||||||
- `TEKNISK_BESKRIVNING.md` - teknisk implementation.
|
- `TEKNISK_BESKRIVNING.md` - teknisk implementation.
|
||||||
- `flutter/next_steps_flutter.md` - Flutter-specifik leveransplan.
|
- `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.
|
||||||
|
|||||||
@@ -98,3 +98,7 @@
|
|||||||
3. Kodduplicering reducerad i prioriterade moduler.
|
3. Kodduplicering reducerad i prioriterade moduler.
|
||||||
4. Testskydd finns för alla kritiska flöden.
|
4. Testskydd finns för alla kritiska flöden.
|
||||||
5. CI-gates förhindrar att kvaliteten glider tillbaka.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user