From 67a75905259341ddf47cfc28393ae17739519d2b Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 21 May 2026 17:33:21 +0200 Subject: [PATCH] feat(ai): add AI trace tracking and admin panel - Add AiTrace model to Prisma schema with relations to User - Implement AiTraceService with CRUD operations for AI traces - Add new admin panel for AI traces with filtering and detail views - Integrate trace persistence in receipt import flow - Add API endpoints for listing and retrieving AI traces - Update Flutter admin UI with new AI tab and navigation - Add new domain models for AI traces and details - Add migration for AiTrace table creation BREAKING CHANGE: None --- .kilo/plans/1779343870465-eager-forest.md | 135 ++++ .../20260521171000_add_ai_trace/migration.sql | 24 + backend/prisma/schema.prisma | 23 + backend/src/ai/ai-trace.service.spec.ts | 93 +++ backend/src/ai/ai-trace.service.ts | 492 +++++++++++++ backend/src/ai/ai.controller.ts | 51 +- backend/src/ai/ai.module.ts | 23 +- .../src/ai/dto/list-ai-traces.query.dto.ts | 37 + .../receipt-import.parse-flow.spec.ts | 36 +- .../receipt-import/receipt-import.service.ts | 184 ++++- filanalys.md | 281 ++++---- flutter/lib/core/api/api_paths.dart | 111 ++- flutter/lib/core/ui/app_shell.dart | 72 +- .../features/admin/data/admin_repository.dart | 61 +- .../features/admin/domain/admin_ai_trace.dart | 107 +++ .../admin/domain/admin_ai_trace_detail.dart | 75 ++ .../admin/presentation/admin_ai_panel.dart | 676 ++++++++++++++---- .../presentation/admin_database_panel.dart | 28 +- .../admin/presentation/admin_screen.dart | 78 +- flutter/test/admin_aliases_panel_test.dart | 169 ++++- .../presentation/admin_ai_panel_test.dart | 230 ++++++ 21 files changed, 2477 insertions(+), 509 deletions(-) create mode 100644 .kilo/plans/1779343870465-eager-forest.md create mode 100644 backend/prisma/migrations/20260521171000_add_ai_trace/migration.sql create mode 100644 backend/src/ai/ai-trace.service.spec.ts create mode 100644 backend/src/ai/ai-trace.service.ts create mode 100644 backend/src/ai/dto/list-ai-traces.query.dto.ts create mode 100644 flutter/lib/features/admin/domain/admin_ai_trace.dart create mode 100644 flutter/lib/features/admin/domain/admin_ai_trace_detail.dart create mode 100644 flutter/test/features/admin/presentation/admin_ai_panel_test.dart diff --git a/.kilo/plans/1779343870465-eager-forest.md b/.kilo/plans/1779343870465-eager-forest.md new file mode 100644 index 00000000..65a56458 --- /dev/null +++ b/.kilo/plans/1779343870465-eager-forest.md @@ -0,0 +1,135 @@ +# Plan: Separat Admin-AI huvudtabb + AI-insyn för Kvitto/Flyer + +## Mål +- Flytta ut `AI` från `Admin > Databas` till en egen huvudtabb i admin (samma nivå som `Users` och `Database`). +- På nya `AI`-fliken visa två underflikar: + - `Kvitto` (AI-funktioner för receipt-import) + - `Flyer` (AI-funktioner för flyer-import) +- Ge admin insyn i: + - Prompt (vad modellen fick) + - Output (vad modellen returnerade) + - Per import/sessionshistorik + +## Nuvarande läge (verifierat) +- Huvudtabbar i admin definieras i: + - `flutter/lib/features/admin/presentation/admin_screen.dart` + - `flutter/lib/core/ui/app_shell.dart` +- `AI` ligger idag som intern databas-tab i: + - `flutter/lib/features/admin/presentation/admin_database_panel.dart` +- Nuvarande `AdminAiPanel` visar bara modellinfo: + - `flutter/lib/features/admin/presentation/admin_ai_panel.dart` +- Flyer har sessions-API och sparar parsat resultat, men ingen API-yta för prompt/output: + - `backend/src/flyer-import/flyer-import.controller.ts` + - `backend/src/flyer-import/dto/flyer-import.response.ts` +- Receipt-import har ingen sessionhistorik i recipe-api och ingen prompt/output i svar: + - `backend/src/receipt-import/receipt-import.controller.ts` + - `backend/src/receipt-import/dto/parsed-receipt-item.dto.ts` + +## UX-förslag (enkelt och snyggt) +- Ny huvudflik `AI` i admin med tydlig struktur: + - Topp: två chips/segmenterade knappar `Kvitto` och `Flyer` + - Innehåll: 2-kolumnslayout på desktop, enkel stack på mobil + - Vänster: lista med senaste importer (tid, användare, fil, status) + - Höger: detaljer för vald import +- Detaljvyn har tre kort: + 1. **Prompt** (monospace, copy-knapp, expand/collapse) + 2. **Model Output** (formatterad JSON, copy-knapp) + 3. **Sammanfattning** (modell, duration, chunk/retry, warnings) +- Filter högst upp: + - Källa: Kvitto/Flyer + - Period: senaste 24h / 7d / 30d + - Endast fel +- Default-beteende: + - Välj senaste import automatiskt + - Visa läs-only data (ingen redigering av prompt i denna iteration) + +## Föreslagen implementation + +### 1) Flutter: ny Admin-huvudtabb AI +1. Utöka enum och query-hantering: + - `AdminViewTab` får `ai` + - query-stöd: `?tab=ai` +2. Uppdatera admin-title chips i `AppShell`: + - Lägg till `AI` bredvid `Användare` och `Databas` +3. Rendra ny panel i `AdminScreen`: + - `AdminAiPanel` blir panel för huvudtabben +4. Ta bort AI från `AdminDatabasePanel` interna tabs + +Berörda filer: +- `flutter/lib/features/admin/presentation/admin_screen.dart` +- `flutter/lib/core/ui/app_shell.dart` +- `flutter/lib/features/admin/presentation/admin_database_panel.dart` + +### 2) Flutter: bygg om `AdminAiPanel` till AI-observability +1. Inför underflikar `Kvitto` / `Flyer` +2. Lägg till vänster lista + höger detalj +3. Lägg till komponenter: + - `PromptCard` (text/copy) + - `OutputJsonCard` (pretty JSON/copy) + - `TraceMetaCard` (modell, tid, status) +4. Lägg till `adminRepository`-metoder för att hämta traces + +Berörda filer: +- `flutter/lib/features/admin/presentation/admin_ai_panel.dart` +- `flutter/lib/features/admin/data/admin_repository.dart` +- `flutter/lib/core/api/api_paths.dart` +- ev. nya domänmodeller under `flutter/lib/features/admin/domain/` + +### 3) Backend: exponera AI trace-data (admin-only) +1. Lägg till admin-endpoints för trace-lista + detalj +2. Returnera prompt/output samt metadata +3. Begränsa åtkomst till admin + +Föreslagen API-yta: +- `GET /ai/traces?source=receipt|flyer&limit=...&cursor=...` +- `GET /ai/traces/:id` + +### 4) Datalagring för prompt/output +För stabil UX behövs persistens, inte bara loggar. + +Föreslagen modell: +- Ny Prisma-tabell `AiTrace` + - `id`, `source` (`receipt`/`flyer`), `userId`, `sessionId?`, `model`, `prompt`, `rawOutput`, `normalizedOutput`, `status`, `error`, `durationMs`, `createdAt` + +Integration: +- Flyer: skapa trace i `AiFlyerParserService` vid varje AI-anrop/chunk (sammanfatta till en sessionsrad eller flera child-rader) +- Receipt: recipe-api behöver trace från importer-service eller egen instrumentation av prompt/output + +## Viktig teknisk avgränsning (receipt) +- I nuvarande repo byggs flyer-prompt i `recipe-api` och är enkel att visa. +- Receipt-AI sker i importer-flöde; prompt/output finns sannolikt inte i recipe-api idag. +- Därför två realistiska steg: + 1. **Steg 1 (snabbt):** full trace-visning för Flyer + modellinfo för Kvitto + 2. **Steg 2:** utöka receipt/importer så prompt/output skickas eller lagras som trace och visas i samma UI + +## Säkerhet +- Endast admin får läsa traces. +- Prompt/output kan innehålla känslig text från uppladdade filer: + - visa som read-only + - möjlighet till maskning av persondata i senare steg + - paginering + kort retention (t.ex. 30 dagar) rekommenderas + +## Testplan +- Flutter widget-test: + - Ny huvudtabb `AI` syns och route `?tab=ai` fungerar + - Underflikar `Kvitto`/`Flyer` växlar korrekt + - Prompt/output renderas och copy-knappar fungerar +- Backend tester: + - admin-only behörighet på trace-endpoints + - lista/detalj svarar korrekt + - trace skapas för flyer parse-flöde +- Regression: + - Admin `Users`/`Database` fungerar oförändrat + +## Genomförandeordning +1. Flytta AI till huvudtabb i Flutter (utan backend-ändring först) +2. Bygg ny AI-panelstruktur med underflikar +3. Inför backend trace-endpoints + Prisma-migration +4. Koppla Flyer trace end-to-end +5. Koppla Receipt trace (beroende på importer-instrumentation) +6. Sluttest + docs + +## Rekommenderat beslut inför implementation +- Implementera i två faser för låg risk: + - Fas A: Ny UX + full Flyer-insyn direkt + - Fas B: Receipt prompt/output när importer-trace är tillgänglig diff --git a/backend/prisma/migrations/20260521171000_add_ai_trace/migration.sql b/backend/prisma/migrations/20260521171000_add_ai_trace/migration.sql new file mode 100644 index 00000000..e941e707 --- /dev/null +++ b/backend/prisma/migrations/20260521171000_add_ai_trace/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE `AiTrace` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `source` VARCHAR(191) NOT NULL, + `userId` INTEGER NULL, + `sessionId` INTEGER NULL, + `model` VARCHAR(191) NULL, + `prompt` LONGTEXT NULL, + `rawOutput` LONGTEXT NULL, + `normalizedOutput` JSON NULL, + `status` VARCHAR(191) NOT NULL, + `error` TEXT NULL, + `durationMs` INTEGER NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `AiTrace_source_createdAt_idx`(`source`, `createdAt`), + INDEX `AiTrace_userId_createdAt_idx`(`userId`, `createdAt`), + INDEX `AiTrace_status_createdAt_idx`(`status`, `createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `AiTrace` ADD CONSTRAINT `AiTrace_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2d98eb2b..b9b601be 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { flyerSessions FlyerSession[] flyerSelections FlyerSelection[] shoppingListItems ShoppingListItem[] + aiTraces AiTrace[] } model Product { @@ -388,3 +389,25 @@ model ShoppingListItem { @@index([productId, unit, status]) @@index([categoryId]) } + +model AiTrace { + id Int @id @default(autoincrement()) + source String + userId Int? + sessionId Int? + model String? + prompt String? @db.LongText + rawOutput String? @db.LongText + normalizedOutput Json? + status String + error String? @db.Text + durationMs Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([source, createdAt]) + @@index([userId, createdAt]) + @@index([status, createdAt]) +} diff --git a/backend/src/ai/ai-trace.service.spec.ts b/backend/src/ai/ai-trace.service.spec.ts new file mode 100644 index 00000000..e3ab2062 --- /dev/null +++ b/backend/src/ai/ai-trace.service.spec.ts @@ -0,0 +1,93 @@ +import { AiTraceService } from './ai-trace.service'; + +describe('AiTraceService receipt masking', () => { + const prismaMock = { + aiTrace: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + flyerSession: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + }; + + const service = new AiTraceService(prismaMock as any); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('masks sensitive data in receipt prompt and rawOutput', async () => { + prismaMock.aiTrace.findFirst.mockResolvedValue({ + id: 42, + source: 'receipt', + status: 'success', + createdAt: new Date('2026-05-21T10:00:00.000Z'), + userId: 7, + sessionId: null, + model: 'importer-receipt-ai', + durationMs: 240, + error: null, + prompt: 'Kund email anna@example.com och telefon 070-123 45 67', + rawOutput: JSON.stringify({ + personnummer: '850101-1234', + email: 'anna@example.com', + nested: { + namn: 'Anna Andersson', + phone: '+46701234567', + }, + }), + normalizedOutput: { + items: [ + { + rawName: 'Mjolk', + customerEmail: 'anna@example.com', + }, + ], + }, + user: { + username: 'admin', + email: 'admin@example.com', + }, + }); + + const result = await service.getTraceById('receipt-42'); + + expect(result.prompt).not.toContain('anna@example.com'); + expect(result.prompt).not.toContain('070-123 45 67'); + expect(result.prompt).toContain('[MASKED]'); + + expect(result.rawOutput).not.toContain('850101-1234'); + expect(result.rawOutput).not.toContain('anna@example.com'); + expect(result.rawOutput).not.toContain('Anna Andersson'); + expect(result.rawOutput).toContain('[MASKED]'); + + expect(result.normalizedOutput).toEqual({ + items: [ + { + rawName: 'Mjolk', + customerEmail: '[MASKED]', + }, + ], + }); + }); + + it('filters flyer list by errors in database query', async () => { + prismaMock.flyerSession.findMany.mockResolvedValue([]); + + await service.listTraces({ + source: 'flyer', + limit: 20, + onlyErrors: true, + }); + + expect(prismaMock.flyerSession.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + items: { none: {} }, + }), + }), + ); + }); +}); diff --git a/backend/src/ai/ai-trace.service.ts b/backend/src/ai/ai-trace.service.ts new file mode 100644 index 00000000..7c168e24 --- /dev/null +++ b/backend/src/ai/ai-trace.service.ts @@ -0,0 +1,492 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type AiTraceSource = 'receipt' | 'flyer'; + +export type AiTraceStatus = 'success' | 'warning' | 'error'; + +const AI_TRACE_MASK_FIELDS = ['personnummer', 'telefon', 'email', 'address', 'namn']; +const SWEDISH_PERSONAL_ID_REGEX = /\b(\d{2})?(\d{6})[-+ ]?(\d{4})\b/g; +const EMAIL_REGEX = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi; +const PHONE_REGEX = /\b(?:\+46|0)\s?\d(?:[\d\s-]{6,}\d)\b/g; + +export type AiTraceListItem = { + id: string; + source: AiTraceSource; + status: AiTraceStatus; + createdAt: string; + userId: number; + userLabel: string; + sessionId: number | null; + fileName: string | null; + model: string | null; + durationMs: number | null; + warningsCount: number; + hasPrompt: boolean; + hasOutput: boolean; + error: string | null; +}; + +export type AiTraceListResponse = { + items: AiTraceListItem[]; + nextCursor: string | null; +}; + +export type AiTraceDetail = { + id: string; + source: AiTraceSource; + status: AiTraceStatus; + createdAt: string; + userId: number; + userLabel: string; + sessionId: number | null; + fileName: string | null; + model: string | null; + durationMs: number | null; + retryCount: number | null; + chunkCount: number | null; + warnings: string[]; + error: string | null; + prompt: string | null; + rawOutput: string | null; + normalizedOutput: Record | null; + summary: Record; +}; + +@Injectable() +export class AiTraceService { + constructor(private readonly prisma: PrismaService) {} + + async listTraces(params: { + source: AiTraceSource; + limit: number; + cursor?: string; + period?: '24h' | '7d' | '30d'; + onlyErrors?: boolean; + }): Promise { + if (params.source === 'receipt') { + return this.listReceiptTraces(params); + } + + const take = Math.max(1, Math.min(params.limit || 20, 100)); + const cursorId = this.parseCursor(params.cursor); + const periodStart = this.periodStart(params.period); + + const sessions = await this.prisma.flyerSession.findMany({ + where: { + ...(periodStart ? { createdAt: { gte: periodStart } } : {}), + ...(cursorId ? { id: { lt: cursorId } } : {}), + ...(params.onlyErrors ? { items: { none: {} } } : {}), + }, + orderBy: { id: 'desc' }, + take: take + 1, + select: { + id: true, + userId: true, + createdAt: true, + sourceFileName: true, + user: { select: { username: true, email: true } }, + items: { + select: { + id: true, + parseReasons: true, + matchReasons: true, + }, + }, + }, + }); + + const hasMore = sessions.length > take; + const page = hasMore ? sessions.slice(0, take) : sessions; + + const items: AiTraceListItem[] = page.map((session) => { + const warningsCount = session.items.reduce((sum, item) => { + const parseWarnings = Array.isArray(item.parseReasons) ? item.parseReasons.length : 0; + const matchWarnings = Array.isArray(item.matchReasons) ? item.matchReasons.length : 0; + return sum + parseWarnings + matchWarnings; + }, 0); + const status = this.statusFromSession(session.items.length, warningsCount); + return { + id: this.flyerTraceId(session.id), + source: 'flyer', + status, + createdAt: session.createdAt.toISOString(), + userId: session.userId, + userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId), + sessionId: session.id, + fileName: session.sourceFileName, + model: 'ministral-8b-2512', + durationMs: null, + warningsCount, + hasPrompt: false, + hasOutput: session.items.length > 0, + error: status === 'error' ? 'Inga produkter kunde extraheras från flyern.' : null, + }; + }); + + return { + items, + nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null, + }; + } + + async getTraceById(id: string): Promise { + const parsed = this.parseTraceId(id); + if (parsed.source === 'receipt') { + return this.getReceiptTraceById(parsed.numericId); + } + + const session = await this.prisma.flyerSession.findUnique({ + where: { id: parsed.numericId }, + select: { + id: true, + userId: true, + createdAt: true, + sourceFileName: true, + sourceMimeType: true, + sourceFileSize: true, + user: { select: { username: true, email: true } }, + items: { + orderBy: { id: 'asc' }, + select: { + id: true, + rawName: true, + normalizedName: true, + brand: true, + categoryHint: true, + categoryId: true, + price: true, + priceUnit: true, + comparisonPrice: true, + comparisonUnit: true, + weight: true, + bundleWeight: true, + isBundle: true, + bundleItems: true, + offerText: true, + parseConfidence: true, + parseReasons: true, + matchedProductId: true, + matchedProductName: true, + matchedVia: true, + matchConfidence: true, + matchReasons: true, + }, + }, + }, + }); + + if (!session) { + throw new NotFoundException('AI-trace hittades inte.'); + } + + const warnings = this.collectWarnings(session.items); + const status = this.statusFromSession(session.items.length, warnings.length); + + const normalizedOutput = { + sessionId: session.id, + source: 'flyer', + sourceFileName: session.sourceFileName, + sourceMimeType: session.sourceMimeType, + sourceFileSize: session.sourceFileSize, + itemCount: session.items.length, + items: session.items.map((item) => ({ + id: item.id, + rawName: item.rawName, + normalizedName: item.normalizedName, + brand: item.brand, + categoryHint: item.categoryHint, + categoryId: item.categoryId, + price: item.price != null ? Number(item.price) : null, + priceUnit: item.priceUnit, + comparisonPrice: item.comparisonPrice != null ? Number(item.comparisonPrice) : null, + comparisonUnit: item.comparisonUnit, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: Array.isArray(item.bundleItems) ? item.bundleItems : [], + offerText: item.offerText, + parseConfidence: item.parseConfidence, + parseReasons: Array.isArray(item.parseReasons) ? item.parseReasons : [], + matchedProductId: item.matchedProductId, + matchedProductName: item.matchedProductName, + matchedVia: item.matchedVia, + matchConfidence: item.matchConfidence, + matchReasons: Array.isArray(item.matchReasons) ? item.matchReasons : [], + })), + warnings, + } as Record; + + return { + id: this.flyerTraceId(session.id), + source: 'flyer', + status, + createdAt: session.createdAt.toISOString(), + userId: session.userId, + userLabel: this.userLabel(session.user?.username, session.user?.email, session.userId), + sessionId: session.id, + fileName: session.sourceFileName, + model: 'ministral-8b-2512', + durationMs: null, + retryCount: null, + chunkCount: null, + warnings, + error: session.items.length === 0 ? 'Inga produkter kunde extraheras från flyern.' : null, + prompt: null, + rawOutput: JSON.stringify(this.maskSensitiveData(normalizedOutput)), + normalizedOutput: this.maskSensitiveData(normalizedOutput), + summary: { + source: 'flyer', + sessionId: session.id, + itemCount: session.items.length, + warningsCount: warnings.length, + promptAvailable: false, + outputAvailable: true, + retentionHintDays: 30, + maskedFields: AI_TRACE_MASK_FIELDS, + }, + }; + } + + private statusFromSession(itemCount: number, warningsCount: number): AiTraceStatus { + if (itemCount <= 0) return 'error'; + if (warningsCount > 0) return 'warning'; + return 'success'; + } + + private maskSensitiveData(data: Record): Record { + const clone = JSON.parse(JSON.stringify(data)) as Record; + return this.maskDeep(clone) as Record; + } + + private maskDeep(value: unknown): unknown { + if (typeof value === 'string') { + return this.maskSensitiveText(value); + } + if (Array.isArray(value)) { + return value.map((entry) => this.maskDeep(entry)); + } + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + const lowerKey = key.toLowerCase(); + if (AI_TRACE_MASK_FIELDS.some((field) => lowerKey.includes(field))) { + out[key] = '[MASKED]'; + continue; + } + out[key] = this.maskDeep(nested); + } + return out; + } + return value; + } + + private maskSensitiveText(value: string): string { + return value + .replace(EMAIL_REGEX, '[MASKED]') + .replace(SWEDISH_PERSONAL_ID_REGEX, '[MASKED]') + .replace(PHONE_REGEX, '[MASKED]'); + } + + private maskRawOutput(rawOutput: string | null | undefined): string | null { + if (typeof rawOutput !== 'string' || rawOutput.trim().length === 0) { + return null; + } + + try { + const parsed = JSON.parse(rawOutput); + if (parsed && typeof parsed === 'object') { + const masked = this.maskDeep(parsed); + return JSON.stringify(masked); + } + if (typeof parsed === 'string') { + return this.maskSensitiveText(parsed); + } + return this.maskSensitiveText(String(parsed)); + } catch { + return this.maskSensitiveText(rawOutput); + } + } + + private parseCursor(cursor?: string): number | null { + if (!cursor) return null; + const value = Number.parseInt(cursor, 10); + return Number.isFinite(value) && value > 0 ? value : null; + } + + private periodStart(period?: '24h' | '7d' | '30d'): Date | null { + if (!period) return null; + const now = Date.now(); + const map: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + }; + const duration = map[period]; + if (!duration) return null; + return new Date(now - duration); + } + + private parseTraceId(id: string): { source: AiTraceSource; numericId: number } { + const trimmed = id.trim(); + if (trimmed.startsWith('flyer-')) { + const value = Number.parseInt(trimmed.replace('flyer-', ''), 10); + if (Number.isFinite(value) && value > 0) { + return { source: 'flyer', numericId: value }; + } + } + if (trimmed.startsWith('receipt-')) { + const value = Number.parseInt(trimmed.replace('receipt-', ''), 10); + if (Number.isFinite(value) && value > 0) { + return { source: 'receipt', numericId: value }; + } + } + throw new NotFoundException('AI-trace hittades inte.'); + } + + private flyerTraceId(sessionId: number): string { + return `flyer-${sessionId}`; + } + + private userLabel(username: string | null | undefined, email: string | null | undefined, userId: number): string { + if (username && username.trim().length > 0) return username.trim(); + if (email && email.trim().length > 0) return email.trim(); + return `user:${userId}`; + } + + private collectWarnings(items: Array<{ parseReasons: unknown; matchReasons: unknown }>): string[] { + const warnings = new Set(); + for (const item of items) { + if (Array.isArray(item.parseReasons)) { + for (const reason of item.parseReasons) { + const text = String(reason ?? '').trim(); + if (text.length > 0) warnings.add(`parse:${text}`); + } + } + if (Array.isArray(item.matchReasons)) { + for (const reason of item.matchReasons) { + const text = String(reason ?? '').trim(); + if (text.length > 0) warnings.add(`match:${text}`); + } + } + } + return Array.from(warnings); + } + + private async listReceiptTraces(params: { + source: AiTraceSource; + limit: number; + cursor?: string; + period?: '24h' | '7d' | '30d'; + onlyErrors?: boolean; + }): Promise { + const take = Math.max(1, Math.min(params.limit || 20, 100)); + const cursorId = this.parseCursor(params.cursor); + const periodStart = this.periodStart(params.period); + + const rows = await this.prisma.aiTrace.findMany({ + where: { + source: 'receipt', + ...(periodStart ? { createdAt: { gte: periodStart } } : {}), + ...(cursorId ? { id: { lt: cursorId } } : {}), + ...(params.onlyErrors ? { status: 'error' } : {}), + }, + orderBy: { id: 'desc' }, + take: take + 1, + select: { + id: true, + source: true, + status: true, + createdAt: true, + userId: true, + sessionId: true, + model: true, + durationMs: true, + error: true, + prompt: true, + rawOutput: true, + user: { select: { username: true, email: true } }, + }, + }); + + const hasMore = rows.length > take; + const page = hasMore ? rows.slice(0, take) : rows; + + return { + items: page.map((row) => ({ + id: `receipt-${row.id}`, + source: 'receipt', + status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success', + createdAt: row.createdAt.toISOString(), + userId: row.userId ?? 0, + userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0), + sessionId: row.sessionId, + fileName: null, + model: row.model, + durationMs: row.durationMs, + warningsCount: 0, + hasPrompt: !!row.prompt, + hasOutput: !!row.rawOutput, + error: row.error, + })), + nextCursor: hasMore ? String(page[page.length - 1]?.id ?? '') : null, + }; + } + + private async getReceiptTraceById(traceId: number): Promise { + const row = await this.prisma.aiTrace.findFirst({ + where: { id: traceId, source: 'receipt' }, + select: { + id: true, + source: true, + status: true, + createdAt: true, + userId: true, + sessionId: true, + model: true, + durationMs: true, + error: true, + prompt: true, + rawOutput: true, + normalizedOutput: true, + user: { select: { username: true, email: true } }, + }, + }); + + if (!row) { + throw new NotFoundException('AI-trace hittades inte.'); + } + + const normalizedOutput = row.normalizedOutput && typeof row.normalizedOutput === 'object' + ? this.maskSensitiveData(row.normalizedOutput as Record) + : null; + + return { + id: `receipt-${row.id}`, + source: 'receipt', + status: row.status === 'error' ? 'error' : row.status === 'warning' ? 'warning' : 'success', + createdAt: row.createdAt.toISOString(), + userId: row.userId ?? 0, + userLabel: this.userLabel(row.user?.username, row.user?.email, row.userId ?? 0), + sessionId: row.sessionId, + fileName: null, + model: row.model, + durationMs: row.durationMs, + retryCount: null, + chunkCount: null, + warnings: [], + error: row.error, + prompt: row.prompt ? this.maskSensitiveText(row.prompt) : null, + rawOutput: this.maskRawOutput(row.rawOutput), + normalizedOutput, + summary: { + source: 'receipt', + traceId: row.id, + promptAvailable: !!row.prompt, + outputAvailable: !!row.rawOutput || normalizedOutput != null, + retentionHintDays: 30, + maskedFields: AI_TRACE_MASK_FIELDS, + }, + }; + } +} diff --git a/backend/src/ai/ai.controller.ts b/backend/src/ai/ai.controller.ts index 5e1c7c02..bd41fc7e 100644 --- a/backend/src/ai/ai.controller.ts +++ b/backend/src/ai/ai.controller.ts @@ -1,6 +1,9 @@ -import { Controller, Get } from '@nestjs/common'; -import { Public } from '../auth/decorators/public.decorator'; -import { AI_CATEGORIZATION_MODEL } from './ai.service'; +import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { AI_CATEGORIZATION_MODEL } from './ai.service'; +import { AiTraceService } from './ai-trace.service'; +import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto'; const RECEIPT_IMPORT_MODEL = 'mistral-small-2603'; @@ -15,10 +18,12 @@ export interface AiModelInfo { } @Controller('ai') -export class AiController { - @Get('models') - @Public() - getModels(): AiModelInfo[] { +export class AiController { + constructor(private readonly aiTraceService: AiTraceService) {} + + @Get('models') + @Public() + getModels(): AiModelInfo[] { return [ { id: 'receipt-pdf', @@ -64,7 +69,31 @@ export class AiController { path: '/admin/products', trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"', access: 'Admin', - }, - ]; - } -} + }, + ]; + } + + @Roles('admin') + @Get('traces') + listTraces(@Query() query: ListAiTracesQueryDto) { + return this.aiTraceService.listTraces({ + source: query.source ?? 'flyer', + limit: query.limit ?? 20, + cursor: query.cursor, + period: query.period, + onlyErrors: query.onlyErrors ?? false, + }); + } + + @Roles('admin') + @Get('traces/:id') + getTraceById(@Param('id') id: string) { + return this.aiTraceService.getTraceById(id); + } + + @Roles('admin') + @Get('receipt/traces/:id') + getReceiptTraceById(@Param('id', ParseIntPipe) id: number) { + return this.aiTraceService.getTraceById(`receipt-${id}`); + } +} diff --git a/backend/src/ai/ai.module.ts b/backend/src/ai/ai.module.ts index b2da299d..b1b968d6 100644 --- a/backend/src/ai/ai.module.ts +++ b/backend/src/ai/ai.module.ts @@ -1,10 +1,13 @@ -import { Module } from '@nestjs/common'; -import { AiService } from './ai.service'; -import { AiController } from './ai.controller'; - -@Module({ - controllers: [AiController], - providers: [AiService], - exports: [AiService], -}) -export class AiModule {} +import { Module } from '@nestjs/common'; +import { AiService } from './ai.service'; +import { AiController } from './ai.controller'; +import { AiTraceService } from './ai-trace.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AiController], + providers: [AiService, AiTraceService], + exports: [AiService], +}) +export class AiModule {} diff --git a/backend/src/ai/dto/list-ai-traces.query.dto.ts b/backend/src/ai/dto/list-ai-traces.query.dto.ts new file mode 100644 index 00000000..3aefbce7 --- /dev/null +++ b/backend/src/ai/dto/list-ai-traces.query.dto.ts @@ -0,0 +1,37 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListAiTracesQueryDto { + @IsOptional() + @IsIn(['receipt', 'flyer']) + source?: 'receipt' | 'flyer'; + + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : value; + }) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @IsIn(['24h', '7d', '30d']) + period?: '24h' | '7d' | '30d'; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'boolean') return value; + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return undefined; + return ['1', 'true', 'yes', 'on'].includes(normalized); + }) + @IsBoolean() + onlyErrors?: boolean; +} diff --git a/backend/src/receipt-import/receipt-import.parse-flow.spec.ts b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts index cae3e6e4..03765159 100644 --- a/backend/src/receipt-import/receipt-import.parse-flow.spec.ts +++ b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts @@ -12,12 +12,13 @@ describe('ReceiptImportService parseReceipt flow', () => { cat(51, 'Godis', 'Glass, godis & snacks > Godis'), ]; - const prismaMock = { - receiptAlias: { findMany: jest.fn() }, - product: { findMany: jest.fn() }, - unitMapping: { findMany: jest.fn() }, - user: { findUnique: jest.fn() }, - }; + const prismaMock = { + aiTrace: { create: jest.fn() }, + receiptAlias: { findMany: jest.fn() }, + product: { findMany: jest.fn() }, + unitMapping: { findMany: jest.fn() }, + user: { findUnique: jest.fn() }, + }; const aiServiceMock = { suggestCategory: jest.fn(), @@ -80,14 +81,21 @@ describe('ReceiptImportService parseReceipt flow', () => { confidence: 'low', }); - jest - .spyOn(service as any, 'parseReceiptViaImporter') - .mockResolvedValue([ - { rawName: 'MIXAD VARA', quantity: 1, unit: 'st' }, - { rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' }, - { rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' }, - { rawName: 'helt okänd vara', quantity: 1, unit: 'st' }, - ]); + jest + .spyOn(service as any, 'parseReceiptViaImporter') + .mockResolvedValue({ + items: [ + { rawName: 'MIXAD VARA', quantity: 1, unit: 'st' }, + { rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' }, + { rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' }, + { rawName: 'helt okänd vara', quantity: 1, unit: 'st' }, + ], + trace: { + prompt: 'test prompt', + rawOutput: '{"items":[]}', + normalizedOutput: { items: [] }, + }, + }); const file = { buffer: Buffer.from('dummy'), diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 627caf33..1eb3a57d 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -19,8 +19,10 @@ import { } from '../common/utils/receipt-alias'; import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service'; -const IMPORTER_SERVICE_URL = - process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; +const IMPORTER_SERVICE_URL = + process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; + +const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai'; const WEAK_DESCRIPTORS = new Set([ 'rokt', @@ -133,21 +135,63 @@ export class ReceiptImportService { private readonly flyerSelectionService: FlyerSelectionService, ) {} - async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { - // Steg 1: Delegera AI-parsning till microservice-importer - const rawItems = await this.parseReceiptViaImporter(file); + async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { + const parseStartedAt = Date.now(); + let parseError: string | null = null; + let tracePrompt: string | null = null; + let traceRawOutput: string | null = null; + let traceNormalizedOutput: Record | null = null; + + // Steg 1: Delegera AI-parsning till microservice-importer + let rawItems: ParsedReceiptItem[]; + try { + const importer = await this.parseReceiptViaImporter(file); + rawItems = importer.items; + tracePrompt = importer.trace.prompt; + traceRawOutput = importer.trace.rawOutput; + traceNormalizedOutput = importer.trace.normalizedOutput; + } catch (err) { + parseError = err instanceof Error ? err.message : String(err); + await this.persistReceiptTrace({ + userId, + model: RECEIPT_IMPORT_MODEL, + prompt: tracePrompt, + rawOutput: traceRawOutput, + normalizedOutput: traceNormalizedOutput, + status: 'error', + error: parseError, + durationMs: Date.now() - parseStartedAt, + }); + throw err; + } // Steg 2 & 3: Unified matching + categorization // Samla context en gång för alla items const context = await this.prepareMatchingContext(userId); // Mappa alla items genom unified matcher - return Promise.all( - rawItems.map((item) => - this.matchAndEnrichReceiptItem(item, context), - ), - ); - } + const parsedItems = await Promise.all( + rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)), + ); + + await this.persistReceiptTrace({ + userId, + model: RECEIPT_IMPORT_MODEL, + prompt: tracePrompt, + rawOutput: traceRawOutput, + normalizedOutput: { + importer: traceNormalizedOutput, + enrichedItems: parsedItems, + }, + status: parsedItems.length == 0 ? 'error' : 'success', + error: parsedItems.length == 0 + ? 'Inga kvittorader kunde tolkas av importer-tjänsten.' + : null, + durationMs: Date.now() - parseStartedAt, + }); + + return parsedItems; + } private async prepareMatchingContext(userId?: number): Promise { const prismaAny = this.prisma as any; @@ -573,7 +617,14 @@ export class ReceiptImportService { return response; } - private async parseReceiptViaImporter(file: Express.Multer.File): Promise { + private async parseReceiptViaImporter(file: Express.Multer.File): Promise<{ + items: ParsedReceiptItem[]; + trace: { + prompt: string | null; + rawOutput: string | null; + normalizedOutput: Record | null; + }; + }> { const form = new FormData(); form.append( 'file', @@ -608,9 +659,112 @@ export class ReceiptImportService { throw new BadRequestException(message); } - const items = (await response.json()) as ParsedReceiptItem[]; - return items.filter((item) => !isIgnoredReceiptName(item.rawName)); - } + const body = (await response.json()) as + | ParsedReceiptItem[] + | { + items?: ParsedReceiptItem[]; + prompt?: unknown; + rawOutput?: unknown; + normalizedOutput?: Record; + }; + + const normalizedItems = this.extractImporterItems(body) + .filter((item) => !isIgnoredReceiptName(item.rawName)); + + return { + items: normalizedItems, + trace: { + prompt: this.extractImporterPrompt(body), + rawOutput: this.extractImporterRawOutput(body), + normalizedOutput: this.extractImporterNormalizedOutput(body), + }, + }; + } + + private extractImporterItems( + body: ParsedReceiptItem[] | { items?: ParsedReceiptItem[] }, + ): ParsedReceiptItem[] { + if (Array.isArray(body)) return body; + if (Array.isArray(body.items)) return body.items; + return []; + } + + private extractImporterPrompt( + body: ParsedReceiptItem[] | { prompt?: unknown }, + ): string | null { + if (Array.isArray(body)) return null; + if (typeof body.prompt !== 'string') return null; + const prompt = body.prompt.trim(); + return prompt && prompt.length > 0 ? prompt : null; + } + + private extractImporterRawOutput( + body: ParsedReceiptItem[] | { rawOutput?: unknown }, + ): string | null { + if (Array.isArray(body)) return JSON.stringify(body); + if (typeof body.rawOutput === 'string' && body.rawOutput.trim().length > 0) { + return body.rawOutput; + } + if (body.rawOutput !== undefined) { + try { + return JSON.stringify(body.rawOutput); + } catch { + return String(body.rawOutput); + } + } + return JSON.stringify(body); + } + + private extractImporterNormalizedOutput( + body: ParsedReceiptItem[] | { normalizedOutput?: Record; items?: ParsedReceiptItem[] }, + ): Record | null { + if (Array.isArray(body)) { + return { items: body }; + } + if (body.normalizedOutput && typeof body.normalizedOutput === 'object') { + return body.normalizedOutput; + } + if (Array.isArray(body.items)) { + return { items: body.items }; + } + return null; + } + + private async persistReceiptTrace(params: { + userId?: number; + model: string; + prompt: string | null; + rawOutput: string | null; + normalizedOutput: Record | null; + status: 'success' | 'error'; + error: string | null; + durationMs: number; + }): Promise { + try { + await this.prisma.aiTrace.create({ + data: { + source: 'receipt', + userId: params.userId, + model: params.model, + prompt: params.prompt, + rawOutput: params.rawOutput, + ...(params.normalizedOutput == null + ? {} + : { + normalizedOutput: + params.normalizedOutput as Prisma.InputJsonValue, + }), + status: params.status, + error: params.error, + durationMs: params.durationMs, + }, + }); + } catch (traceErr) { + this.logger.warn( + `Kunde inte spara receipt AI-trace: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}`, + ); + } + } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // UNIFIED MATCHER: Kombinerar product matching + categorization diff --git a/filanalys.md b/filanalys.md index 2213b551..e8784967 100644 --- a/filanalys.md +++ b/filanalys.md @@ -1,148 +1,133 @@ -Du är en senior utvecklare och säkerhetsexpert. Analysera alla commit-kandidater i detta fullstack-projekt (backend: NestJS + Prisma, frontend: Next.js/Flutter, databas: MariaDB). - -Syfte: -- Detta är en pre-commit quality gate som ska användas innan commit. -- Ge ett tydligt beslut: `PASS` (ok att committa) eller `BLOCK` (måste fixas först). -- Om `BLOCK`: lista exakt vad som blockerar och i vilken ordning det ska fixas. - -Arbetsordning för filurval: -1. Primärt: analysera alla staged filer. -2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked). -3. Exkludera alltid irrelevanta filer: node_modules, .git, build/cache-artifacts, binärfiler, genererade filer som inte ska committas. - -Inled rapporten med en kort Scope-sektion som anger: -- Vilken urvalsregel som användes (staged eller commit-kandidater). -- Exakt vilka filer som analyserades. -- Vilka filer som exkluderades och varför. - -Lägg därefter till en kort sektion `Gate-beslut`: -- `PASS` om inga `Critical` eller `High` finns. -- `BLOCK` om minst en `Critical` eller `High` finns. -- Vid `BLOCK`, ge en kort checklista med konkreta fixar. - -Ge en detaljerad rapport enligt följande struktur: - ---- -### **1. Allmän kodkvalitet** - -- **Läsbarhet/underhållbarhet** (kan blockera om allvarligt): - - Finns det bristande namngivning (variabler, funktioner, klasser)? - - Saknas kommentarer för komplex logik? - - Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)? - - Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)? - ---- -### **1b. Performance-optimeringar** (INFORMATIONAL) - -Dessa rapporteras men blockerar inte commit. Kan adresseras i senare iteration: - -- **Algoritm-effektivitet**: - - Finns det O(n²) eller värre algoritmer som kan vara O(n)? - - Finns onödig kod (död kod, duplicerad logik)? - -- **Resurser**: - - Kan minne eller CPU-användning reduceras (t.ex. undvika djupa kopior, använda streams)? - - Kan loopar eller databaserfrågor (Prisma) optimeras (t.ex. med caching, batch-behandling)? - - Finns N+1-frågor eller ineffektiva `include/select`-mönster? - -**Severity**: `Low` eller `Medium` beroende på påverkan. Blockerar aldrig commit. - ---- -### **2. Säkerhetsanalys** -- **Sårbarheter**: - - Finns det risk för SQL-injection (Prisma), XSS, CSRF, eller insecure deserialization? - - Används osäkra bibliotek (t.ex. föråldrade versioner av `axios`, `lodash`, `express`)? - - Finns det hårdkodade lösenord, API-nycklar eller tokens? - - Saknas input-validering (t.ex. för filupp laddningar, användarinmatning)? - -- **Autentisering/auktorisation**: - - Finns det brister i JWT-hantering (t.ex. svaga algoritmer, saknade `exp`-fält)? - - Används HTTP istället för HTTPS? - - Saknas rate limiting för känsliga endpoints? - -- **Datahantering**: - - Lagras känslig data (t.ex. lösenord) i klartext? - - Finns det loggning av känslig data? - - Används säkra krypteringsmetoder (t.ex. AES-256, bcrypt)? - ---- -### **2b. Backend-specifik kontroll (NestJS + Prisma)** -- **API-kontrakt och validering**: - - Kontrollera DTO-validering (`class-validator`) på indata till controllers. - - Kontrollera att controllers inte accepterar osanerad payload direkt till service/Prisma. - - Kontrollera att felhantering använder korrekta HTTP-statuskoder (inte generiska 500/400 i onödan). - -- **Auktorisation och scope**: - - Kontrollera att user-scope upprätthålls i queries/mutationer (ingen IDOR). - - Kontrollera att admin-endpoints skyddas med rätt guards/roller. - - Kontrollera att privata resurser inte kan nås via andras ID. - -- **Prisma och dataintegritet**: - - Kontrollera att `where`-villkor inkluderar rätt scope (t.ex. `userId`) där det krävs. - - Kontrollera transaction-användning vid multipla skrivoperationer. - - Kontrollera risk för N+1-frågor och föreslå `include/select`-optimering där relevant. - -- **Drift och robusthet**: - - Kontrollera rate limiting/throttling på känsliga endpoints. - - Kontrollera att loggar inte exponerar tokens, lösenord eller fulla stacktraces i produktion. - - Kontrollera timeout/retry-strategi vid anrop till externa tjänster. - ---- -### **3. Sammanfattning** -- **Topp 6 kritiska åtgärder** (prioriterade efter risk/vinst). -- **Uppskattad tid** för att implementera förslagen. -- **Rekommenderade verktyg** för automatiserade kontroller (t.ex. `ESLint`, `Prisma Lint`, `OWASP Dependency-Check`). - ---- -### **Klassificering av fynd (Severity)** - -**BLOCKING** (hindrar commit): -- `Critical`: Säkerhetshål, scope-brister (IDOR), SQL-injection, XSS, eller data-loss risk. -- `High`: Allvarlig korrektness-fel, felaktig autentisering/auktorisation, eller felaktig felhantering som påverkar produktion. - -**INFORMATIONAL** (rapporteras, men blockerar inte): -- `Medium`: Code-quality, läsbarhet, testluckor, eller mindre performance-optimeringar. -- `Low`: Stilfrågor, dokumentation, eller nice-to-have refactor. - -**Regel**: Gate-beslut = `PASS` om inga `Critical` eller `High` finns. `BLOCK` annars. - ---- -### **Regler för analysen** -- Var **specifik**: Ge **kod-exempel** för varje förslag. -- Var **praktisk**: Fokusera på **realistiska förbättringar** som kan implementeras nu. -- Var **kritisk**: Peka ut **allvarliga risker** (t.ex. säkerhetshål) först. -- Använd **severity** per fynd enligt klassificering ovan: `Critical`, `High`, `Medium`, `Low`. -- För varje fynd: ange fil, kort riskbeskrivning, varför det är ett problem, severity, och konkret åtgärd. -- **Separa fynd efter severity**: Listet först alla `Critical`/`High` (blocking), sedan `Medium`/`Low` (informational). -- Om inga allvarliga risker hittas: skriv det explicit och lyft kvarvarande risker/testluckor. -- Ignorera filer som inte är relevanta (t.ex. node_modules, .git, binärfiler). -- Prioritera körbarhet: föreslagna åtgärder ska kunna göras i denna kodbas utan större arkitekturprojekt. -- Undvik generiska råd. Allt ska vara kopplat till faktisk kod i scope. -- När både frontend och backend finns i scope: dela upp fynd per delsystem. -- Om endast backendfiler finns i scope: lägg huvudfokus på sektion **2b** och prioritera säkerhet/scope före stilfrågor. - ---- -### **Outputformat (obligatoriskt)** -1. `Scope` -2. `Gate-beslut` (`PASS` eller `BLOCK`) -3. `1. Allmän kodkvalitet` (blocking issues) -4. `1b. Performance-optimeringar` (informational) -5. `2. Säkerhetsanalys` (blocking issues) -6. `2b. Backend-specifik kontroll` (blocking + informational) -7. `3. Sammanfattning` (topprioriteringar, tidskattning) - -Om inga relevanta filer hittas: -- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree). -- Föreslå nästa konkreta steg (t.ex. stagea filer och kör prompten igen). - ---- -### **Kontext för projektet** -- **Backend**: NestJS + Prisma + MariaDB (Docker-container). -- **Frontend**: Next.js + TypeScript + Flutter (kan förekomma i samma repo). -- **Mål**: Förbereda för produktion, minska teknisk skuld, säkra känslig data. - ---- -### **CI-koppling** -- Denna prompt är främst ett lokalt pre-commit-steg. -- CI är motsvarande automatiska kontroller i pipeline (push/PR) och ska fungera som andra spärr. -- Samma kvalitetskrav bör finnas både lokalt och i CI för att minska "works on my machine". \ No newline at end of file +Du är en senior utvecklare och säkerhetsexpert. Analysera commit-kandidater i detta fullstack-projekt (backend: NestJS + Prisma, frontend: Next.js/Flutter, databas: MariaDB). + +Syfte: +- Detta är en pre-commit quality gate innan commit. +- Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`. +- Vid `BLOCK`: lista exakta blockerare och fixordning. + +--- +## 0. Deterministiska gate-regler (källa till sanning) + +### 0.1 Filurval (delta-first) +1. Primärt: analysera alla staged filer. +2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked). +3. Exkludera alltid: `node_modules`, `.git`, build/cache-artifacts, binärfiler, genererade filer som inte ska committas. +4. Fokusera blockerande bedömning på förändrad kod (delta). Legacy-problem i opåverkade delar rapporteras som teknisk skuld (ej blockerande i denna gate). + +### 0.2 Severity och beslut +- **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection). +- **High**: allvarlig korrektness-/driftsrisk i produktion. +- **Medium/Low**: informativa förbättringar (blockerar inte). + +**Beslutslogik (deterministisk):** +- `BLOCK` om minst 1 `Critical`. +- `BLOCK` om 2 eller fler `High`. +- `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`. +- `PASS` om inga `Critical`/`High`. + +### 0.3 Evidenskrav för blockerande fynd +Varje `Critical`/`High` måste ha: +- `Evidence`: `code`, `test`, eller `runtime`. +- Fil + radreferens. +- Konkreta fixsteg. + +Fynd med endast antagande märks `Needs verification` och får inte ensamt orsaka `BLOCK`, om inte risken är uppenbart kritisk. + +### 0.4 Stop-early-regel (effektivitet) +- Vid första tydliga `Critical`: sätt preliminärt `BLOCK`, identifiera max 3 ytterligare blockerare, avsluta sedan djupanalys. + +### 0.5 Rapportbudget +- Rapportera max 5 informativa fynd (`Medium/Low`), prioriterade efter högst nytta/lägst kostnad. + +--- +## 1. Analysfokus + +### 1.1 Allmän kodkvalitet +- Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet. +- TypeScript/Flutter best practices. +- Kommentarer för icke-obvious logik. + +### 1.2 Performance-optimeringar (informational) +- Algoritmisk effektivitet. +- Onödiga kopior/serialiseringar. +- Databasfrågor, N+1-risk, ineffektiva `include/select`. + +### 1.3 Säkerhetsanalys +- Injection/XSS/CSRF/insecure deserialization. +- Inputvalidering och filuppladdningar. +- Secrets i kod/loggar. +- Känslig data i klartext eller otillräckligt maskerad. + +### 1.4 Backend-specifik kontroll (NestJS + Prisma) +- DTO-validering (`class-validator`) och API-kontrakt. +- Auktorisation/scope (IDOR-skydd, admin-guards). +- Prisma-scope i `where`, transaktioner vid multipla writes. +- Timeout/retry/rate limiting och robust felhantering. + +--- +## 2. Krav på varje fynd +Använd följande mall: +- **Severity**: `Critical|High|Medium|Low` +- **Evidence**: `code|test|runtime|Needs verification` +- **Delsystem**: `backend|frontend|db|infra` +- **Fil**: `` +- **Risk**: kort riskbeskrivning +- **Varför**: varför detta är ett problem +- **Åtgärd**: konkret, realistisk fix +- **Verifiering**: kommando/test för att bekräfta fix + +Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`). + +--- +## 3. Obligatoriskt outputformat +Returnera exakt i denna ordning: + +1. `Scope` + - Urvalsregel: `staged` eller `working-tree` + - Analyserade filer (exakt lista) + - Exkluderade filer (med orsak) + +2. `Gate-beslut` + - `PASS|PASS_WITH_WARNINGS|BLOCK` + - Antal per severity: `Critical`, `High`, `Medium`, `Low` + - Kort motivering + +3. `Blocking Findings (Critical/High)` + - Om inga finns: skriv `Inga blockerande fynd`. + +4. `Informational Findings (Medium/Low)` + - Max 5 fynd. + +5. `Fixplan (vid BLOCK eller PASS_WITH_WARNINGS)` + - Numrerad ordning, konkreta steg. + +6. `Sammanfattning` + - Topp 3 åtgärder efter risk/vinst + - Tidsestimat + - Rekommenderade automatiserade kontroller + +--- +## 4. Konsistenskontroller (måste uppfyllas) +- Om `Gate-beslut=PASS` får inga `Critical/High` listas. +- Om `Gate-beslut=BLOCK` måste `Fixplan` innehålla minst 1 konkret blockerande åtgärd. +- Om `PASS_WITH_WARNINGS` används måste exakt 1 `High` finnas och 0 `Critical`. + +--- +## 5. Fallback: inget att analysera +Om inga relevanta filer hittas: +- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree). +- Ge nästa konkreta steg: + - `git add ` + - `git diff --cached --name-only` + - Kör analysen igen. + +--- +## 6. Kontext för projektet +- Backend: NestJS + Prisma + MariaDB (Docker). +- Frontend: Next.js + TypeScript + Flutter. +- Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data. + +--- +## 7. CI-koppling +- Detta är lokalt pre-commit-steg. +- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader. diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 29167d2d..095552f9 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,14 +13,15 @@ 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 const backfillMineCategories = '/products/mine/backfill-categories'; + 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'; static String update(int id) => '/products/$id'; static String canonicalName(int id) => '/products/$id/canonical-name'; - static String canonicalNamePrivate(int id) => '/products/private/$id/canonical-name'; + static String canonicalNamePrivate(int id) => + '/products/private/$id/canonical-name'; static String remove(int id) => '/products/$id'; static String restore(int id) => '/products/$id/restore'; static const bulkUpdate = '/products/bulk-update'; @@ -28,37 +29,63 @@ class ProductApiPaths { class AiApiPaths { static const models = '/ai/models'; + static String traces({ + required String source, + int? limit, + String? cursor, + String? period, + bool onlyErrors = false, + }) { + final params = {'source': source}; + if (limit != null) params['limit'] = '$limit'; + if (cursor != null && cursor.isNotEmpty) params['cursor'] = cursor; + if (period != null && period.isNotEmpty) params['period'] = period; + if (onlyErrors) params['onlyErrors'] = 'true'; + final query = params.entries + .map((e) => + '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}') + .join('&'); + return '/ai/traces?$query'; + } + + static String traceById(String traceId) => + '/ai/traces/${Uri.encodeComponent(traceId)}'; } class CategoryApiPaths { static const tree = '/categories/tree'; } -class ReceiptImportApiPaths { - static const refreshCategories = '/receipt-import/refresh-categories'; - static const unitMappings = '/receipt-import/unit-mappings'; -} - -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 ReceiptImportApiPaths { + static const refreshCategories = '/receipt-import/refresh-categories'; + static const unitMappings = '/receipt-import/unit-mappings'; +} + +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)}'; @@ -77,7 +104,8 @@ class RecipeApiPaths { static String remove(int id) => '/recipes/$id'; static String setVisibility(int id) => '/recipes/$id/visibility'; static String share(int id) => '/recipes/$id/share'; - static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}'; + static String unshare(int id, String username) => + '/recipes/$id/share/${Uri.encodeComponent(username)}'; static String inventoryPreview(int id) => '/recipes/$id/inventory-preview'; static String analysis(int id) => '/recipes/$id/analysis'; static String rematch(int id) => '/recipes/$id/rematch'; @@ -92,9 +120,11 @@ class InventoryApiPaths { static String update(int id) => '/inventory/$id'; static String remove(int id) => '/inventory/$id'; static String moveToPantry(int id) => '/inventory/$id/move-to-pantry'; - static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry'; + static String moveToPantryAdmin(int id) => + '/inventory/admin/$id/move-to-pantry'; static String consume(int id) => '/inventory/$id/consume'; - static String consumptionHistory(int id) => '/inventory/$id/consumption-history'; + static String consumptionHistory(int id) => + '/inventory/$id/consumption-history'; } class AdminInventoryApiPaths { @@ -105,10 +135,12 @@ class AdminInventoryApiPaths { 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)}') + .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 String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry'; @@ -121,7 +153,8 @@ class PantryApiPaths { static const list = '/pantry'; static String remove(int id) => '/pantry/$id'; static String moveToInventory(int id) => '/pantry/$id/move-to-inventory'; - static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory'; + static String moveToInventoryAdmin(int id) => + '/pantry/admin/$id/move-to-inventory'; static const adminList = '/pantry/admin'; static const adminCreate = '/pantry/admin'; static String adminUpdate(int id) => '/pantry/admin/$id'; @@ -143,14 +176,14 @@ class MealPlanApiPaths { static const list = '/meal-plan'; static String listByRange(String from, String to) => - '$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + '$list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; static String shoppingList(String from, String to) => - '$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + '$list/shopping-list?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; static String inventoryCompare(String from, String to) => - '$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; + '$list/inventory-compare?from=${Uri.encodeQueryComponent(from)}&to=${Uri.encodeQueryComponent(to)}'; static String removeByDate(String date) => - '$list/${Uri.encodeComponent(date)}'; -} + '$list/${Uri.encodeComponent(date)}'; +} diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 72b2e6d8..01fe23f3 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -49,19 +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: '/inkopslista', - title: 'Inköpslista', - icon: Icons.shopping_cart_outlined, - label: 'Inköpslista', - ), - ]; + _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; @@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget { } } - final isRecipesRoute = location.startsWith('/recipes') && - !location.startsWith('/recipes/'); + final isRecipesRoute = + location.startsWith('/recipes') && !location.startsWith('/recipes/'); final isImportRoute = location == '/import'; final isAdminRoute = location.startsWith('/admin'); final adminTab = AdminViewTabX.fromQuery( @@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget { selected: adminTab == AdminViewTab.database, onSelected: (_) => navigateToAdminTab(AdminViewTab.database), ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text('AI'), + selected: adminTab == AdminViewTab.ai, + onSelected: (_) => navigateToAdminTab(AdminViewTab.ai), + ), ], ), ); @@ -140,7 +146,8 @@ class AppShell extends ConsumerWidget { Widget shell = Scaffold( appBar: AppBar( - title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title), + title: + isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title), bottom: isImportRoute ? const TabBar( tabs: [ @@ -148,17 +155,17 @@ class AppShell extends ConsumerWidget { icon: Icon(Icons.restaurant_menu_outlined), text: 'Recept', ), - Tab( - icon: Icon(Icons.receipt_long_outlined), - text: 'Kvitto', - ), - Tab( - icon: Icon(Icons.local_offer_outlined), - text: 'Flyer', - ), - ], - ) - : null, + Tab( + icon: Icon(Icons.receipt_long_outlined), + text: 'Kvitto', + ), + Tab( + icon: Icon(Icons.local_offer_outlined), + text: 'Flyer', + ), + ], + ) + : null, actions: [ if (isRecipesRoute) Consumer( @@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget { PopupMenuButton( icon: const Icon(Icons.grid_view), tooltip: 'Välj antal kolumner', - onSelected: (columns) => - ref.read(recipesViewProvider.notifier).setColumns(columns), + onSelected: (columns) => ref + .read(recipesViewProvider.notifier) + .setColumns(columns), itemBuilder: (context) => const [ PopupMenuItem(value: 2, child: Text('2 kolumner')), PopupMenuItem(value: 4, child: Text('4 kolumner')), @@ -288,9 +296,9 @@ class AppShell extends ConsumerWidget { ), ); - if (isImportRoute) { - shell = DefaultTabController(length: 3, child: shell); - } + if (isImportRoute) { + shell = DefaultTabController(length: 3, child: shell); + } return shell; } diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 13752572..12337f34 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart'; import '../domain/admin_pantry_item.dart'; import '../domain/admin_inventory_item.dart'; import '../domain/admin_product.dart'; +import '../domain/admin_ai_trace.dart'; +import '../domain/admin_ai_trace_detail.dart'; import '../domain/ai_model_info.dart'; import '../domain/pending_product.dart'; import '../domain/receipt_alias.dart'; @@ -145,7 +147,8 @@ class AdminRepository { (data['data'] as List?) ?? const []; if (raw.isEmpty && data.isNotEmpty) { - debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}'); + debugPrint( + '[AdminRepository] Unexpected API wrapper shape: ${data.keys}'); } } else { raw = const []; @@ -172,7 +175,8 @@ class AdminRepository { Future setRecipeSharing(int userId, {required bool canShareRecipes}) => _patch(UserApiPaths.setRecipeSharing(userId), - body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson); + body: {'canShareRecipes': canShareRecipes}, + parse: UserAdmin.fromJson); Future updateEmail(int userId, String email) => _patchVoid(UserApiPaths.updateEmail(userId), {'email': email}); @@ -194,7 +198,8 @@ class AdminRepository { parse: (d) => UserAdmin.fromJson(d as Map), ); - Future deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId)); + Future deleteUser(int userId) => + _deleteVoid(UserApiPaths.delete(userId)); /// Returns `{ temporaryPassword, to, subject, body }`. Future> resetPassword(int userId) => @@ -203,7 +208,8 @@ class AdminRepository { // ── Produkter ────────────────────────────────────────────────────────────── Future> listProducts() => - _getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); + _getList(ProductApiPaths.list, AdminProduct.fromJson, + requiresAuth: false); @Deprecated('Use listProducts(). Kept for temporary compatibility.') Future> listGlobalProducts() => listProducts(); @@ -249,7 +255,8 @@ class AdminRepository { final list = merged.values.toList(); list.sort( - (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), ); _selectableProductsCache = List.from(list); _selectableProductsCacheAt = now; @@ -263,7 +270,8 @@ class AdminRepository { _getList(ProductApiPaths.pending, PendingProduct.fromJson); Future setProductStatus(int productId, String status) => - _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) { + _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}) + .then((_) { _invalidateSelectableProductsCache(); }); @@ -271,14 +279,16 @@ class AdminRepository { _post( ProductApiPaths.promotePrivate(productId), body: null, - parse: (d) => AdminProduct.fromJson(Map.from(d as Map)), + parse: (d) => + AdminProduct.fromJson(Map.from(d as Map)), ).then((value) { _invalidateSelectableProductsCache(); return value; }); Future setProductCategory(int productId, {required int? categoryId}) => - _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) { + _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}) + .then((_) { _invalidateSelectableProductsCache(); }); @@ -301,7 +311,8 @@ class AdminRepository { _invalidateSelectableProductsCache(); }); - Future updateCanonicalNamePrivate(int productId, String canonicalName) => + Future updateCanonicalNamePrivate( + int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalNamePrivate(productId), {'canonicalName': canonicalName.trim()}, @@ -336,7 +347,8 @@ class AdminRepository { int _parseUpdatedCount(dynamic data) { if (data is! Map) { - debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}'); + debugPrint( + '[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}'); return 0; } final map = Map.from(data); @@ -391,8 +403,7 @@ class AdminRepository { // ── Kategorier ───────────────────────────────────────────────────────────── - Future> listCategoryTree() => - _getList( + Future> listCategoryTree() => _getList( CategoryApiPaths.tree, AdminCategoryNode.fromJson, requiresAuth: false, @@ -404,6 +415,26 @@ class AdminRepository { Future> listAiModels() => _getList(AiApiPaths.models, AiModelInfo.fromJson); + Future listAiTraces({ + required AdminAiTraceSource source, + int limit = 25, + String? cursor, + String? period, + bool onlyErrors = false, + }) => + _getMap( + AiApiPaths.traces( + source: source.apiValue, + limit: limit, + cursor: cursor, + period: period, + onlyErrors: onlyErrors, + ), + ).then(AdminAiTraceListResponse.fromJson); + + Future getAiTraceById(String traceId) => + _getMap(AiApiPaths.traceById(traceId)).then(AdminAiTraceDetail.fromJson); + // ── Kvittoalias (admin/global fallback) ─────────────────────────────────── Future> listReceiptAliases() => @@ -543,7 +574,8 @@ class AdminRepository { if (location != null && location.trim().isNotEmpty) 'location': location.trim(), }, - parse: (d) => AdminPantryItem.fromJson(Map.from(d as Map)), + parse: (d) => + AdminPantryItem.fromJson(Map.from(d as Map)), ); } @@ -582,6 +614,7 @@ class AdminRepository { required int targetInventoryId, }) => _getMap( - AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId), + AdminInventoryApiPaths.mergePreview( + sourceInventoryId, targetInventoryId), ); } diff --git a/flutter/lib/features/admin/domain/admin_ai_trace.dart b/flutter/lib/features/admin/domain/admin_ai_trace.dart new file mode 100644 index 00000000..02173b8e --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_ai_trace.dart @@ -0,0 +1,107 @@ +enum AdminAiTraceSource { receipt, flyer } + +enum AdminAiTraceStatus { success, warning, error } + +extension AdminAiTraceSourceX on AdminAiTraceSource { + String get apiValue => + this == AdminAiTraceSource.receipt ? 'receipt' : 'flyer'; + + String get label => this == AdminAiTraceSource.receipt ? 'Kvitto' : 'Flyer'; + + static AdminAiTraceSource fromApi(String? value) { + if (value == 'receipt') return AdminAiTraceSource.receipt; + return AdminAiTraceSource.flyer; + } +} + +extension AdminAiTraceStatusX on AdminAiTraceStatus { + String get label => switch (this) { + AdminAiTraceStatus.success => 'OK', + AdminAiTraceStatus.warning => 'Varning', + AdminAiTraceStatus.error => 'Fel', + }; + + static AdminAiTraceStatus fromApi(String? value) { + return switch (value) { + 'error' => AdminAiTraceStatus.error, + 'warning' => AdminAiTraceStatus.warning, + _ => AdminAiTraceStatus.success, + }; + } +} + +class AdminAiTraceListItem { + final String id; + final AdminAiTraceSource source; + final AdminAiTraceStatus status; + final DateTime createdAt; + final int userId; + final String userLabel; + final int? sessionId; + final String? fileName; + final String? model; + final int? durationMs; + final int warningsCount; + final bool hasPrompt; + final bool hasOutput; + final String? error; + + const AdminAiTraceListItem({ + required this.id, + required this.source, + required this.status, + required this.createdAt, + required this.userId, + required this.userLabel, + required this.sessionId, + required this.fileName, + required this.model, + required this.durationMs, + required this.warningsCount, + required this.hasPrompt, + required this.hasOutput, + required this.error, + }); + + factory AdminAiTraceListItem.fromJson(Map json) { + return AdminAiTraceListItem( + id: (json['id'] ?? '').toString(), + source: AdminAiTraceSourceX.fromApi(json['source']?.toString()), + status: AdminAiTraceStatusX.fromApi(json['status']?.toString()), + createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ?? + DateTime.fromMillisecondsSinceEpoch(0), + userId: (json['userId'] as num?)?.toInt() ?? 0, + userLabel: (json['userLabel'] ?? '').toString(), + sessionId: (json['sessionId'] as num?)?.toInt(), + fileName: json['fileName']?.toString(), + model: json['model']?.toString(), + durationMs: (json['durationMs'] as num?)?.toInt(), + warningsCount: (json['warningsCount'] as num?)?.toInt() ?? 0, + hasPrompt: json['hasPrompt'] == true, + hasOutput: json['hasOutput'] == true, + error: json['error']?.toString(), + ); + } +} + +class AdminAiTraceListResponse { + final List items; + final String? nextCursor; + + const AdminAiTraceListResponse({ + required this.items, + required this.nextCursor, + }); + + factory AdminAiTraceListResponse.fromJson(Map json) { + final rawItems = (json['items'] as List?) ?? const []; + return AdminAiTraceListResponse( + items: rawItems + .whereType() + .map((entry) => + AdminAiTraceListItem.fromJson(Map.from(entry))) + .toList(), + nextCursor: json['nextCursor']?.toString(), + ); + } +} diff --git a/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart b/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart new file mode 100644 index 00000000..f1c2d148 --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_ai_trace_detail.dart @@ -0,0 +1,75 @@ +import 'admin_ai_trace.dart'; + +class AdminAiTraceDetail { + final String id; + final AdminAiTraceSource source; + final AdminAiTraceStatus status; + final DateTime createdAt; + final int userId; + final String userLabel; + final int? sessionId; + final String? fileName; + final String? model; + final int? durationMs; + final int? retryCount; + final int? chunkCount; + final List warnings; + final String? error; + final String? prompt; + final String? rawOutput; + final Map? normalizedOutput; + final Map summary; + + const AdminAiTraceDetail({ + required this.id, + required this.source, + required this.status, + required this.createdAt, + required this.userId, + required this.userLabel, + required this.sessionId, + required this.fileName, + required this.model, + required this.durationMs, + required this.retryCount, + required this.chunkCount, + required this.warnings, + required this.error, + required this.prompt, + required this.rawOutput, + required this.normalizedOutput, + required this.summary, + }); + + factory AdminAiTraceDetail.fromJson(Map json) { + final warningsRaw = (json['warnings'] as List?) ?? const []; + final normalizedOutputMap = json['normalizedOutput'] is Map + ? Map.from(json['normalizedOutput'] as Map) + : null; + final summaryMap = json['summary'] is Map + ? Map.from(json['summary'] as Map) + : const {}; + + return AdminAiTraceDetail( + id: (json['id'] ?? '').toString(), + source: AdminAiTraceSourceX.fromApi(json['source']?.toString()), + status: AdminAiTraceStatusX.fromApi(json['status']?.toString()), + createdAt: DateTime.tryParse((json['createdAt'] ?? '').toString()) ?? + DateTime.fromMillisecondsSinceEpoch(0), + userId: (json['userId'] as num?)?.toInt() ?? 0, + userLabel: (json['userLabel'] ?? '').toString(), + sessionId: (json['sessionId'] as num?)?.toInt(), + fileName: json['fileName']?.toString(), + model: json['model']?.toString(), + durationMs: (json['durationMs'] as num?)?.toInt(), + retryCount: (json['retryCount'] as num?)?.toInt(), + chunkCount: (json['chunkCount'] as num?)?.toInt(), + warnings: warningsRaw.map((entry) => entry.toString()).toList(), + error: json['error']?.toString(), + prompt: json['prompt']?.toString(), + rawOutput: json['rawOutput']?.toString(), + normalizedOutput: normalizedOutputMap, + summary: summaryMap, + ); + } +} diff --git a/flutter/lib/features/admin/presentation/admin_ai_panel.dart b/flutter/lib/features/admin/presentation/admin_ai_panel.dart index 149c34fe..ab3af3c7 100644 --- a/flutter/lib/features/admin/presentation/admin_ai_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_ai_panel.dart @@ -1,139 +1,537 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../core/api/api_error_mapper.dart'; -import '../../../core/l10n/l10n.dart'; -import '../data/admin_repository.dart'; -import '../domain/ai_model_info.dart'; - -class AdminAiPanel extends ConsumerStatefulWidget { - final bool embedded; - - const AdminAiPanel({super.key, this.embedded = false}); - - @override - ConsumerState createState() => _AdminAiPanelState(); -} - -class _AdminAiPanelState extends ConsumerState { - bool _isLoading = true; - String? _error; - List _models = []; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _isLoading = true; - _error = null; - }); - try { - final models = await ref.read(adminRepositoryProvider).listAiModels(); - if (!mounted) return; - setState(() => _models = models); - } catch (e) { - if (!mounted) return; - setState(() => _error = mapErrorToUserMessage(e, context)); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - Color _chipColor(String value, ColorScheme scheme) { - final lower = value.toLowerCase(); - if (lower.contains('admin')) return scheme.primaryContainer; - if (lower.contains('premium')) return scheme.tertiaryContainer; - return scheme.secondaryContainer; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - if (_isLoading) return const Center(child: CircularProgressIndicator()); - if (_error != null) { - return buildCopyableErrorPanel( - context: context, - message: _error!, - onRetry: _load, - title: 'Kunde inte läsa AI-modeller', - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('AI', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - context.l10n.adminAiDescription, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - const Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Chip(label: Text('Models')), - Chip(label: Text('Access')), - Chip(label: Text('Trigger')), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 12), - if (_models.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Inga AI-modeller hittades.', - style: theme.textTheme.bodyMedium, - ), - ), - ), - ..._models.map( - (model) => Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(model.name, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text(model.description), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Chip(label: Text(model.model)), - Chip( - label: Text(model.access), - backgroundColor: _chipColor(model.access, theme.colorScheme), - ), - Chip(label: Text(model.trigger)), - ], - ), - const SizedBox(height: 8), - Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall), - ], - ), - ), - ), - ), - ], - ); - } -} \ No newline at end of file +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/admin_ai_trace.dart'; +import '../domain/admin_ai_trace_detail.dart'; + +class AdminAiPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminAiPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _AdminAiPanelState(); +} + +class _AdminAiPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + + AdminAiTraceSource _source = AdminAiTraceSource.flyer; + String _period = '7d'; + bool _onlyErrors = false; + + List _items = const []; + String? _nextCursor; + String? _selectedId; + AdminAiTraceDetail? _selected; + bool _isDetailLoading = false; + bool _promptExpanded = false; + String? _cachedOutputTraceId; + String? _cachedOutputPrettyJson; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final response = await ref.read(adminRepositoryProvider).listAiTraces( + source: _source, + limit: 30, + period: _period, + onlyErrors: _onlyErrors, + ); + if (!mounted) return; + final selectedId = + response.items.isEmpty ? null : response.items.first.id; + setState(() { + _items = response.items; + _nextCursor = response.nextCursor; + _selectedId = selectedId; + _selected = null; + _promptExpanded = false; + _cachedOutputTraceId = null; + _cachedOutputPrettyJson = null; + }); + if (selectedId != null) { + await _loadDetail(selectedId); + } + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _loadMore() async { + if (_nextCursor == null || _nextCursor!.isEmpty) return; + try { + final response = await ref.read(adminRepositoryProvider).listAiTraces( + source: _source, + limit: 30, + cursor: _nextCursor, + period: _period, + onlyErrors: _onlyErrors, + ); + if (!mounted) return; + setState(() { + _items = [..._items, ...response.items]; + _nextCursor = response.nextCursor; + }); + } catch (_) { + // Ignore soft pagination failures. + } + } + + Future _loadDetail(String id) async { + setState(() { + _isDetailLoading = true; + _selected = null; + _cachedOutputTraceId = null; + _cachedOutputPrettyJson = null; + }); + try { + final detail = await ref.read(adminRepositoryProvider).getAiTraceById(id); + if (!mounted) return; + setState(() => _selected = detail); + } catch (_) { + if (!mounted) return; + setState(() => _selected = null); + } finally { + if (mounted) { + setState(() => _isDetailLoading = false); + } + } + } + + String _formatDateTime(DateTime value) { + final local = value.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}'; + } + + Future _copyText(String value, String label) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$label kopierad'))); + } + + String _prettyJson(Object? data) { + if (data == null) return '{}'; + return const JsonEncoder.withIndent(' ').convert(data); + } + + Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) { + return switch (status) { + AdminAiTraceStatus.success => Colors.green.shade700, + AdminAiTraceStatus.warning => Colors.orange.shade700, + AdminAiTraceStatus.error => scheme.error, + }; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa AI-spårning', + ); + } + + final content = LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 980; + final listPane = _buildTraceList(); + final detailPane = _buildTraceDetail(); + + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 2, child: listPane), + const SizedBox(width: 12), + Expanded(flex: 3, child: detailPane), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 260, child: listPane), + const SizedBox(height: 12), + Expanded(child: detailPane), + ], + ); + }, + ); + + return Padding( + padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTopFilters(), + const SizedBox(height: 12), + Expanded(child: content), + ], + ), + ); + } + + Widget _buildTopFilters() { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ChoiceChip( + label: const Text('Kvitto'), + selected: _source == AdminAiTraceSource.receipt, + onSelected: (_) { + setState(() => _source = AdminAiTraceSource.receipt); + _load(); + }, + ), + ChoiceChip( + label: const Text('Flyer'), + selected: _source == AdminAiTraceSource.flyer, + onSelected: (_) { + setState(() => _source = AdminAiTraceSource.flyer); + _load(); + }, + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('24h'), + selected: _period == '24h', + onSelected: (_) { + setState(() => _period = '24h'); + _load(); + }, + ), + FilterChip( + label: const Text('7d'), + selected: _period == '7d', + onSelected: (_) { + setState(() => _period = '7d'); + _load(); + }, + ), + FilterChip( + label: const Text('30d'), + selected: _period == '30d', + onSelected: (_) { + setState(() => _period = '30d'); + _load(); + }, + ), + FilterChip( + label: const Text('Endast fel'), + selected: _onlyErrors, + onSelected: (value) { + setState(() => _onlyErrors = value); + _load(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildTraceList() { + final theme = Theme.of(context); + if (_items.isEmpty) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _source == AdminAiTraceSource.receipt + ? 'Receipt trace-data saknas i recipe-api i denna fas.' + : 'Inga importer matchar valda filter.', + style: theme.textTheme.bodyMedium, + ), + ), + ); + } + + return Card( + child: Column( + children: [ + Expanded( + child: ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = _items[index]; + final selected = item.id == _selectedId; + return ListTile( + selected: selected, + onTap: () { + setState(() { + _selectedId = item.id; + _promptExpanded = false; + }); + _loadDetail(item.id); + }, + title: Text(item.fileName ?? item.id), + subtitle: Text( + '${_formatDateTime(item.createdAt)} • ${item.userLabel}'), + trailing: Chip( + label: Text(item.status.label), + labelStyle: TextStyle( + color: _statusColor(item.status, theme.colorScheme)), + ), + ); + }, + ), + ), + if (_nextCursor != null && _nextCursor!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8), + child: OutlinedButton.icon( + onPressed: _loadMore, + icon: const Icon(Icons.expand_more), + label: const Text('Ladda fler'), + ), + ), + ], + ), + ); + } + + Widget _buildTraceDetail() { + if (_isDetailLoading) { + return const Card(child: Center(child: CircularProgressIndicator())); + } + final detail = _selected; + if (detail == null) { + return const Card( + child: Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Välj en import för detaljer.'), + ), + ), + ); + } + + final prompt = detail.prompt; + final outputJson = detail.normalizedOutput ?? + (detail.rawOutput == null + ? const {} + : {'rawOutput': detail.rawOutput}); + final prettyOutput = _prettyOutputFor(detail.id, outputJson); + + return ListView( + children: [ + _TraceMetaCard(detail: detail, formatDateTime: _formatDateTime), + const SizedBox(height: 12), + _PromptCard( + prompt: prompt, + expanded: _promptExpanded, + onToggleExpand: () => + setState(() => _promptExpanded = !_promptExpanded), + onCopy: prompt == null ? null : () => _copyText(prompt, 'Prompt'), + ), + const SizedBox(height: 12), + _OutputJsonCard( + jsonText: prettyOutput, + onCopy: () => _copyText(prettyOutput, 'Output JSON'), + ), + ], + ); + } + + String _prettyOutputFor(String traceId, Object? outputJson) { + if (_cachedOutputTraceId == traceId && _cachedOutputPrettyJson != null) { + return _cachedOutputPrettyJson!; + } + final next = _prettyJson(outputJson); + _cachedOutputTraceId = traceId; + _cachedOutputPrettyJson = next; + return next; + } +} + +class _TraceMetaCard extends StatelessWidget { + final AdminAiTraceDetail detail; + final String Function(DateTime value) formatDateTime; + + const _TraceMetaCard({required this.detail, required this.formatDateTime}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sammanfattning', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text('Källa: ${detail.source.label}'), + Text('Tid: ${formatDateTime(detail.createdAt)}'), + Text('Användare: ${detail.userLabel}'), + Text('Status: ${detail.status.label}'), + Text('Modell: ${detail.model ?? 'okänd'}'), + if (detail.durationMs != null) + Text('Duration: ${detail.durationMs} ms'), + if (detail.chunkCount != null) Text('Chunks: ${detail.chunkCount}'), + if (detail.retryCount != null) + Text('Retries: ${detail.retryCount}'), + if (detail.warnings.isNotEmpty) + Text('Warnings: ${detail.warnings.length}'), + if (detail.error != null && detail.error!.isNotEmpty) + Text('Fel: ${detail.error}', + style: TextStyle(color: theme.colorScheme.error)), + ], + ), + ), + ); + } +} + +class _PromptCard extends StatelessWidget { + final String? prompt; + final bool expanded; + final VoidCallback onToggleExpand; + final VoidCallback? onCopy; + + const _PromptCard({ + required this.prompt, + required this.expanded, + required this.onToggleExpand, + required this.onCopy, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final value = (prompt ?? '').trim(); + final hasPrompt = value.isNotEmpty; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Prompt', style: theme.textTheme.titleMedium)), + IconButton( + tooltip: 'Expandera/kollapsa', + onPressed: hasPrompt ? onToggleExpand : null, + icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more), + ), + IconButton( + tooltip: 'Kopiera', + onPressed: hasPrompt ? onCopy : null, + icon: const Icon(Icons.copy_all), + ), + ], + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + hasPrompt + ? value + : 'Prompt är inte tillgänglig i denna fas för vald källa.', + maxLines: expanded ? null : 10, + overflow: + expanded ? TextOverflow.visible : TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(fontFamily: 'monospace'), + ), + ), + ], + ), + ), + ); + } +} + +class _OutputJsonCard extends StatelessWidget { + final String jsonText; + final VoidCallback onCopy; + + const _OutputJsonCard({required this.jsonText, required this.onCopy}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Model Output', + style: theme.textTheme.titleMedium)), + IconButton( + tooltip: 'Kopiera JSON', + onPressed: onCopy, + icon: const Icon(Icons.copy_all), + ), + ], + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + jsonText, + style: theme.textTheme.bodySmall + ?.copyWith(fontFamily: 'monospace'), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index 12aa8bd9..2cff25e2 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -5,7 +5,6 @@ import 'dart:async'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/realtime/realtime_sync.dart'; -import 'admin_ai_panel.dart'; import 'admin_aliases_panel.dart'; import 'admin_inventory_panel.dart'; import 'admin_pantry_panel.dart'; @@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart'; import 'admin_products_panel.dart'; import '../../profile/data/profile_repository.dart'; -enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai } +enum _DatabaseTab { + inventory, + pantry, + products, + privateProducts, + pending, + aliases +} class _DatabaseTabConfig { final _DatabaseTab tab; @@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState { title: 'Alias', panel: const AdminAliasesPanel(embedded: true), ), - _DatabaseTabConfig( - tab: _DatabaseTab.ai, - title: 'AI', - panel: const AdminAiPanel(embedded: true), - ), ]; Future _refreshCategories() async { @@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState { @override Widget build(BuildContext context) { - final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab); + final currentTab = + _tabConfigs.firstWhere((config) => config.tab == _activeTab); final header = Card( child: Padding( @@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState { child: ChoiceChip( label: Text(config.title), selected: _activeTab == config.tab, - onSelected: (_) => setState(() => _activeTab = config.tab), + onSelected: (_) => + setState(() => _activeTab = config.tab), ), ), ) @@ -157,7 +160,8 @@ class _AdminDatabasePanelState extends ConsumerState { const SizedBox(width: 8), IconButton( tooltip: 'Uppdatera kategorier', - onPressed: _isRefreshingCategories ? null : _refreshCategories, + onPressed: + _isRefreshingCategories ? null : _refreshCategories, icon: _isRefreshingCategories ? const SizedBox( height: 16, @@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState { const SizedBox(height: 12), Expanded( child: KeyedSubtree( - key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'), + key: + ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'), child: currentTab.panel, ), ), @@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState { ); } } - diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index b49f1b84..ecc742e7 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -1,36 +1,42 @@ -import 'package:flutter/material.dart'; -import 'admin_database_panel.dart'; -import 'admin_users_panel.dart'; - -enum AdminViewTab { users, database } - -extension AdminViewTabX on AdminViewTab { - static AdminViewTab fromQuery(String? value) { - return switch (value) { - 'database' => AdminViewTab.database, - _ => AdminViewTab.users, - }; - } - - String get queryValue => this == AdminViewTab.database ? 'database' : 'users'; -} - -class AdminScreen extends StatelessWidget { - final AdminViewTab initialTab; - - const AdminScreen({super.key, this.initialTab = AdminViewTab.users}); - - @override - Widget build(BuildContext context) { - final activePanel = switch (initialTab) { - AdminViewTab.users => const AdminUsersPanel(embedded: true), - AdminViewTab.database => const AdminDatabasePanel(embedded: true), - }; - - return Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: activePanel, - ); - } -} - +import 'package:flutter/material.dart'; +import 'admin_ai_panel.dart'; +import 'admin_database_panel.dart'; +import 'admin_users_panel.dart'; + +enum AdminViewTab { users, database, ai } + +extension AdminViewTabX on AdminViewTab { + static AdminViewTab fromQuery(String? value) { + return switch (value) { + 'database' => AdminViewTab.database, + 'ai' => AdminViewTab.ai, + _ => AdminViewTab.users, + }; + } + + String get queryValue => switch (this) { + AdminViewTab.users => 'users', + AdminViewTab.database => 'database', + AdminViewTab.ai => 'ai', + }; +} + +class AdminScreen extends StatelessWidget { + final AdminViewTab initialTab; + + const AdminScreen({super.key, this.initialTab = AdminViewTab.users}); + + @override + Widget build(BuildContext context) { + final activePanel = switch (initialTab) { + AdminViewTab.users => const AdminUsersPanel(embedded: true), + AdminViewTab.database => const AdminDatabasePanel(embedded: true), + AdminViewTab.ai => const AdminAiPanel(embedded: true), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: activePanel, + ); + } +} diff --git a/flutter/test/admin_aliases_panel_test.dart b/flutter/test/admin_aliases_panel_test.dart index 89ebfb15..1e41e278 100644 --- a/flutter/test/admin_aliases_panel_test.dart +++ b/flutter/test/admin_aliases_panel_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:recipe_flutter/features/admin/data/admin_repository.dart'; import 'package:recipe_flutter/features/admin/domain/admin_ai_categorize_result.dart'; +import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart'; +import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart'; import 'package:recipe_flutter/features/admin/domain/admin_category_node.dart'; import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart'; import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart'; @@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository { TestAdminRepositoryWrapper(this._fakeRepo); @override - Future> listReceiptAliases() => _fakeRepo.listReceiptAliases(); + Future> listReceiptAliases() => + _fakeRepo.listReceiptAliases(); @override - Future> listGlobalProducts() => _fakeRepo.listGlobalProducts(); + Future> listGlobalProducts() => + _fakeRepo.listGlobalProducts(); @override - Future updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) => _fakeRepo.updateReceiptAlias(id, receiptName: receiptName, productId: productId, isGlobal: isGlobal); + Future updateReceiptAlias(int id, + {String? receiptName, int? productId, bool? isGlobal}) => + _fakeRepo.updateReceiptAlias(id, + receiptName: receiptName, productId: productId, isGlobal: isGlobal); // Stub implementations for other required methods @override - Future> aiCategorizeBulk({List? productIds}) => throw UnimplementedError(); + Future> aiCategorizeBulk( + {List? productIds}) => + throw UnimplementedError(); @override - Future bulkSetCategory(List ids, {required int? categoryId}) => throw UnimplementedError(); + Future bulkSetCategory(List ids, {required int? categoryId}) => + throw UnimplementedError(); @override - Future createAdminInventory({int? userId, required int productId, required double quantity, required String unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError(); + Future createAdminInventory( + {int? userId, + required int productId, + required double quantity, + required String unit, + String? location, + String? brand, + String? receiptName, + String? suitableFor, + String? comment}) => + throw UnimplementedError(); @override - Future createAdminPantry({int? userId, required int productId, String? location}) => throw UnimplementedError(); + Future createAdminPantry( + {int? userId, required int productId, String? location}) => + throw UnimplementedError(); @override - Future> createProduct(String name, {int? categoryId}) => throw UnimplementedError(); + Future> createProduct(String name, {int? categoryId}) => + throw UnimplementedError(); @override - Future createUser({required String username, required String email, required String password, String role = 'user'}) => throw UnimplementedError(); + Future createUser( + {required String username, + required String email, + required String password, + String role = 'user'}) => + throw UnimplementedError(); @override Future deleteUser(int userId) => throw UnimplementedError(); @override - Future> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError(); + Future> listAdminInventory( + {int? userId, String? sort}) => + throw UnimplementedError(); @override - Future> listAdminPantry({int? userId}) => throw UnimplementedError(); + Future> listAdminPantry({int? userId}) => + throw UnimplementedError(); @override Future> listAiModels() => throw UnimplementedError(); @override - Future> listCategoryTree() => throw UnimplementedError(); + Future getAiTraceById(String traceId) => + throw UnimplementedError(); @override - Future> listDeletedProducts() => throw UnimplementedError(); + Future listAiTraces( + {required AdminAiTraceSource source, + int limit = 25, + String? cursor, + String? period, + bool onlyErrors = false}) => + throw UnimplementedError(); @override - Future> listPendingProducts() => throw UnimplementedError(); + Future> listCategoryTree() => + throw UnimplementedError(); @override - Future> listPrivateProducts() => throw UnimplementedError(); + Future> listDeletedProducts() => + throw UnimplementedError(); + @override + Future> listPendingProducts() => + throw UnimplementedError(); + @override + Future> listPrivateProducts() => + throw UnimplementedError(); @override Future> listProducts() => throw UnimplementedError(); @override - Future> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError(); + Future> listSelectableProductsForAdmin( + {bool forceRefresh = false}) => + throw UnimplementedError(); @override Future> listUsers() => throw UnimplementedError(); @override - Future mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError(); + Future mergeAdminInventory( + {required int sourceInventoryId, required int targetInventoryId}) => + throw UnimplementedError(); @override - Future mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); + Future mergeProducts( + {required int sourceProductId, required int targetProductId}) => + throw UnimplementedError(); @override - Future mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); + Future mergeProductsPrivate( + {required int sourceProductId, required int targetProductId}) => + throw UnimplementedError(); @override - Future moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError(); + Future moveAdminInventoryToPantry(int inventoryId) => + throw UnimplementedError(); @override - Future moveAdminPantryToInventory(int pantryItemId, Map body) => throw UnimplementedError(); + Future moveAdminPantryToInventory( + int pantryItemId, Map body) => + throw UnimplementedError(); @override - Future> previewAdminInventoryMerge({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError(); + Future> previewAdminInventoryMerge( + {required int sourceInventoryId, required int targetInventoryId}) => + throw UnimplementedError(); @override - Future> previewMerge({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); + Future> previewMerge( + {required int sourceProductId, required int targetProductId}) => + throw UnimplementedError(); @override - Future promotePrivateProduct(int productId) => throw UnimplementedError(); + Future promotePrivateProduct(int productId) => + throw UnimplementedError(); @override - Future removeAdminInventory(int inventoryId) => throw UnimplementedError(); + Future removeAdminInventory(int inventoryId) => + throw UnimplementedError(); @override - Future removeAdminPantryItem(int pantryItemId) => throw UnimplementedError(); + Future removeAdminPantryItem(int pantryItemId) => + throw UnimplementedError(); @override Future removeProduct(int productId) => throw UnimplementedError(); @override Future removeReceiptAlias(int id) => throw UnimplementedError(); @override - Future> resetPassword(int userId) => throw UnimplementedError(); + Future> resetPassword(int userId) => + throw UnimplementedError(); @override Future restoreProduct(int productId) => throw UnimplementedError(); @override - Future setPremium(int userId, {required bool isPremium}) => throw UnimplementedError(); + Future setPremium(int userId, {required bool isPremium}) => + throw UnimplementedError(); @override - Future setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError(); + Future setProductCategory(int productId, {required int? categoryId}) => + throw UnimplementedError(); @override - Future setProductStatus(int productId, String status) => throw UnimplementedError(); + Future setProductStatus(int productId, String status) => + throw UnimplementedError(); @override - Future setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError(); + Future setRecipeSharing(int userId, + {required bool canShareRecipes}) => + throw UnimplementedError(); @override - Future setRole(int userId, String newRole) => throw UnimplementedError(); + Future setRole(int userId, String newRole) => + throw UnimplementedError(); @override - Future updateAdminInventory(int inventoryId, {int? productId, double? quantity, String? unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError(); + Future updateAdminInventory(int inventoryId, + {int? productId, + double? quantity, + String? unit, + String? location, + String? brand, + String? receiptName, + String? suitableFor, + String? comment}) => + throw UnimplementedError(); @override - Future updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError(); + Future updateAdminPantry(int pantryItemId, + {int? productId, String? location}) => + throw UnimplementedError(); @override - Future updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError(); + Future updateCanonicalName(int productId, String canonicalName) => + throw UnimplementedError(); @override - Future updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError(); + Future updateCanonicalNamePrivate( + int productId, String canonicalName) => + throw UnimplementedError(); @override - Future updateEmail(int userId, String email) => throw UnimplementedError(); + Future updateEmail(int userId, String email) => + throw UnimplementedError(); @override - Future upsertReceiptAlias({required String receiptName, required int productId, bool isGlobal = false}) => throw UnimplementedError(); + Future upsertReceiptAlias( + {required String receiptName, + required int productId, + bool isGlobal = false}) => + throw UnimplementedError(); } // Simple fake that only implements the methods we need @@ -128,7 +218,8 @@ class FakeAdminRepository { return _products; } - Future updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) async { + Future updateReceiptAlias(int id, + {String? receiptName, int? productId, bool? isGlobal}) async { // Find and update alias final index = _aliases.indexWhere((a) => a.id == id); if (index >= 0) { diff --git a/flutter/test/features/admin/presentation/admin_ai_panel_test.dart b/flutter/test/features/admin/presentation/admin_ai_panel_test.dart new file mode 100644 index 00000000..a5ff5d9f --- /dev/null +++ b/flutter/test/features/admin/presentation/admin_ai_panel_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/core/ui/app_shell.dart'; +import 'package:recipe_flutter/features/admin/data/admin_repository.dart'; +import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart'; +import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart'; +import 'package:recipe_flutter/features/admin/presentation/admin_ai_panel.dart'; +import 'package:recipe_flutter/features/admin/presentation/admin_screen.dart'; +import 'package:recipe_flutter/features/auth/data/auth_providers.dart'; + +class _FakeAdminRepository implements AdminRepository { + final AdminAiTraceListResponse flyerList; + final AdminAiTraceListResponse receiptList; + final Map details; + + _FakeAdminRepository({ + required this.flyerList, + required this.receiptList, + required this.details, + }); + + @override + Future listAiTraces({ + required AdminAiTraceSource source, + int limit = 25, + String? cursor, + String? period, + bool onlyErrors = false, + }) async { + return source == AdminAiTraceSource.flyer ? flyerList : receiptList; + } + + @override + Future getAiTraceById(String traceId) async { + return details[traceId] ?? details.values.first; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Widget _buildPanelApp(AdminRepository repo) { + return ProviderScope( + overrides: [ + adminRepositoryProvider.overrideWithValue(repo), + ], + child: const MaterialApp( + home: Scaffold( + body: AdminAiPanel(embedded: true), + ), + ), + ); +} + +void main() { + final flyerItem = AdminAiTraceListItem( + id: 'flyer-101', + source: AdminAiTraceSource.flyer, + status: AdminAiTraceStatus.warning, + createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'), + userId: 7, + userLabel: 'admin', + sessionId: 101, + fileName: 'willys-v20.pdf', + model: 'ministral-8b-2512', + durationMs: 1880, + warningsCount: 2, + hasPrompt: true, + hasOutput: true, + error: null, + ); + + final flyerDetail = AdminAiTraceDetail( + id: 'flyer-101', + source: AdminAiTraceSource.flyer, + status: AdminAiTraceStatus.warning, + createdAt: DateTime.parse('2026-05-20T12:34:56.000Z'), + userId: 7, + userLabel: 'admin', + sessionId: 101, + fileName: 'willys-v20.pdf', + model: 'ministral-8b-2512', + durationMs: 1880, + retryCount: 1, + chunkCount: 3, + warnings: const ['parse:low_confidence'], + error: null, + prompt: 'Prompttext exempel', + rawOutput: '{"ok":true}', + normalizedOutput: const { + 'sessionId': 101, + 'items': [ + {'rawName': 'Tomat'} + ], + }, + summary: const {'itemCount': 1}, + ); + + group('Admin AI tab and panel', () { + testWidgets('Admin main route query tab=ai renders AI panel', + (tester) async { + final fakeRepo = _FakeAdminRepository( + flyerList: + AdminAiTraceListResponse(items: [flyerItem], nextCursor: null), + receiptList: + const AdminAiTraceListResponse(items: [], nextCursor: null), + details: {'flyer-101': flyerDetail}, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + adminRepositoryProvider.overrideWithValue(fakeRepo), + ], + child: const MaterialApp( + home: Scaffold( + body: AdminScreen(initialTab: AdminViewTab.ai), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Kvitto'), findsOneWidget); + expect(find.text('Flyer'), findsOneWidget); + expect(find.text('willys-v20.pdf'), findsOneWidget); + }); + + testWidgets('AppShell shows AI top chip and navigates with query', + (tester) async { + String? navigatedTo; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + isAdminProvider.overrideWithValue(true), + ], + child: MaterialApp( + home: AppShell( + location: '/admin?tab=ai', + onNavigateToPath: (path) => navigatedTo = path, + child: const SizedBox.shrink(), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('AI'), findsOneWidget); + await tester.tap(find.text('Databas')); + await tester.pump(); + + expect(navigatedTo, '/admin?tab=database'); + }); + + testWidgets('Source switching toggles between Flyer and Kvitto views', + (tester) async { + final fakeRepo = _FakeAdminRepository( + flyerList: + AdminAiTraceListResponse(items: [flyerItem], nextCursor: null), + receiptList: + const AdminAiTraceListResponse(items: [], nextCursor: null), + details: {'flyer-101': flyerDetail}, + ); + + await tester.pumpWidget(_buildPanelApp(fakeRepo)); + await tester.pumpAndSettle(); + + expect(find.text('willys-v20.pdf'), findsOneWidget); + + await tester.tap(find.text('Kvitto')); + await tester.pumpAndSettle(); + expect(find.text('Receipt trace-data saknas i recipe-api i denna fas.'), + findsOneWidget); + + await tester.tap(find.text('Flyer')); + await tester.pumpAndSettle(); + expect(find.text('willys-v20.pdf'), findsOneWidget); + }); + + testWidgets('Prompt and output render and copy actions show snackbars', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1400, 1200)); + final fakeRepo = _FakeAdminRepository( + flyerList: + AdminAiTraceListResponse(items: [flyerItem], nextCursor: null), + receiptList: + const AdminAiTraceListResponse(items: [], nextCursor: null), + details: {'flyer-101': flyerDetail}, + ); + + await tester.pumpWidget(_buildPanelApp(fakeRepo)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.byType(ListTile).first); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Sammanfattning'), findsOneWidget); + final detailScroll = find.byType(Scrollable).last; + await tester.scrollUntilVisible( + find.text('Model Output'), + 200, + scrollable: detailScroll, + ); + await tester.pumpAndSettle(); + + expect(find.text('Model Output'), findsOneWidget); + expect(find.textContaining('"sessionId": 101'), findsOneWidget); + + final copyPrompt = find.byTooltip('Kopiera'); + final copyOutput = find.byTooltip('Kopiera JSON'); + expect(copyPrompt, findsOneWidget); + expect(copyOutput, findsOneWidget); + + await tester.tap(copyPrompt); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + await tester.tap(copyOutput); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + addTearDown(() => tester.binding.setSurfaceSize(null)); + }); + }); +}