From a1a2c334278bde039b86e9e9c3d0d61b5b06c2fd Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 20 May 2026 09:07:30 +0200 Subject: [PATCH] feat(shopping-list): add shopping list feature with flyer integration This commit introduces a comprehensive shopping list feature with the following key changes: Backend: - Added ShoppingListItem model with relations to User, Product, and Category - Added new fields to FlyerSession for source file metadata - Added categoryId field to FlyerItem model - Implemented session source file retrieval endpoint - Added endpoint for updating flyer session items with category assignment - Added endpoint for planning flyer selections to shopping list - Implemented backfillCategoriesMine for AI-assisted category assignment - Added ShoppingListModule and integrated with FlyerSelectionModule Frontend: - Added ShoppingListScreen and navigation route - Implemented API paths and client methods for shopping list operations - Added category tree loading for shopping list item creation - Integrated shopping list functionality in flyer import tab - Added category backfill trigger in inventory screen - Updated FlyerImportItem model with categoryId support - Added methods for updating flyer session items and retrieving source files Database: - Added new Prisma migration for flyer source metadata and shopping list items - Updated schema with new relations and indexes The shopping list feature allows users to: 1. Plan flyer selections directly to their shopping list 2. View and manage their shopping list items 3. Update flyer session items with proper categorization 4. Retrieve original flyer source files 5. Automatically backfill categories for uncategorized products --- .kilo/plans/1779256290774-nimble-lagoon.md | 152 +++++++++ .../migration.sql | 51 ++++ backend/prisma/schema.prisma | 43 ++- backend/src/app.module.ts | 2 + .../flyer-import/dto/flyer-import.response.ts | 29 +- .../flyer-import/dto/update-flyer-item.dto.ts | 20 ++ .../flyer-import/flyer-import.controller.ts | 34 +++ .../flyer-import/flyer-import.service.spec.ts | 116 ++++++- .../src/flyer-import/flyer-import.service.ts | 288 ++++++++++++++++-- .../dto/plan-to-shopping-list.dto.ts | 11 + .../flyer-selection.controller.ts | 13 + .../flyer-selection/flyer-selection.module.ts | 3 +- .../flyer-selection.service.ts | 11 + backend/src/products/products.controller.ts | 8 +- backend/src/products/products.service.ts | 65 +++- .../receipt-import/receipt-import.service.ts | 28 +- .../dto/shopping-list-item.response.ts | 14 + .../update-shopping-list-item-status.dto.ts | 6 + .../shopping-list/shopping-list.controller.ts | 49 +++ .../src/shopping-list/shopping-list.module.ts | 12 + .../shopping-list.service.spec.ts | 81 +++++ .../shopping-list/shopping-list.service.ts | 162 ++++++++++ flutter/lib/core/api/api_paths.dart | 14 +- flutter/lib/core/router/app_router.dart | 44 +-- flutter/lib/core/ui/app_shell.dart | 20 +- .../import/data/import_repository.dart | 90 ++++++ .../import/domain/flyer_import_item.dart | 37 ++- .../import/domain/flyer_import_result.dart | 16 + .../import/presentation/flyer_import_tab.dart | 231 +++++++++++++- .../presentation/create_inventory_screen.dart | 2 +- .../presentation/inventory_screen.dart | 34 ++- .../data/shopping_list_providers.dart | 18 ++ .../data/shopping_list_repository.dart | 60 ++++ .../domain/shopping_list_item.dart | 40 +++ .../presentation/shopping_list_screen.dart | 99 ++++++ .../data/flyer_import_session_test.dart | 1 + .../domain/inventory_item_category_test.dart | 41 +++ 37 files changed, 1843 insertions(+), 102 deletions(-) create mode 100644 .kilo/plans/1779256290774-nimble-lagoon.md create mode 100644 backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql create mode 100644 backend/src/flyer-import/dto/update-flyer-item.dto.ts create mode 100644 backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts create mode 100644 backend/src/shopping-list/dto/shopping-list-item.response.ts create mode 100644 backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts create mode 100644 backend/src/shopping-list/shopping-list.controller.ts create mode 100644 backend/src/shopping-list/shopping-list.module.ts create mode 100644 backend/src/shopping-list/shopping-list.service.spec.ts create mode 100644 backend/src/shopping-list/shopping-list.service.ts create mode 100644 flutter/lib/features/shopping_list/data/shopping_list_providers.dart create mode 100644 flutter/lib/features/shopping_list/data/shopping_list_repository.dart create mode 100644 flutter/lib/features/shopping_list/domain/shopping_list_item.dart create mode 100644 flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart create mode 100644 flutter/test/features/inventory/domain/inventory_item_category_test.dart diff --git a/.kilo/plans/1779256290774-nimble-lagoon.md b/.kilo/plans/1779256290774-nimble-lagoon.md new file mode 100644 index 00000000..965b5b4a --- /dev/null +++ b/.kilo/plans/1779256290774-nimble-lagoon.md @@ -0,0 +1,152 @@ +# Åtgärdsplan utifrån E2E-fynd (Flyer + Inventory) + +## Mål +- Göra flyerflödet praktiskt användbart för jämförelse, redigering och planering till inköpslista. +- Säkerställa att inventory/pantry visar korrekta kategorier i stället för att allt hamnar i `Övrigt`. + +## Scope (utifrån dina punkter) +1. Flyer-vy: + - Visa importerad flyer-PDF för jämförelse mot extraherade rader. + - Redigera poster (namn + kategori) med samma kategorikälla som products/pantry. + - `Planera X markerade` ska skapa en faktisk inköpslista i en egen flik. +2. Inventory: + - Felsöka och åtgärda varför poster visas under `Övrigt`. + +--- + +## Nulägesbild (från kodbasen) +- Flutter har redan `FlyerImportTab` med: + - filval/import, + - checkboxar, + - `Planera X markerade` -> `POST /flyer-sessions/:id/selections/bulk`. +- PDF-visning finns idag bara för aktuell uppladdad fil i minnet (`_pickedFile.bytes`) och kan inte återöppnas säkert vid återställd session/app-omstart. +- Flyer-rader kan ännu inte redigeras i UI (ingen inline edit-dialog för flyer-item). +- Meal Plan har en shopping-sektion, men ingen dedikerad flik för flyer-planerade köp. +- Kategori-träd finns redan och används i flera vyer via `/categories/tree`. +- Inventory läser kategori via `product.categoryRef`; null blir `Övrigt` i UI. + +--- + +## Genomförandeplan + +### 1) Flyer-PDF: beständig förhandsvisning per session +**Backend** +- Lägg till lagring av originalfil för flyer-session (MVP: lokal filstore eller DB blob beroende på befintligt mönster i projektet). +- Utöka `flyer_session` metadata med filreferens (filnamn, mime, storlek, storageKey). +- Ny endpoint: `GET /flyer-import/sessions/:sessionId/source` (auth + ägarskap) som streamar PDF/bild. + +**Flutter** +- I `FlyerImportTab`: + - använd befintlig local preview direkt efter uppladdning, + - när session återställs: hämta source-endpoint och visa `Visa flyer` även då. +- Behåll fallback-meddelande för plattformar som inte kan öppna PDF direkt. + +**Klart-kriterium** +- Samma importerade flyer kan öppnas efter tab-byte och app-omstart för samma användare. + +### 2) Redigering av flyer-poster (namn + kategori) +**Backend** +- Lägg till endpoint för uppdatering av flyer-item i session, t.ex. + - `PATCH /flyer-import/sessions/:sessionId/items/:itemId` + - fält: `rawName` (eller `displayName`) och `categoryId` (ev. `categoryHintPath` för visning). +- Validera att kategori finns i samma kategoriträd (`categories`). +- Ägarskapskontroll via sessionens `userId`. + +**Flutter** +- I listan i `FlyerImportTab`: lägg till `Redigera`-action per rad. +- Edit-dialog: + - textfält för namn, + - kategori-väljare baserad på samma träd/komponentmönster som inventory/pantry/admin. +- Spara uppdatering till backend och uppdatera lokal/session state. + +**Klart-kriterium** +- Användaren kan ändra namn och kategori på en flyer-rad och ser ändringen direkt i listan. + +### 3) Flyer -> Inköpslista i egen flik +**Backend** +- Definiera enkel shopping-list-resurs för MVP (user-scoped): + - tabell t.ex. `shopping_list_item` (name/productId/categoryId/quantity/unit/source/status/userId). +- Ny endpoint för att skapa inköpsrader från flyer-selections: + - `POST /flyer-sessions/:sessionId/selections/plan-to-shopping-list` + - alternativt återanvänd bulk-create i shopping-modul. +- Deduplicering/regler: + - om samma `productId+unit` finns öppet: summera eller hoppa över (bestäms i implementation; rekommenderat: summera). + +**Flutter** +- Lägg till ny flik/skärm `Inköpslista` i app-shell. +- `Planera X markerade` i flyer-vyn ska: + 1) skapa/uppdatera flyer selections, + 2) trigga backend-mappning till shopping-list, + 3) visa snackbar med antal tillagda/uppdaterade rader. +- Inköpslista-vyn (MVP): lista rader + enkel check/avprickning. + +**Klart-kriterium** +- Klick på `Planera X markerade` flyttar markerade flyer-produkter till Inköpslista-fliken. + +### 4) Inventory-fel: allt i `Övrigt` +**Felsökning** +- Verifiera varför `product.categoryId/categoryRef` blir null i aktuella poster: + - skapade produkter utan kategori, + - importflöden som inte persistar vald kategori, + - äldre data utan backfill. + +**Åtgärd** +- Säkerställ att produktskapande från import/edit alltid skickar/sätter kategori när sådan är vald. +- Lägg skydd i backend så kategori inte tappas vid update-flöden. +- Engångs-backfill för befintliga produkter utan kategori: + - använd befintlig kategoriseringslogik (regel/AI) + fallback till rimlig underkategori. +- Kör re-fetch/invalidations i Flutter inventory/pantry efter backfill. + +**Klart-kriterium** +- Inventory/pantry visar blandade korrekta kategorier; endast okända poster ligger kvar i `Övrigt`. + +--- + +## Testplan + +### Backend +- Nya tester för: + - auth/ownership på source-endpoint och item-edit endpoint, + - validering av kategori-id, + - plan-to-shopping-list (antal skapade, dedupe, idempotens). + +### Flutter widget/integration +- Flyer: + - render av `Visa flyer` efter restore, + - edit-dialog uppdaterar rad, + - `Planera X markerade` ger förväntad feedback. +- Inköpslista: + - ny flik syns, + - mottar rader från flyer. +- Inventory regression: + - kategori visas från product category path när satt, + - `Övrigt` endast fallback. + +### E2E-checklista +- Importera flyer PDF -> öppna PDF -> redigera 2 rader -> planera markerade -> verifiera Inköpslista-fliken. +- Starta om app -> återöppna samma flyer-PDF -> verifiera ändringar kvar. +- Kontrollera inventory/pantry-kategorier efter backfill. + +--- + +## Leveransordning (rekommenderad) +1. Inventory-kategori bugfix + backfill (snabbt värde, hög påverkan). +2. Flyer item-redigering (namn/kategori). +3. Inköpslista-flik + backend-mappning från flyer. +4. Beständig flyer-PDF source-visning (kan byggas parallellt med 2/3 om backendkapacitet finns). + +--- + +## Risker och mitigering +- **Datamigrering/backfill-risk**: kör först mot staging + logga träffsäkerhet och antal fallback till `Övrigt`. +- **Dubbelposter i inköpslista**: inför tydlig dedupe-regel och testfall. +- **PDF-hantering per plattform**: behåll web-first öppning och tydligt fallback-meddelande där native viewer saknas. +- **Prestanda vid stora flyers**: paginera/virtuallista om UI blir tungt. + +--- + +## Definition of Done +- Flyer-vyn har fungerande: PDF-visning, redigering av namn/kategori, planering till inköpslista. +- Inköpslista finns som separat flik och visar planerade flyer-rader. +- Inventory/pantry kategoriserar korrekt och `Övrigt` används endast som verklig fallback. +- Nya backend- och Flutter-tester gröna. diff --git a/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql b/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql new file mode 100644 index 00000000..09228caa --- /dev/null +++ b/backend/prisma/migrations/20260520081000_flyer_source_shopping_list_and_category_backfill/migration.sql @@ -0,0 +1,51 @@ +ALTER TABLE `FlyerSession` + ADD COLUMN `sourceFileName` VARCHAR(191) NULL, + ADD COLUMN `sourceMimeType` VARCHAR(191) NULL, + ADD COLUMN `sourceFileSize` INTEGER NULL, + ADD COLUMN `sourceStorageKey` VARCHAR(191) NULL, + ADD COLUMN `sourceData` LONGBLOB NULL; + +ALTER TABLE `FlyerItem` + ADD COLUMN `categoryId` INTEGER NULL; + +ALTER TABLE `FlyerItem` + ADD CONSTRAINT `FlyerItem_categoryId_fkey` + FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX `FlyerItem_categoryId_idx` ON `FlyerItem`(`categoryId`); + +CREATE TABLE `ShoppingListItem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `name` VARCHAR(191) NOT NULL, + `productId` INTEGER NULL, + `categoryId` INTEGER NULL, + `quantity` DECIMAL(10, 2) NULL, + `unit` VARCHAR(191) NULL, + `source` VARCHAR(191) NOT NULL DEFAULT 'manual', + `status` VARCHAR(191) NOT NULL DEFAULT 'open', + `checkedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `ShoppingListItem_userId_status_idx`(`userId`, `status`), + INDEX `ShoppingListItem_productId_unit_status_idx`(`productId`, `unit`, `status`), + INDEX `ShoppingListItem_categoryId_idx`(`categoryId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE `ShoppingListItem` + ADD CONSTRAINT `ShoppingListItem_categoryId_fkey` + FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c15a0774..d2f59a08 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { unitMappings UnitMapping[] flyerSessions FlyerSession[] flyerSelections FlyerSelection[] + shoppingListItems ShoppingListItem[] } model Product { @@ -57,16 +58,19 @@ model Product { categoryId Int? categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) isPrivate Boolean @default(false) - unitMappings UnitMapping[] -} + unitMappings UnitMapping[] + shoppingListItems ShoppingListItem[] +} model Category { id Int @id @default(autoincrement()) name String parentId Int? parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) - children Category[] @relation("CategoryTree") - products Product[] + children Category[] @relation("CategoryTree") + products Product[] + flyerItems FlyerItem[] + shoppingListItems ShoppingListItem[] @@unique([name, parentId]) @@index([parentId]) @@ -289,6 +293,11 @@ model FlyerSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt expiresAt DateTime? + sourceFileName String? + sourceMimeType String? + sourceFileSize Int? + sourceStorageKey String? + sourceData Bytes? user User @relation(fields: [userId], references: [id], onDelete: Cascade) items FlyerItem[] @@ -305,6 +314,7 @@ model FlyerItem { rawName String normalizedName String categoryHint String? + categoryId Int? price Decimal? @db.Decimal(10, 2) priceUnit String? comparisonPrice Decimal? @db.Decimal(10, 2) @@ -321,10 +331,12 @@ model FlyerItem { updatedAt DateTime @updatedAt session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) selections FlyerSelection[] @@index([sessionId]) @@index([normalizedName]) + @@index([categoryId]) } model FlyerSelection { @@ -348,3 +360,26 @@ model FlyerSelection { @@index([sessionId]) @@index([userId, status]) } + +model ShoppingListItem { + id Int @id @default(autoincrement()) + userId Int + name String + productId Int? + categoryId Int? + quantity Decimal? @db.Decimal(10, 2) + unit String? + source String @default("manual") + status String @default("open") + checkedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + + @@index([userId, status]) + @@index([productId, unit, status]) + @@index([categoryId]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 01047384..ab04d392 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -20,6 +20,7 @@ import { RealtimeModule } from './realtime/realtime.module'; import { HelpTextsModule } from './help-texts/help-texts.module'; import { FlyerImportModule } from './flyer-import/flyer-import.module'; import { FlyerSelectionModule } from './flyer-selection/flyer-selection.module'; +import { ShoppingListModule } from './shopping-list/shopping-list.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; @@ -52,6 +53,7 @@ import { RolesGuard } from './auth/roles.guard'; HelpTextsModule, FlyerImportModule, FlyerSelectionModule, + ShoppingListModule, ], providers: [ { diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 8c834b4a..cfabfad6 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -1,10 +1,11 @@ export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; -export type FlyerImportItem = { - flyerItemId: number | null; - rawName: string; - normalizedName: string; - category: string | null; +export type FlyerImportItem = { + flyerItemId: number | null; + rawName: string; + normalizedName: string; + category: string | null; + categoryId: number | null; price: number | null; priceUnit: string | null; comparisonPrice: number | null; @@ -21,10 +22,14 @@ export type FlyerImportItem = { matchReasons: string[]; }; -export type FlyerImportResponse = { - sessionId: number | null; - retailer: 'willys'; - parserVersion: 'v1'; - items: FlyerImportItem[]; - warnings: string[]; -}; +export type FlyerImportResponse = { + sessionId: number | null; + retailer: 'willys'; + parserVersion: 'v1'; + sourceAvailable: boolean; + sourceFileName: string | null; + sourceMimeType: string | null; + sourceFileSize: number | null; + items: FlyerImportItem[]; + warnings: string[]; +}; diff --git a/backend/src/flyer-import/dto/update-flyer-item.dto.ts b/backend/src/flyer-import/dto/update-flyer-item.dto.ts new file mode 100644 index 00000000..f3d85919 --- /dev/null +++ b/backend/src/flyer-import/dto/update-flyer-item.dto.ts @@ -0,0 +1,20 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class UpdateFlyerItemDto { + @IsOptional() + @IsString() + @MaxLength(191) + rawName?: string; + + @IsOptional() + @Transform(({ value }) => { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + }) + @IsInt() + @Min(1) + categoryId?: number | null; +} diff --git a/backend/src/flyer-import/flyer-import.controller.ts b/backend/src/flyer-import/flyer-import.controller.ts index 808bfc8c..19a4279f 100644 --- a/backend/src/flyer-import/flyer-import.controller.ts +++ b/backend/src/flyer-import/flyer-import.controller.ts @@ -1,12 +1,16 @@ import { + Body, BadRequestException, Controller, Get, + Header, HttpCode, + Patch, Param, ParseIntPipe, Post, Request, + StreamableFile, UnauthorizedException, UploadedFile, UseInterceptors, @@ -15,6 +19,7 @@ import { Throttle } from '@nestjs/throttler'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { FlyerImportResponse } from './dto/flyer-import.response'; +import { UpdateFlyerItemDto } from './dto/update-flyer-item.dto'; import { FlyerImportService } from './flyer-import.service'; const ALLOWED_MIMES = [ @@ -72,6 +77,35 @@ export class FlyerImportController { return this.flyerImportService.getSession(sessionId, userId); } + @Get('sessions/:sessionId/source') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + @Header('Cache-Control', 'private, max-age=300') + async getSessionSource( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + const source = await this.flyerImportService.getSessionSource(sessionId, userId); + return new StreamableFile(source.data, { + disposition: `inline; filename="${source.fileName.replace(/"/g, '')}"`, + type: source.mimeType, + length: source.contentLength, + }); + } + + @Patch('sessions/:sessionId/items/:itemId') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 60 } }) + async updateSessionItem( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Param('itemId', ParseIntPipe) itemId: number, + @Request() req: any, + @Body() dto: UpdateFlyerItemDto, + ) { + const userId = this.getUserId(req); + return this.flyerImportService.updateSessionItem(sessionId, itemId, userId, dto); + } + private getUserId(req?: any): number { const userId = typeof req?.user?.id === 'number' diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts index 2205db13..53298ca7 100644 --- a/backend/src/flyer-import/flyer-import.service.spec.ts +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -1,10 +1,18 @@ -import { NotFoundException } from '@nestjs/common'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { FlyerImportService } from './flyer-import.service'; describe('FlyerImportService', () => { const prismaMock = { flyerSession: { findFirst: jest.fn(), + findUnique: jest.fn(), + }, + flyerItem: { + findUnique: jest.fn(), + update: jest.fn(), + }, + category: { + findUnique: jest.fn(), }, }; @@ -28,7 +36,27 @@ describe('FlyerImportService', () => { await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException); expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ where: { id: 123, userId: 1 }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); }); @@ -64,6 +92,7 @@ describe('FlyerImportService', () => { expect(result.items).toHaveLength(1); expect(result.items[0].flyerItemId).toBe(99); expect(result.items[0].matchedVia).toBe('exact'); + expect(result.sourceAvailable).toBe(false); }); }); @@ -79,8 +108,89 @@ describe('FlyerImportService', () => { expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ where: { userId: 1 }, orderBy: { createdAt: 'desc' }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); }); }); + + describe('updateSessionItem', () => { + it('updates rawName and category path', async () => { + prismaMock.flyerSession.findUnique.mockResolvedValue({ id: 7, userId: 1 }); + prismaMock.flyerItem.findUnique.mockResolvedValue({ + id: 12, + sessionId: 7, + rawName: 'Tomat', + }); + prismaMock.category.findUnique.mockResolvedValue({ + id: 3, + name: 'Tomater', + parent: { name: 'Grönsaker', parent: { name: 'Mat', parent: null } }, + }); + prismaMock.flyerItem.update.mockResolvedValue({ + id: 12, + rawName: 'Cocktailtomater', + normalizedName: 'cocktailtomater', + categoryHint: 'Mat > Grönsaker > Tomater', + categoryId: 3, + price: null, + priceUnit: null, + comparisonPrice: null, + comparisonUnit: null, + offerText: null, + parseConfidence: 1, + parseReasons: [], + matchedProductId: null, + matchedProductName: null, + matchedVia: 'none', + matchConfidence: null, + matchReasons: [], + categoryRef: { name: 'Tomater', parent: { name: 'Grönsaker', parent: { name: 'Mat' } } }, + }); + + const service = createService(); + const result = await service.updateSessionItem(7, 12, 1, { + rawName: 'Cocktailtomater', + categoryId: 3, + }); + + expect(result.rawName).toBe('Cocktailtomater'); + expect(result.categoryId).toBe(3); + expect(result.category).toBe('Mat > Grönsaker > Tomater'); + }); + }); + + describe('getSessionSource', () => { + it('throws when session belongs to another user', async () => { + prismaMock.flyerSession.findUnique.mockResolvedValue({ + userId: 99, + sourceFileName: 'flyer.pdf', + sourceMimeType: 'application/pdf', + sourceFileSize: 10, + sourceData: Buffer.from('abc'), + }); + const service = createService(); + + await expect(service.getSessionSource(1, 1)).rejects.toBeInstanceOf(ForbiddenException); + }); + }); }); diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 5541b5fe..54b6f723 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, NotFoundException, @@ -105,6 +106,7 @@ export class FlyerImportService { rawName: item.rawName, normalizedName: item.normalizedName, category: item.category, + categoryId: null, price, priceUnit, comparisonPrice, @@ -122,21 +124,151 @@ export class FlyerImportService { }; }); - const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items); + const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file); return { sessionId: persistedItems.sessionId, retailer: parsed.retailer, parserVersion: parsed.parserVersion, + sourceAvailable: true, + sourceFileName: file.originalname ?? null, + sourceMimeType: file.mimetype ?? null, + sourceFileSize: file.size ?? null, items: persistedItems.items, warnings: parsed.warnings, }; } + async getSessionSource(sessionId: number, userId: number): Promise<{ + fileName: string; + mimeType: string; + contentLength: number; + data: Buffer; + }> { + const session = await this.prisma.flyerSession.findUnique({ + where: { id: sessionId }, + select: { + userId: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceData: true, + }, + }); + + if (!session) { + throw new NotFoundException('Flyer-session hittades inte.'); + } + if (session.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna session.'); + } + if (!session.sourceData || !session.sourceFileName || !session.sourceMimeType) { + throw new NotFoundException('Källfil saknas för denna flyer-session.'); + } + + const data = Buffer.from(session.sourceData); + return { + fileName: session.sourceFileName, + mimeType: session.sourceMimeType, + contentLength: session.sourceFileSize ?? data.length, + data, + }; + } + + async updateSessionItem( + sessionId: number, + itemId: number, + userId: number, + payload: { rawName?: string; categoryId?: number | null }, + ): Promise { + const session = await this.prisma.flyerSession.findUnique({ + where: { id: sessionId }, + select: { id: true, userId: true }, + }); + if (!session) { + throw new NotFoundException('Flyer-session hittades inte.'); + } + if (session.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna session.'); + } + + const item = await this.prisma.flyerItem.findUnique({ + where: { id: itemId }, + select: { id: true, sessionId: true, rawName: true }, + }); + if (!item || item.sessionId !== sessionId) { + throw new NotFoundException('Flyer-rad hittades inte i sessionen.'); + } + + const updateData: Prisma.FlyerItemUncheckedUpdateInput = {}; + + if (typeof payload.rawName === 'string') { + const trimmed = payload.rawName.trim(); + if (!trimmed) { + throw new BadRequestException('Namn får inte vara tomt.'); + } + updateData.rawName = trimmed; + updateData.normalizedName = normalizeName(trimmed) || normalizeName(item.rawName); + } + + if (payload.categoryId !== undefined) { + if (payload.categoryId === null) { + updateData.categoryId = null; + updateData.categoryHint = null; + } else { + const path = await this.resolveCategoryPath(payload.categoryId); + updateData.categoryId = payload.categoryId; + updateData.categoryHint = path; + } + } + + if (Object.keys(updateData).length === 0) { + throw new BadRequestException('Inga giltiga fält att uppdatera.'); + } + + const updated = await this.prisma.flyerItem.update({ + where: { id: itemId }, + data: updateData, + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + }); + + return this.toFlyerImportItem(updated as any); + } + async getSession(sessionId: number, userId: number): Promise { const session = await this.prisma.flyerSession.findFirst({ where: { id: sessionId, userId }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); if (!session) { @@ -150,37 +282,67 @@ export class FlyerImportService { const latest = await this.prisma.flyerSession.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, - include: { items: { orderBy: { id: 'asc' } } }, + select: { + id: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + sourceStorageKey: true, + items: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + orderBy: { id: 'asc' }, + }, + }, }); if (!latest) { - return { - sessionId: null, - retailer: 'willys', - parserVersion: 'v1', - items: [], - warnings: [], - }; + return { + sessionId: null, + retailer: 'willys', + parserVersion: 'v1', + sourceAvailable: false, + sourceFileName: null, + sourceMimeType: null, + sourceFileSize: null, + items: [], + warnings: [], + }; } return this.toFlyerImportResponseFromSession(latest); } - private async persistSessionWithItems( - userId: number, - retailer: 'willys', - items: FlyerImportItem[], - ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { + private async persistSessionWithItems( + userId: number, + retailer: 'willys', + items: FlyerImportItem[], + file: Express.Multer.File, + ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { const weekKey = this.toWeekKey(new Date()); const session = await this.prisma.flyerSession.create({ data: { userId, retailer, - weekKey, - status: 'draft', - }, - select: { id: true }, + weekKey, + status: 'draft', + sourceFileName: file.originalname ?? null, + sourceMimeType: file.mimetype ?? null, + sourceFileSize: file.size ?? file.buffer.length, + sourceStorageKey: this.buildSourceStorageKey(userId, weekKey), + sourceData: Buffer.from(file.buffer), + }, + select: { id: true }, }); const savedItems: FlyerImportItem[] = []; @@ -188,10 +350,11 @@ export class FlyerImportService { const created = await this.prisma.flyerItem.create({ data: { sessionId: session.id, - rawName: item.rawName, - normalizedName: item.normalizedName, - categoryHint: item.category, - price: item.price != null ? new Prisma.Decimal(item.price) : null, + rawName: item.rawName, + normalizedName: item.normalizedName, + categoryHint: item.category, + categoryId: item.categoryId, + price: item.price != null ? new Prisma.Decimal(item.price) : null, priceUnit: item.priceUnit, comparisonPrice: item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null, @@ -472,6 +635,16 @@ export class FlyerImportService { rawName: string; normalizedName: string; categoryHint: string | null; + categoryId: number | null; + categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { + name: string; + } | null; + } | null; + } | null; price: Prisma.Decimal | null; priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; @@ -495,6 +668,8 @@ export class FlyerImportService { ? item.matchedVia : 'none'; + const categoryPath = this.buildCategoryPath(item.categoryRef) ?? item.categoryHint; + const offerLimitText = this.extractOfferLimitText(item.offerText); const offerSignals = this.extractOfferSignals(item.offerText); @@ -502,7 +677,8 @@ export class FlyerImportService { flyerItemId: item.id, rawName: item.rawName, normalizedName: item.normalizedName, - category: item.categoryHint, + category: categoryPath, + categoryId: item.categoryId, price: item.price != null ? item.price.toNumber() : offerSignals.price, priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit, comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice, @@ -524,13 +700,44 @@ export class FlyerImportService { }; } + private buildCategoryPath(categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { name: string } | null; + } | null; + } | null): string | null { + if (!categoryRef) return null; + const names: string[] = []; + let current: { name: string; parent?: any } | null = categoryRef; + while (current) { + names.unshift(current.name); + current = current.parent ?? null; + } + return names.length > 0 ? names.join(' > ') : null; + } + private toFlyerImportResponseFromSession(session: { id: number; + sourceFileName?: string | null; + sourceMimeType?: string | null; + sourceFileSize?: number | null; + sourceStorageKey?: string | null; items: Array<{ id: number; rawName: string; normalizedName: string; categoryHint: string | null; + categoryId: number | null; + categoryRef?: { + name: string; + parent?: { + name: string; + parent?: { + name: string; + } | null; + } | null; + } | null; price: Prisma.Decimal | null; priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; @@ -549,8 +756,41 @@ export class FlyerImportService { sessionId: session.id, retailer: 'willys', parserVersion: 'v1', + sourceAvailable: !!session.sourceStorageKey, + sourceFileName: session.sourceFileName ?? null, + sourceMimeType: session.sourceMimeType ?? null, + sourceFileSize: session.sourceFileSize ?? null, items: session.items.map((item) => this.toFlyerImportItem(item)), warnings: [], }; } + + private async resolveCategoryPath(categoryId: number): Promise { + const category = await this.prisma.category.findUnique({ + where: { id: categoryId }, + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }); + + if (!category) { + throw new BadRequestException(`Kategori med id ${categoryId} hittades inte.`); + } + + const names: string[] = []; + let current: { name: string; parent: any } | null = category as any; + while (current) { + names.unshift(current.name); + current = current.parent; + } + return names.join(' > '); + } + + private buildSourceStorageKey(userId: number, weekKey: string): string { + return `flyer/${userId}/${weekKey}/${Date.now()}`; + } } diff --git a/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts b/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts new file mode 100644 index 00000000..0a36a06f --- /dev/null +++ b/backend/src/flyer-selection/dto/plan-to-shopping-list.dto.ts @@ -0,0 +1,11 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsInt, IsOptional, Min } from 'class-validator'; + +export class PlanToShoppingListDto { + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + @Min(1, { each: true }) + itemIds?: number[]; +} diff --git a/backend/src/flyer-selection/flyer-selection.controller.ts b/backend/src/flyer-selection/flyer-selection.controller.ts index 1dd69982..1dc18a7c 100644 --- a/backend/src/flyer-selection/flyer-selection.controller.ts +++ b/backend/src/flyer-selection/flyer-selection.controller.ts @@ -17,6 +17,7 @@ import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.d import { FlyerSelectionResponse } from './dto/flyer-selection.response'; import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto'; import { FlyerSelectionService } from './flyer-selection.service'; +import { PlanToShoppingListDto } from './dto/plan-to-shopping-list.dto'; @Controller('flyer-sessions/:sessionId/selections') export class FlyerSelectionController { @@ -80,6 +81,18 @@ export class FlyerSelectionController { await this.flyerSelectionService.remove(sessionId, selectionId, userId); } + @Post('plan-to-shopping-list') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 20 } }) + async planToShoppingList( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Body() dto: PlanToShoppingListDto, + @Request() req?: any, + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + const userId = this.getUserId(req); + return this.flyerSelectionService.planToShoppingList(sessionId, userId, dto.itemIds); + } + private getUserId(req?: any): number { const userId = typeof req?.user?.id === 'number' diff --git a/backend/src/flyer-selection/flyer-selection.module.ts b/backend/src/flyer-selection/flyer-selection.module.ts index 7ed44da0..a7ead5f8 100644 --- a/backend/src/flyer-selection/flyer-selection.module.ts +++ b/backend/src/flyer-selection/flyer-selection.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; +import { ShoppingListModule } from '../shopping-list/shopping-list.module'; import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service'; import { FlyerSelectionController } from './flyer-selection.controller'; import { FlyerSelectionSyncController } from './flyer-selection-sync.controller'; import { FlyerSelectionService } from './flyer-selection.service'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ShoppingListModule], controllers: [FlyerSelectionController, FlyerSelectionSyncController], providers: [FlyerSelectionService, FlyerSelectionMatcherService], exports: [FlyerSelectionService], diff --git a/backend/src/flyer-selection/flyer-selection.service.ts b/backend/src/flyer-selection/flyer-selection.service.ts index 96e96b10..800556a4 100644 --- a/backend/src/flyer-selection/flyer-selection.service.ts +++ b/backend/src/flyer-selection/flyer-selection.service.ts @@ -18,12 +18,14 @@ import { CandidateSelection, FlyerSelectionMatcherService, } from './flyer-selection-matcher.service'; +import { ShoppingListService } from '../shopping-list/shopping-list.service'; @Injectable() export class FlyerSelectionService { constructor( private readonly prisma: PrismaService, private readonly matcher: FlyerSelectionMatcherService, + private readonly shoppingListService: ShoppingListService, ) {} async listBySession(sessionId: number, userId: number): Promise { @@ -295,6 +297,15 @@ export class FlyerSelectionService { return rows.map((row) => this.toResponse(row)); } + async planToShoppingList( + sessionId: number, + userId: number, + itemIds?: number[], + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + await this.assertSessionOwnership(sessionId, userId); + return this.shoppingListService.upsertFromFlyerSelections(sessionId, userId, itemIds); + } + async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise { const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey); const rows = this.matcher.matchRows(dto.items, candidates); diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index b9c74717..9745c66d 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -176,6 +176,12 @@ export class ProductsController { return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId); } + @Post('mine/backfill-categories') + @HttpCode(200) + backfillCategoriesMine(@Request() req: { user: { id: number } }) { + return this.productsService.backfillCategoriesMine(req.user.id); + } + @Roles('admin') @Post('merge') merge(@Body() body: MergeProductsDto) { @@ -267,4 +273,4 @@ export class ProductsController { bulkUpdate(@Body() body: BulkUpdateProductsDto) { return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId }); } -} \ No newline at end of file +} diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 3d81185c..d9c9ebe9 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -664,4 +664,67 @@ export class ProductsService { select: { id: true, categoryId: true }, }); } -} \ No newline at end of file + + async backfillCategoriesMine(userId: number): Promise<{ updated: number; fallbackToOvrigt: number }> { + const [categories, products] = await Promise.all([ + this.categoriesService.findFlattened(), + this.prisma.product.findMany({ + where: { + ownerId: userId, + isActive: true, + categoryId: null, + }, + select: { + id: true, + name: true, + canonicalName: true, + }, + }), + ]); + + if (products.length === 0) { + return { updated: 0, fallbackToOvrigt: 0 }; + } + + const fallback = + categories.find((category) => category.path.toLowerCase().endsWith(' > övrigt')) + ?? categories.find((category) => category.name.toLowerCase() === 'övrigt') + ?? categories[0]; + + let updated = 0; + let fallbackToOvrigt = 0; + + for (const product of products) { + let targetCategoryId = fallback?.id; + let usedFallback = true; + + try { + const suggestion = await this.aiService.suggestCategory( + product.canonicalName ?? product.name, + categories, + ); + if (suggestion?.categoryId) { + targetCategoryId = suggestion.categoryId; + usedFallback = suggestion.usedFallback === true; + } + } catch { + usedFallback = true; + } + + if (!targetCategoryId) { + continue; + } + + await this.prisma.product.update({ + where: { id: product.id }, + data: { categoryId: targetCategoryId }, + }); + updated += 1; + if (usedFallback) { + fallbackToOvrigt += 1; + } + } + + return { updated, fallbackToOvrigt }; + } +} diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 46414ac3..627caf33 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -380,16 +380,24 @@ export class ReceiptImportService { }); productId = created.id; } - } else if (item.productId) { - // Använd befintlig produkt - const product = await tx.product.findUnique({ - where: { id: item.productId }, - }); - if (!product) { - throw new Error(`Produkten med ID ${item.productId} hittades inte.`); - } - productId = product.id; - } else { + } else if (item.productId) { + // Använd befintlig produkt + const product = await tx.product.findUnique({ + where: { id: item.productId }, + }); + if (!product) { + throw new Error(`Produkten med ID ${item.productId} hittades inte.`); + } + + if (item.categoryId != null && product.categoryId !== item.categoryId) { + await tx.product.update({ + where: { id: product.id }, + data: { categoryId: item.categoryId }, + }); + } + + productId = product.id; + } else { throw new Error('Antingen productId eller createProductName måste anges.'); } diff --git a/backend/src/shopping-list/dto/shopping-list-item.response.ts b/backend/src/shopping-list/dto/shopping-list-item.response.ts new file mode 100644 index 00000000..159e2f0f --- /dev/null +++ b/backend/src/shopping-list/dto/shopping-list-item.response.ts @@ -0,0 +1,14 @@ +export type ShoppingListItemResponse = { + id: number; + userId: number; + name: string; + productId: number | null; + categoryId: number | null; + quantity: number | null; + unit: string | null; + source: string; + status: string; + checkedAt: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts b/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts new file mode 100644 index 00000000..62291aa0 --- /dev/null +++ b/backend/src/shopping-list/dto/update-shopping-list-item-status.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateShoppingListItemStatusDto { + @IsBoolean() + checked!: boolean; +} diff --git a/backend/src/shopping-list/shopping-list.controller.ts b/backend/src/shopping-list/shopping-list.controller.ts new file mode 100644 index 00000000..8563adac --- /dev/null +++ b/backend/src/shopping-list/shopping-list.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + Patch, + Request, + UnauthorizedException, + Body, +} from '@nestjs/common'; +import { ShoppingListService } from './shopping-list.service'; +import { UpdateShoppingListItemStatusDto } from './dto/update-shopping-list-item-status.dto'; +import { ShoppingListItemResponse } from './dto/shopping-list-item.response'; + +@Controller('shopping-list') +export class ShoppingListController { + constructor(private readonly shoppingListService: ShoppingListService) {} + + @Get('items') + async listOpen(@Request() req?: any): Promise { + const userId = this.getUserId(req); + return this.shoppingListService.listOpen(userId); + } + + @Patch('items/:itemId/status') + async updateStatus( + @Param('itemId', ParseIntPipe) itemId: number, + @Body() dto: UpdateShoppingListItemStatusDto, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.shoppingListService.updateCheckedStatus(userId, itemId, dto.checked); + } + + private getUserId(req?: any): number { + const userId = + typeof req?.user?.id === 'number' + ? req.user.id + : typeof req?.user?.userId === 'number' + ? req.user.userId + : undefined; + + if (!userId) { + throw new UnauthorizedException('Kunde inte identifiera användaren.'); + } + + return userId; + } +} diff --git a/backend/src/shopping-list/shopping-list.module.ts b/backend/src/shopping-list/shopping-list.module.ts new file mode 100644 index 00000000..8fbcd1ea --- /dev/null +++ b/backend/src/shopping-list/shopping-list.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ShoppingListController } from './shopping-list.controller'; +import { ShoppingListService } from './shopping-list.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ShoppingListController], + providers: [ShoppingListService], + exports: [ShoppingListService], +}) +export class ShoppingListModule {} diff --git a/backend/src/shopping-list/shopping-list.service.spec.ts b/backend/src/shopping-list/shopping-list.service.spec.ts new file mode 100644 index 00000000..81c9d071 --- /dev/null +++ b/backend/src/shopping-list/shopping-list.service.spec.ts @@ -0,0 +1,81 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { ShoppingListService } from '../shopping-list/shopping-list.service'; + +describe('ShoppingListService', () => { + const prismaMock = { + shoppingListItem: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + flyerSelection: { + findMany: jest.fn(), + }, + $transaction: jest.fn(), + }; + + const createService = () => new ShoppingListService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws when updating another users shopping item', async () => { + prismaMock.shoppingListItem.findUnique.mockResolvedValue({ id: 1, userId: 99 }); + const service = createService(); + + await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('throws when shopping item is missing', async () => { + prismaMock.shoppingListItem.findUnique.mockResolvedValue(null); + const service = createService(); + + await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('deduplicates by productId+unit when planning from flyer selections', async () => { + prismaMock.flyerSelection.findMany.mockResolvedValue([ + { + id: 10, + plannedQuantity: new Prisma.Decimal(1), + plannedUnit: 'kg', + item: { + id: 100, + rawName: 'Tomat', + matchedProductId: 7, + categoryId: 22, + priceUnit: 'kg', + }, + }, + { + id: 11, + plannedQuantity: new Prisma.Decimal(2), + plannedUnit: 'kg', + item: { + id: 101, + rawName: 'Tomat', + matchedProductId: 7, + categoryId: 22, + priceUnit: 'kg', + }, + }, + ]); + + prismaMock.$transaction.mockImplementation(async (cb: any) => cb(prismaMock)); + prismaMock.shoppingListItem.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 999 }); + + const service = createService(); + const result = await service.upsertFromFlyerSelections(1, 1, [100, 101]); + + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + expect(prismaMock.shoppingListItem.create).toHaveBeenCalledTimes(1); + expect(prismaMock.shoppingListItem.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/shopping-list/shopping-list.service.ts b/backend/src/shopping-list/shopping-list.service.ts new file mode 100644 index 00000000..8b120cc1 --- /dev/null +++ b/backend/src/shopping-list/shopping-list.service.ts @@ -0,0 +1,162 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { ShoppingListItemResponse } from './dto/shopping-list-item.response'; + +@Injectable() +export class ShoppingListService { + constructor(private readonly prisma: PrismaService) {} + + async listOpen(userId: number): Promise { + const rows = await this.prisma.shoppingListItem.findMany({ + where: { userId, status: 'open' }, + orderBy: [{ createdAt: 'desc' }], + }); + return rows.map((row) => this.toResponse(row)); + } + + async updateCheckedStatus( + userId: number, + itemId: number, + checked: boolean, + ): Promise { + const existing = await this.prisma.shoppingListItem.findUnique({ where: { id: itemId } }); + if (!existing) { + throw new NotFoundException('Inköpsrad hittades inte.'); + } + if (existing.userId !== userId) { + throw new ForbiddenException('Du saknar åtkomst till denna inköpsrad.'); + } + + const updated = await this.prisma.shoppingListItem.update({ + where: { id: itemId }, + data: { + status: checked ? 'checked' : 'open', + checkedAt: checked ? new Date() : null, + }, + }); + + return this.toResponse(updated); + } + + async upsertFromFlyerSelections( + sessionId: number, + userId: number, + itemIds?: number[], + ): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> { + const selections = await this.prisma.flyerSelection.findMany({ + where: { + sessionId, + userId, + status: 'planned', + ...(itemIds && itemIds.length > 0 ? { itemId: { in: itemIds } } : {}), + }, + include: { + item: { + select: { + id: true, + rawName: true, + categoryId: true, + matchedProductId: true, + priceUnit: true, + }, + }, + }, + }); + + if (selections.length === 0) { + return { created: 0, updated: 0, processedSelectionIds: [] }; + } + + let created = 0; + let updated = 0; + + await this.prisma.$transaction(async (tx) => { + for (const selection of selections) { + const quantity = selection.plannedQuantity ?? new Prisma.Decimal(1); + const unit = selection.plannedUnit ?? selection.item.priceUnit ?? 'st'; + const normalizedUnit = unit.trim() || 'st'; + const productId = selection.item.matchedProductId ?? null; + + let existing: { id: number } | null = null; + if (productId != null) { + existing = await tx.shoppingListItem.findFirst({ + where: { + userId, + status: 'open', + productId, + unit: normalizedUnit, + }, + select: { id: true }, + }); + } + + if (existing) { + await tx.shoppingListItem.update({ + where: { id: existing.id }, + data: { + quantity: { + increment: quantity, + }, + name: selection.item.rawName, + categoryId: selection.item.categoryId ?? undefined, + source: 'flyer', + }, + }); + updated += 1; + continue; + } + + await tx.shoppingListItem.create({ + data: { + userId, + name: selection.item.rawName, + productId, + categoryId: selection.item.categoryId, + quantity, + unit: normalizedUnit, + source: 'flyer', + status: 'open', + }, + }); + created += 1; + } + }); + + return { + created, + updated, + processedSelectionIds: selections.map((selection) => selection.id), + }; + } + + private toResponse(row: { + id: number; + userId: number; + name: string; + productId: number | null; + categoryId: number | null; + quantity: Prisma.Decimal | null; + unit: string | null; + source: string; + status: string; + checkedAt: Date | null; + createdAt: Date; + updatedAt: Date; + }): ShoppingListItemResponse { + return { + id: row.id, + userId: row.userId, + name: row.name, + productId: row.productId, + categoryId: row.categoryId, + quantity: row.quantity == null ? null : Number(row.quantity), + unit: row.unit, + source: row.source, + status: row.status, + checkedAt: row.checkedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 01e71aa1..29167d2d 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -2,7 +2,7 @@ class AuthApiPaths { static const login = '/auth/login'; } -class ProductApiPaths { +class ProductApiPaths { static const list = '/products'; static const mine = '/products/mine'; static const createPrivate = '/products/private'; @@ -13,7 +13,8 @@ class ProductApiPaths { static const deleted = '/products/deleted'; static const merge = '/products/merge'; static const mergePrivate = '/products/private/merge'; - static String updateMineCategory(int id) => '/products/mine/$id/category'; + static String updateMineCategory(int id) => '/products/mine/$id/category'; + static const backfillMineCategories = '/products/mine/backfill-categories'; static String mergePreview(int sourceProductId, int targetProductId) => '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; @@ -42,13 +43,22 @@ class FlyerImportApiPaths { static const parse = '/flyer-import/parse'; static const latestSession = '/flyer-import/sessions/latest'; static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId'; + static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source'; + static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId'; } class FlyerSelectionApiPaths { static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections'; static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk'; + static String planToShoppingListBySession(int sessionId) => + '/flyer-sessions/$sessionId/selections/plan-to-shopping-list'; static const open = '/flyer-selections/open'; } + +class ShoppingListApiPaths { + static const items = '/shopping-list/items'; + static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status'; +} class HelpTextApiPaths { static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}'; diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 14d676d8..8448d8ce 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -21,17 +21,19 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; import '../../features/meal_plan/presentation/meal_plan_screen.dart'; import '../../features/pantry/presentation/pantry_screen.dart'; -import '../../features/import/presentation/import_screen.dart'; -import '../../features/admin/presentation/admin_screen.dart'; +import '../../features/import/presentation/import_screen.dart'; +import '../../features/shopping_list/presentation/shopping_list_screen.dart'; +import '../../features/admin/presentation/admin_screen.dart'; int? _shellBranchIndexForPath(String path) { if (path.startsWith('/recipes')) return 0; if (path.startsWith('/inventory')) return 1; if (path.startsWith('/matsedel')) return 2; if (path.startsWith('/baslager')) return 3; - if (path.startsWith('/import')) return 4; - if (path.startsWith('/profile')) return 5; - if (path.startsWith('/admin')) return 6; + if (path.startsWith('/import')) return 4; + if (path.startsWith('/inkopslista')) return 5; + if (path.startsWith('/profile')) return 6; + if (path.startsWith('/admin')) return 7; return null; } @@ -242,18 +244,26 @@ final appRouterProvider = Provider((ref) { ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/import', - builder: (context, state) => const ImportScreen(), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/profile', + StatefulShellBranch( + routes: [ + GoRoute( + path: '/import', + builder: (context, state) => const ImportScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/inkopslista', + builder: (context, state) => const ShoppingListScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', builder: (context, state) => const ProfileScreen(), ), ], diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index c942d13c..72b2e6d8 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -49,13 +49,19 @@ class AppShell extends ConsumerWidget { icon: Icons.storefront_outlined, label: 'Baslager', ), - _AppDestination( - path: '/import', - title: 'Importera', - icon: Icons.upload_file_outlined, - label: 'Importera', - ), - ]; + _AppDestination( + path: '/import', + title: 'Importera', + icon: Icons.upload_file_outlined, + label: 'Importera', + ), + _AppDestination( + path: '/inkopslista', + title: 'Inköpslista', + icon: Icons.shopping_cart_outlined, + label: 'Inköpslista', + ), + ]; List<_AppDestination> _destinations() => _baseDestinations; diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index 36e9bf4d..f39196d5 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -7,6 +7,7 @@ import 'dart:developer' as developer; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_exception.dart'; +import '../domain/flyer_import_item.dart'; import '../domain/flyer_import_result.dart'; import '../domain/help_text_content.dart'; import '../domain/quick_import_result.dart'; @@ -282,6 +283,95 @@ class ImportRepository { return parsed.cast>(); } + Future> planFlyerSelectionsToShoppingList({ + required int sessionId, + required List itemIds, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.planToShoppingListBySession(sessionId)}'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'itemIds': itemIds}), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte planera till inköpslista: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) return const {}; + return parsed; + } + + Future updateFlyerSessionItem({ + required int sessionId, + required int itemId, + required String rawName, + required int? categoryId, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.patchItem(sessionId, itemId)}'); + final response = await _client.patch( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'rawName': rawName, + 'categoryId': categoryId, + }), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte uppdatera flyer-rad: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid uppdatering av flyer-rad.', + ); + } + + return FlyerImportItem.fromJson(parsed); + } + + Future getFlyerSourceBytes({ + required int sessionId, + String? token, + }) async { + final response = await _client.get( + Uri.parse('$_baseUrl${FlyerImportApiPaths.sourceBySession(sessionId)}'), + headers: { + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta flyerkälla: ${response.body}', + statusCode: response.statusCode, + ); + } + + return response.bodyBytes; + } + /// Upload a file (PDF or image) for recipe extraction. /// /// [bytes] — raw file bytes from file_picker. diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index c1e37cc1..72c4b602 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -2,7 +2,8 @@ class FlyerImportItem { final int? flyerItemId; final String rawName; final String normalizedName; - final String? category; + final String? category; + final int? categoryId; final double? price; final String? priceUnit; final String? offerText; @@ -21,7 +22,8 @@ class FlyerImportItem { required this.flyerItemId, required this.rawName, required this.normalizedName, - this.category, + this.category, + this.categoryId, this.price, this.priceUnit, this.offerText, @@ -42,7 +44,8 @@ class FlyerImportItem { flyerItemId: (json['flyerItemId'] as num?)?.toInt(), rawName: json['rawName'] as String? ?? '', normalizedName: json['normalizedName'] as String? ?? '', - category: json['category'] as String?, + category: json['category'] as String?, + categoryId: (json['categoryId'] as num?)?.toInt(), price: (json['price'] as num?)?.toDouble(), priceUnit: json['priceUnit'] as String?, offerText: json['offerText'] as String?, @@ -65,6 +68,7 @@ class FlyerImportItem { 'rawName': rawName, 'normalizedName': normalizedName, 'category': category, + 'categoryId': categoryId, 'price': price, 'priceUnit': priceUnit, 'offerText': offerText, @@ -80,4 +84,31 @@ class FlyerImportItem { 'matchConfidence': matchConfidence, }; } + + FlyerImportItem copyWith({ + String? rawName, + String? category, + int? categoryId, + }) { + return FlyerImportItem( + flyerItemId: flyerItemId, + rawName: rawName ?? this.rawName, + normalizedName: normalizedName, + category: category ?? this.category, + categoryId: categoryId ?? this.categoryId, + price: price, + priceUnit: priceUnit, + offerText: offerText, + isOffer: isOffer, + offerLimitText: offerLimitText, + comparisonPrice: comparisonPrice, + comparisonUnit: comparisonUnit, + parseConfidence: parseConfidence, + parseReasons: parseReasons, + matchedProductId: matchedProductId, + matchedProductName: matchedProductName, + matchedVia: matchedVia, + matchConfidence: matchConfidence, + ); + } } diff --git a/flutter/lib/features/import/domain/flyer_import_result.dart b/flutter/lib/features/import/domain/flyer_import_result.dart index a68db965..c13191c2 100644 --- a/flutter/lib/features/import/domain/flyer_import_result.dart +++ b/flutter/lib/features/import/domain/flyer_import_result.dart @@ -4,11 +4,19 @@ class FlyerImportResult { final int? sessionId; final List items; final List warnings; + final bool sourceAvailable; + final String? sourceFileName; + final String? sourceMimeType; + final int? sourceFileSize; FlyerImportResult({ required this.sessionId, required this.items, required this.warnings, + required this.sourceAvailable, + this.sourceFileName, + this.sourceMimeType, + this.sourceFileSize, }); factory FlyerImportResult.fromJson(Map json) { @@ -24,6 +32,10 @@ class FlyerImportResult { .map((item) => FlyerImportItem.fromJson(item as Map)) .toList(), warnings: warnings, + sourceAvailable: json['sourceAvailable'] == true, + sourceFileName: json['sourceFileName'] as String?, + sourceMimeType: json['sourceMimeType'] as String?, + sourceFileSize: (json['sourceFileSize'] as num?)?.toInt(), ); } @@ -32,6 +44,10 @@ class FlyerImportResult { 'sessionId': sessionId, 'items': items.map((item) => item.toJson()).toList(), 'warnings': warnings, + 'sourceAvailable': sourceAvailable, + 'sourceFileName': sourceFileName, + 'sourceMimeType': sourceMimeType, + 'sourceFileSize': sourceFileSize, }; } } diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 234bf4e0..f834628f 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -1,9 +1,16 @@ import 'package:file_picker/file_picker.dart'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/ui/category_then_product_picker.dart'; +import '../../admin/domain/admin_category_node.dart'; import '../../../core/utils/pdf_opener.dart'; import '../../auth/data/auth_providers.dart'; +import '../../shopping_list/data/shopping_list_providers.dart'; import '../data/flyer_import_session.dart'; import '../data/import_providers.dart'; import '../domain/flyer_import_item.dart'; @@ -21,15 +28,38 @@ class _FlyerImportTabState extends ConsumerState { bool _isLoading = false; bool _isSaving = false; PlatformFile? _pickedFile; + Uint8List? _restoredSourceBytes; + List _categoryTree = const []; FlyerImportResult? _result; final Map _selected = {}; @override void initState() { super.initState(); + _loadCategoryTree(); _restoreSession(); } + Future _loadCategoryTree() async { + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final categoryData = await api.getJson(CategoryApiPaths.tree, token: token); + final categoryList = categoryData is List + ? categoryData + : (categoryData is Map && categoryData['items'] is List) + ? categoryData['items'] as List + : const []; + final tree = categoryList + .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) + .toList(); + if (!mounted) return; + setState(() => _categoryTree = tree); + } catch (_) { + // Kategoriträdet är valfritt för att visa listan. + } + } + Future _restoreSession() async { final notifier = ref.read(flyerImportSessionProvider.notifier); await notifier.restore(); @@ -53,10 +83,12 @@ class _FlyerImportTabState extends ConsumerState { : session.selected; setState(() { _result = serverResult; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); }); + await _loadRestoredSourceIfNeeded(serverResult, token); notifier.setImportedResult( result: serverResult, selected: selected, @@ -78,10 +110,12 @@ class _FlyerImportTabState extends ConsumerState { }; setState(() { _result = latest; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); }); + await _loadRestoredSourceIfNeeded(latest, token); notifier.setImportedResult( result: latest, selected: selected, @@ -100,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState { if (!mounted || session?.result == null) return; setState(() { _result = session!.result; + _restoredSourceBytes = null; _selected ..clear() ..addAll(session.selected); @@ -107,6 +142,20 @@ class _FlyerImportTabState extends ConsumerState { } } + Future _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async { + if (result.sessionId == null || result.sourceAvailable != true) return; + if (_pickedFile?.bytes != null) return; + try { + final repo = ref.read(importRepositoryProvider); + final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token); + if (!mounted) return; + setState(() => _restoredSourceBytes = bytes); + } catch (_) { + if (!mounted) return; + setState(() => _restoredSourceBytes = null); + } + } + Future _pickFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, @@ -138,6 +187,7 @@ class _FlyerImportTabState extends ConsumerState { } setState(() { _result = parsed; + _restoredSourceBytes = null; _selected ..clear() ..addAll(selected); @@ -159,10 +209,12 @@ class _FlyerImportTabState extends ConsumerState { if (result?.sessionId == null) return; final itemsToSave = >[]; + final selectedItemIds = []; for (var i = 0; i < result!.items.length; i++) { final item = result.items[i]; final isSelected = _selected[i] == true; if (!isSelected || item.flyerItemId == null) continue; + selectedItemIds.add(item.flyerItemId!); itemsToSave.add({ 'itemId': item.flyerItemId, 'plannedQuantity': 1, @@ -187,9 +239,23 @@ class _FlyerImportTabState extends ConsumerState { items: itemsToSave, token: token, ); + final shopping = await repo.planFlyerSelectionsToShoppingList( + sessionId: result.sessionId!, + itemIds: selectedItemIds, + token: token, + ); if (!mounted) return; + final created = (shopping['created'] as num?)?.toInt() ?? 0; + final updated = (shopping['updated'] as num?)?.toInt() ?? 0; + ref.invalidate(shoppingListItemsProvider); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${saved.length} varor planerade.')), + SnackBar( + content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'), + action: SnackBarAction( + label: 'Öppna', + onPressed: () => context.go('/inkopslista'), + ), + ), ); } catch (e) { if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e'); @@ -198,6 +264,141 @@ class _FlyerImportTabState extends ConsumerState { } } + Future _editItem(int index, FlyerImportItem item) async { + final sessionId = _result?.sessionId; + final itemId = item.flyerItemId; + if (sessionId == null || itemId == null) return; + + final nameController = TextEditingController(text: item.rawName); + int? selectedCategoryId = item.categoryId; + String? selectedCategoryPath = item.category; + + final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setLocalState) { + return AlertDialog( + title: const Text('Redigera rad'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Namn', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Text( + selectedCategoryPath == null || selectedCategoryPath!.isEmpty + ? 'Ingen kategori vald' + : selectedCategoryPath!, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _categoryTree.isEmpty + ? null + : () async { + final selected = await CategoryThenProductPicker.showCategorySheet( + context, + categoryTree: _categoryTree, + preselectedCategoryId: selectedCategoryId, + ); + if (selected == null) return; + setLocalState(() { + selectedCategoryId = selected.id; + selectedCategoryPath = selected.path; + }); + }, + icon: const Icon(Icons.category_outlined), + label: const Text('Välj kategori'), + ), + TextButton( + onPressed: () { + setLocalState(() { + selectedCategoryId = null; + selectedCategoryPath = null; + }); + }, + child: const Text('Rensa'), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(( + name: nameController.text.trim(), + categoryId: selectedCategoryId, + categoryPath: selectedCategoryPath, + )); + }, + child: const Text('Spara'), + ), + ], + ); + }, + ); + }, + ); + + nameController.dispose(); + if (payload == null || payload.name.isEmpty) return; + + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + final updated = await repo.updateFlyerSessionItem( + sessionId: sessionId, + itemId: itemId, + rawName: payload.name, + categoryId: payload.categoryId, + token: token, + ); + + if (!mounted) return; + final result = _result; + if (result == null) return; + final nextItems = [...result.items]; + nextItems[index] = updated; + final nextResult = FlyerImportResult( + sessionId: result.sessionId, + items: nextItems, + warnings: result.warnings, + sourceAvailable: result.sourceAvailable, + sourceFileName: result.sourceFileName, + sourceMimeType: result.sourceMimeType, + sourceFileSize: result.sourceFileSize, + ); + setState(() { + _result = nextResult; + }); + ref.read(flyerImportSessionProvider.notifier).setImportedResult( + result: nextResult, + selected: Map.from(_selected), + fileName: _pickedFile?.name ?? result.sourceFileName, + ); + } catch (e) { + if (!mounted) return; + showErrorDialog(context, 'Kunde inte uppdatera rad: $e'); + } + } + String _formatPrice(double? price, String? unit) { if (price == null) return ''; final raw = price.toStringAsFixed(2).replaceAll('.', ','); @@ -320,10 +521,10 @@ class _FlyerImportTabState extends ConsumerState { Widget _buildFlyerPreview(ThemeData theme) { final file = _pickedFile; - final bytes = file?.bytes; + final bytes = file?.bytes ?? _restoredSourceBytes; if (bytes == null) return const SizedBox.shrink(); - final filename = file?.name ?? ''; + final filename = file?.name ?? _result?.sourceFileName ?? ''; final fallbackExt = filename.contains('.') ? filename.split('.').last : ''; final ext = (file?.extension ?? fallbackExt).toLowerCase(); final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext); @@ -339,7 +540,7 @@ class _FlyerImportTabState extends ConsumerState { color: theme.colorScheme.primary, ), title: const Text('Flyerförhandsvisning'), - subtitle: Text(file?.name ?? ''), + subtitle: Text(filename), trailing: isImage ? null : OutlinedButton.icon( @@ -449,11 +650,17 @@ class _FlyerImportTabState extends ConsumerState { setState(() => _selected[index] = checked); ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked); }, - title: Row( - children: [ - Expanded(child: Text(item.rawName)), - const SizedBox(width: 8), - _buildQualityBadge(item, theme), + title: Row( + children: [ + Expanded(child: Text(item.rawName)), + IconButton( + tooltip: 'Redigera', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => _editItem(index, item), + ), + const SizedBox(width: 8), + _buildQualityBadge(item, theme), const SizedBox(width: 8), _buildOfferBadge(item, theme), ], @@ -461,8 +668,10 @@ class _FlyerImportTabState extends ConsumerState { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (priceText.isNotEmpty) Text('Pris: $priceText'), - if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), + if (priceText.isNotEmpty) Text('Pris: $priceText'), + if ((item.category ?? '').trim().isNotEmpty) + Text('Kategori: ${item.category}'), + if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), if (limitText != null && limitText.isNotEmpty) Text( 'Begränsning: $limitText', diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 3096e3c2..481c9486 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -249,6 +249,7 @@ class _CreateInventoryScreenState setState(() => _saving = true); try { final token = await ref.read(authStateProvider.future); + await _syncSelectedProductCategory(token); await ref .read(pantryRepositoryProvider) .createPantryItem( @@ -527,4 +528,3 @@ class _CreateInventoryScreenState ); } } - diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index e159f039..b0559d00 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/api/api_error_mapper.dart'; -import '../../../core/l10n/l10n.dart'; +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; import '../../../core/utils/display_labels.dart'; -import '../../auth/data/auth_providers.dart'; +import '../../auth/data/auth_providers.dart'; +import '../../pantry/data/pantry_providers.dart'; import '../domain/inventory_item.dart'; import '../data/inventory_providers.dart'; import 'swipeable_inventory_tile.dart'; @@ -18,9 +21,30 @@ class InventoryScreen extends ConsumerStatefulWidget { ConsumerState createState() => _InventoryScreenState(); } -class _InventoryScreenState extends ConsumerState { +class _InventoryScreenState extends ConsumerState { final Set _selectedIds = {}; - static const _sortByDisplayedCategory = 'l1CategoryAsc'; + static const _sortByDisplayedCategory = 'l1CategoryAsc'; + bool _backfillTriggered = false; + + @override + void initState() { + super.initState(); + _triggerCategoryBackfill(); + } + + Future _triggerCategoryBackfill() async { + if (_backfillTriggered) return; + _backfillTriggered = true; + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + await api.postJson(ProductApiPaths.backfillMineCategories, token: token); + ref.invalidate(inventoryProvider); + ref.invalidate(pantryProvider); + } catch (_) { + // Ignorera fel här för att inte blockera vyn. + } + } static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; diff --git a/flutter/lib/features/shopping_list/data/shopping_list_providers.dart b/flutter/lib/features/shopping_list/data/shopping_list_providers.dart new file mode 100644 index 00000000..0ca3aff9 --- /dev/null +++ b/flutter/lib/features/shopping_list/data/shopping_list_providers.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; +import '../../auth/data/auth_providers.dart'; +import '../domain/shopping_list_item.dart'; +import 'shopping_list_repository.dart'; + +final shoppingListRepositoryProvider = Provider((ref) { + return ShoppingListRepository(ref.watch(apiClientProvider)); +}); + +final shoppingListItemsProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall(ref, () { + return ref.read(shoppingListRepositoryProvider).fetchOpenItems(token: token); + }); +}); diff --git a/flutter/lib/features/shopping_list/data/shopping_list_repository.dart b/flutter/lib/features/shopping_list/data/shopping_list_repository.dart new file mode 100644 index 00000000..2fecb2d4 --- /dev/null +++ b/flutter/lib/features/shopping_list/data/shopping_list_repository.dart @@ -0,0 +1,60 @@ +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; +import '../domain/shopping_list_item.dart'; + +class ShoppingListRepository { + final ApiClient _api; + + const ShoppingListRepository(this._api); + + Future> fetchOpenItems({String? token}) async { + try { + final data = await _api.getJson(ShoppingListApiPaths.items, token: token); + if (data is! List) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return data + .map((item) => ShoppingListItem.fromJson(item as Map)) + .toList(); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte hämta inköpslistan.', + ); + } + } + + Future updateStatus({ + required int itemId, + required bool checked, + String? token, + }) async { + try { + final data = await _api.patchJson( + ShoppingListApiPaths.updateStatus(itemId), + body: {'checked': checked}, + token: token, + ); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, + message: 'Ogiltigt svar från servern.', + ); + } + return ShoppingListItem.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, + message: 'Kunde inte uppdatera inköpsrad.', + ); + } + } +} diff --git a/flutter/lib/features/shopping_list/domain/shopping_list_item.dart b/flutter/lib/features/shopping_list/domain/shopping_list_item.dart new file mode 100644 index 00000000..9c302273 --- /dev/null +++ b/flutter/lib/features/shopping_list/domain/shopping_list_item.dart @@ -0,0 +1,40 @@ +class ShoppingListItem { + final int id; + final String name; + final int? productId; + final int? categoryId; + final double? quantity; + final String? unit; + final String status; + + const ShoppingListItem({ + required this.id, + required this.name, + required this.productId, + required this.categoryId, + required this.quantity, + required this.unit, + required this.status, + }); + + factory ShoppingListItem.fromJson(Map json) { + return ShoppingListItem( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + productId: (json['productId'] as num?)?.toInt(), + categoryId: (json['categoryId'] as num?)?.toInt(), + quantity: (json['quantity'] as num?)?.toDouble(), + unit: json['unit'] as String?, + status: (json['status'] ?? 'open').toString(), + ); + } + + String get quantityLabel { + if (quantity == null) return ''; + final text = quantity == quantity!.roundToDouble() + ? quantity!.toStringAsFixed(0) + : quantity!.toStringAsFixed(2).replaceAll('.', ','); + if (unit == null || unit!.trim().isEmpty) return text; + return '$text ${unit!.trim()}'; + } +} diff --git a/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart b/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart new file mode 100644 index 00000000..e3955715 --- /dev/null +++ b/flutter/lib/features/shopping_list/presentation/shopping_list_screen.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/shopping_list_providers.dart'; +import '../domain/shopping_list_item.dart'; + +class ShoppingListScreen extends ConsumerWidget { + const ShoppingListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncItems = ref.watch(shoppingListItemsProvider); + + return asyncItems.when( + loading: () => const LoadingStateView(label: 'Laddar inköpslista...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error, context), + onRetry: () => ref.invalidate(shoppingListItemsProvider), + ), + data: (items) { + if (items.isEmpty) { + return const EmptyStateView( + title: 'Inköpslistan är tom', + description: 'Planerade flyer-varor hamnar här.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = items[index]; + return _ShoppingListTile(item: item); + }, + ); + }, + ); + } +} + +class _ShoppingListTile extends ConsumerStatefulWidget { + final ShoppingListItem item; + + const _ShoppingListTile({required this.item}); + + @override + ConsumerState<_ShoppingListTile> createState() => _ShoppingListTileState(); +} + +class _ShoppingListTileState extends ConsumerState<_ShoppingListTile> { + bool _saving = false; + + Future _checkOff() async { + if (_saving) return; + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + await ref.read(shoppingListRepositoryProvider).updateStatus( + itemId: widget.item.id, + checked: true, + token: token, + ); + ref.invalidate(shoppingListItemsProvider); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + + return Card( + child: ListTile( + leading: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Checkbox( + value: false, + onChanged: (_) => _checkOff(), + ), + title: Text(item.name), + subtitle: item.quantityLabel.isEmpty ? null : Text(item.quantityLabel), + ), + ); + } +} diff --git a/flutter/test/features/import/data/flyer_import_session_test.dart b/flutter/test/features/import/data/flyer_import_session_test.dart index 4b0ca71c..38543b86 100644 --- a/flutter/test/features/import/data/flyer_import_session_test.dart +++ b/flutter/test/features/import/data/flyer_import_session_test.dart @@ -31,6 +31,7 @@ void main() { ), ], warnings: const [], + sourceAvailable: false, ), selected: const {0: true}, fileName: 'flyer.pdf', diff --git a/flutter/test/features/inventory/domain/inventory_item_category_test.dart b/flutter/test/features/inventory/domain/inventory_item_category_test.dart new file mode 100644 index 00000000..3f0bbb7c --- /dev/null +++ b/flutter/test/features/inventory/domain/inventory_item_category_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart'; + +void main() { + group('InventoryItem.l1Category', () { + test('uses first category level from category path', () { + final item = InventoryItem.fromJson({ + 'id': 1, + 'productId': 10, + 'quantity': 1, + 'unit': 'st', + 'product': { + 'name': 'Tomat', + 'categoryRef': { + 'name': 'Tomater', + 'parent': { + 'name': 'Grönsaker', + 'parent': {'name': 'Mat'}, + }, + }, + }, + }); + + expect(item.categoryPath, 'Mat > Grönsaker > Tomater'); + expect(item.l1Category, 'Mat'); + }); + + test('falls back to Övrigt when category path is missing', () { + final item = InventoryItem.fromJson({ + 'id': 1, + 'productId': 10, + 'quantity': 1, + 'unit': 'st', + 'product': {'name': 'Okänd', 'categoryRef': null}, + }); + + expect(item.categoryPath, isNull); + expect(item.l1Category, 'Övrigt'); + }); + }); +}