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

This commit is contained in:
Nils-Johan Gynther
2026-05-10 00:20:25 +02:00
parent 65137b41fb
commit 1709bb1dad
33 changed files with 1879 additions and 71 deletions
+5
View File
@@ -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.
+11
View File
@@ -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.
+4
View File
@@ -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.
+12
View File
@@ -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.
+10
View File
@@ -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.
+1
View File
@@ -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
+1
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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
+37
View File
@@ -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');
});
});
+158 -1
View File
@@ -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);
});
}); });
+309 -69
View File
@@ -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.
+16
View File
@@ -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.
+6
View File
@@ -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.
+19
View File
@@ -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),
+18
View File
@@ -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.
+20
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.