feat(ai): add AI trace tracking and admin panel
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s

- 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
This commit is contained in:
Nils-Johan Gynther
2026-05-21 17:33:21 +02:00
parent c3520b5ad4
commit 67a7590525
21 changed files with 2477 additions and 509 deletions
+135
View File
@@ -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
@@ -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;
+23
View File
@@ -33,6 +33,7 @@ model User {
flyerSessions FlyerSession[] flyerSessions FlyerSession[]
flyerSelections FlyerSelection[] flyerSelections FlyerSelection[]
shoppingListItems ShoppingListItem[] shoppingListItems ShoppingListItem[]
aiTraces AiTrace[]
} }
model Product { model Product {
@@ -388,3 +389,25 @@ model ShoppingListItem {
@@index([productId, unit, status]) @@index([productId, unit, status])
@@index([categoryId]) @@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])
}
+93
View File
@@ -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: {} },
}),
}),
);
});
});
+492
View File
@@ -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<string, unknown> | null;
summary: Record<string, unknown>;
};
@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<AiTraceListResponse> {
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<AiTraceDetail> {
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<string, unknown>;
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<string, unknown>): Record<string, unknown> {
const clone = JSON.parse(JSON.stringify(data)) as Record<string, unknown>;
return this.maskDeep(clone) as Record<string, unknown>;
}
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<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
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<string, number> = {
'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<string>();
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<AiTraceListResponse> {
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<AiTraceDetail> {
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<string, unknown>)
: 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,
},
};
}
}
+40 -11
View File
@@ -1,6 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Public } from '../auth/decorators/public.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { AI_CATEGORIZATION_MODEL } from './ai.service'; 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'; const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
@@ -15,10 +18,12 @@ export interface AiModelInfo {
} }
@Controller('ai') @Controller('ai')
export class AiController { export class AiController {
@Get('models') constructor(private readonly aiTraceService: AiTraceService) {}
@Public()
getModels(): AiModelInfo[] { @Get('models')
@Public()
getModels(): AiModelInfo[] {
return [ return [
{ {
id: 'receipt-pdf', id: 'receipt-pdf',
@@ -64,7 +69,31 @@ export class AiController {
path: '/admin/products', path: '/admin/products',
trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"', trigger: 'Manuell — knappen "✨ AI-kategorisera okategoriserade"',
access: 'Admin', 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}`);
}
}
+13 -10
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
import { AiController } from './ai.controller'; import { AiController } from './ai.controller';
import { AiTraceService } from './ai-trace.service';
@Module({ import { PrismaModule } from '../prisma/prisma.module';
controllers: [AiController],
providers: [AiService], @Module({
exports: [AiService], imports: [PrismaModule],
}) controllers: [AiController],
export class AiModule {} providers: [AiService, AiTraceService],
exports: [AiService],
})
export class AiModule {}
@@ -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;
}
@@ -12,12 +12,13 @@ describe('ReceiptImportService parseReceipt flow', () => {
cat(51, 'Godis', 'Glass, godis & snacks > Godis'), cat(51, 'Godis', 'Glass, godis & snacks > Godis'),
]; ];
const prismaMock = { const prismaMock = {
receiptAlias: { findMany: jest.fn() }, aiTrace: { create: jest.fn() },
product: { findMany: jest.fn() }, receiptAlias: { findMany: jest.fn() },
unitMapping: { findMany: jest.fn() }, product: { findMany: jest.fn() },
user: { findUnique: jest.fn() }, unitMapping: { findMany: jest.fn() },
}; user: { findUnique: jest.fn() },
};
const aiServiceMock = { const aiServiceMock = {
suggestCategory: jest.fn(), suggestCategory: jest.fn(),
@@ -80,14 +81,21 @@ describe('ReceiptImportService parseReceipt flow', () => {
confidence: 'low', confidence: 'low',
}); });
jest jest
.spyOn(service as any, 'parseReceiptViaImporter') .spyOn(service as any, 'parseReceiptViaImporter')
.mockResolvedValue([ .mockResolvedValue({
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' }, items: [
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' }, { rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' }, { rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
{ rawName: 'helt okänd vara', 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 = { const file = {
buffer: Buffer.from('dummy'), buffer: Buffer.from('dummy'),
@@ -19,8 +19,10 @@ import {
} from '../common/utils/receipt-alias'; } from '../common/utils/receipt-alias';
import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service'; import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service';
const IMPORTER_SERVICE_URL = const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai';
const WEAK_DESCRIPTORS = new Set([ const WEAK_DESCRIPTORS = new Set([
'rokt', 'rokt',
@@ -133,21 +135,63 @@ export class ReceiptImportService {
private readonly flyerSelectionService: FlyerSelectionService, private readonly flyerSelectionService: FlyerSelectionService,
) {} ) {}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> { async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer const parseStartedAt = Date.now();
const rawItems = await this.parseReceiptViaImporter(file); let parseError: string | null = null;
let tracePrompt: string | null = null;
let traceRawOutput: string | null = null;
let traceNormalizedOutput: Record<string, unknown> | 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 // Steg 2 & 3: Unified matching + categorization
// Samla context en gång för alla items // Samla context en gång för alla items
const context = await this.prepareMatchingContext(userId); const context = await this.prepareMatchingContext(userId);
// Mappa alla items genom unified matcher // Mappa alla items genom unified matcher
return Promise.all( const parsedItems = await Promise.all(
rawItems.map((item) => rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)),
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<MatchingContext> { private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
const prismaAny = this.prisma as any; const prismaAny = this.prisma as any;
@@ -573,7 +617,14 @@ export class ReceiptImportService {
return response; return response;
} }
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> { private async parseReceiptViaImporter(file: Express.Multer.File): Promise<{
items: ParsedReceiptItem[];
trace: {
prompt: string | null;
rawOutput: string | null;
normalizedOutput: Record<string, unknown> | null;
};
}> {
const form = new FormData(); const form = new FormData();
form.append( form.append(
'file', 'file',
@@ -608,9 +659,112 @@ export class ReceiptImportService {
throw new BadRequestException(message); throw new BadRequestException(message);
} }
const items = (await response.json()) as ParsedReceiptItem[]; const body = (await response.json()) as
return items.filter((item) => !isIgnoredReceiptName(item.rawName)); | ParsedReceiptItem[]
} | {
items?: ParsedReceiptItem[];
prompt?: unknown;
rawOutput?: unknown;
normalizedOutput?: Record<string, unknown>;
};
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<string, unknown>; items?: ParsedReceiptItem[] },
): Record<string, unknown> | 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<string, unknown> | null;
status: 'success' | 'error';
error: string | null;
durationMs: number;
}): Promise<void> {
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 // UNIFIED MATCHER: Kombinerar product matching + categorization
+133 -148
View File
@@ -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). 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: Syfte:
- Detta är en pre-commit quality gate som ska användas innan commit. - Detta är en pre-commit quality gate innan commit.
- Ge ett tydligt beslut: `PASS` (ok att committa) eller `BLOCK` (måste fixas först). - Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`.
- Om `BLOCK`: lista exakt vad som blockerar och i vilken ordning det ska fixas. - Vid `BLOCK`: lista exakta blockerare och fixordning.
Arbetsordning för filurval: ---
1. Primärt: analysera alla staged filer. ## 0. Deterministiska gate-regler (källa till sanning)
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. ### 0.1 Filurval (delta-first)
1. Primärt: analysera alla staged filer.
Inled rapporten med en kort Scope-sektion som anger: 2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
- Vilken urvalsregel som användes (staged eller commit-kandidater). 3. Exkludera alltid: `node_modules`, `.git`, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
- Exakt vilka filer som analyserades. 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).
- Vilka filer som exkluderades och varför.
### 0.2 Severity och beslut
Lägg därefter till en kort sektion `Gate-beslut`: - **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection).
- `PASS` om inga `Critical` eller `High` finns. - **High**: allvarlig korrektness-/driftsrisk i produktion.
- `BLOCK` om minst en `Critical` eller `High` finns. - **Medium/Low**: informativa förbättringar (blockerar inte).
- Vid `BLOCK`, ge en kort checklista med konkreta fixar.
**Beslutslogik (deterministisk):**
Ge en detaljerad rapport enligt följande struktur: - `BLOCK` om minst 1 `Critical`.
- `BLOCK` om 2 eller fler `High`.
--- - `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`.
### **1. Allmän kodkvalitet** - `PASS` om inga `Critical`/`High`.
- **Läsbarhet/underhållbarhet** (kan blockera om allvarligt): ### 0.3 Evidenskrav för blockerande fynd
- Finns det bristande namngivning (variabler, funktioner, klasser)? Varje `Critical`/`High` måste ha:
- Saknas kommentarer för komplex logik? - `Evidence`: `code`, `test`, eller `runtime`.
- Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)? - Fil + radreferens.
- Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)? - Konkreta fixsteg.
--- Fynd med endast antagande märks `Needs verification` och får inte ensamt orsaka `BLOCK`, om inte risken är uppenbart kritisk.
### **1b. Performance-optimeringar** (INFORMATIONAL)
### 0.4 Stop-early-regel (effektivitet)
Dessa rapporteras men blockerar inte commit. Kan adresseras i senare iteration: - Vid första tydliga `Critical`: sätt preliminärt `BLOCK`, identifiera max 3 ytterligare blockerare, avsluta sedan djupanalys.
- **Algoritm-effektivitet**: ### 0.5 Rapportbudget
- Finns det O(n²) eller värre algoritmer som kan vara O(n)? - Rapportera max 5 informativa fynd (`Medium/Low`), prioriterade efter högst nytta/lägst kostnad.
- Finns onödig kod (död kod, duplicerad logik)?
---
- **Resurser**: ## 1. Analysfokus
- 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)? ### 1.1 Allmän kodkvalitet
- Finns N+1-frågor eller ineffektiva `include/select`-mönster? - Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet.
- TypeScript/Flutter best practices.
**Severity**: `Low` eller `Medium` beroende på påverkan. Blockerar aldrig commit. - Kommentarer för icke-obvious logik.
--- ### 1.2 Performance-optimeringar (informational)
### **2. Säkerhetsanalys** - Algoritmisk effektivitet.
- **Sårbarheter**: - Onödiga kopior/serialiseringar.
- Finns det risk för SQL-injection (Prisma), XSS, CSRF, eller insecure deserialization? - Databasfrågor, N+1-risk, ineffektiva `include/select`.
- 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? ### 1.3 Säkerhetsanalys
- Saknas input-validering (t.ex. för filupp laddningar, användarinmatning)? - Injection/XSS/CSRF/insecure deserialization.
- Inputvalidering och filuppladdningar.
- **Autentisering/auktorisation**: - Secrets i kod/loggar.
- Finns det brister i JWT-hantering (t.ex. svaga algoritmer, saknade `exp`-fält)? - Känslig data i klartext eller otillräckligt maskerad.
- Används HTTP istället för HTTPS?
- Saknas rate limiting för känsliga endpoints? ### 1.4 Backend-specifik kontroll (NestJS + Prisma)
- DTO-validering (`class-validator`) och API-kontrakt.
- **Datahantering**: - Auktorisation/scope (IDOR-skydd, admin-guards).
- Lagras känslig data (t.ex. lösenord) i klartext? - Prisma-scope i `where`, transaktioner vid multipla writes.
- Finns det loggning av känslig data? - Timeout/retry/rate limiting och robust felhantering.
- Används säkra krypteringsmetoder (t.ex. AES-256, bcrypt)?
---
--- ## 2. Krav på varje fynd
### **2b. Backend-specifik kontroll (NestJS + Prisma)** Använd följande mall:
- **API-kontrakt och validering**: - **Severity**: `Critical|High|Medium|Low`
- Kontrollera DTO-validering (`class-validator`) på indata till controllers. - **Evidence**: `code|test|runtime|Needs verification`
- Kontrollera att controllers inte accepterar osanerad payload direkt till service/Prisma. - **Delsystem**: `backend|frontend|db|infra`
- Kontrollera att felhantering använder korrekta HTTP-statuskoder (inte generiska 500/400 i onödan). - **Fil**: `<path:line>`
- **Risk**: kort riskbeskrivning
- **Auktorisation och scope**: - **Varför**: varför detta är ett problem
- Kontrollera att user-scope upprätthålls i queries/mutationer (ingen IDOR). - **Åtgärd**: konkret, realistisk fix
- Kontrollera att admin-endpoints skyddas med rätt guards/roller. - **Verifiering**: kommando/test för att bekräfta fix
- Kontrollera att privata resurser inte kan nås via andras ID.
Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`).
- **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. ## 3. Obligatoriskt outputformat
- Kontrollera risk för N+1-frågor och föreslå `include/select`-optimering där relevant. Returnera exakt i denna ordning:
- **Drift och robusthet**: 1. `Scope`
- Kontrollera rate limiting/throttling på känsliga endpoints. - Urvalsregel: `staged` eller `working-tree`
- Kontrollera att loggar inte exponerar tokens, lösenord eller fulla stacktraces i produktion. - Analyserade filer (exakt lista)
- Kontrollera timeout/retry-strategi vid anrop till externa tjänster. - Exkluderade filer (med orsak)
--- 2. `Gate-beslut`
### **3. Sammanfattning** - `PASS|PASS_WITH_WARNINGS|BLOCK`
- **Topp 6 kritiska åtgärder** (prioriterade efter risk/vinst). - Antal per severity: `Critical`, `High`, `Medium`, `Low`
- **Uppskattad tid** för att implementera förslagen. - Kort motivering
- **Rekommenderade verktyg** för automatiserade kontroller (t.ex. `ESLint`, `Prisma Lint`, `OWASP Dependency-Check`).
3. `Blocking Findings (Critical/High)`
--- - Om inga finns: skriv `Inga blockerande fynd`.
### **Klassificering av fynd (Severity)**
4. `Informational Findings (Medium/Low)`
**BLOCKING** (hindrar commit): - Max 5 fynd.
- `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. 5. `Fixplan (vid BLOCK eller PASS_WITH_WARNINGS)`
- Numrerad ordning, konkreta steg.
**INFORMATIONAL** (rapporteras, men blockerar inte):
- `Medium`: Code-quality, läsbarhet, testluckor, eller mindre performance-optimeringar. 6. `Sammanfattning`
- `Low`: Stilfrågor, dokumentation, eller nice-to-have refactor. - Topp 3 åtgärder efter risk/vinst
- Tidsestimat
**Regel**: Gate-beslut = `PASS` om inga `Critical` eller `High` finns. `BLOCK` annars. - Rekommenderade automatiserade kontroller
--- ---
### **Regler för analysen** ## 4. Konsistenskontroller (måste uppfyllas)
- Var **specifik**: Ge **kod-exempel** för varje förslag. - Om `Gate-beslut=PASS` får inga `Critical/High` listas.
- Var **praktisk**: Fokusera på **realistiska förbättringar** som kan implementeras nu. - Om `Gate-beslut=BLOCK` måste `Fixplan` innehålla minst 1 konkret blockerande åtgärd.
- Var **kritisk**: Peka ut **allvarliga risker** (t.ex. säkerhetshål) först. - Om `PASS_WITH_WARNINGS` används måste exakt 1 `High` finnas och 0 `Critical`.
- 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). ## 5. Fallback: inget att analysera
- Om inga allvarliga risker hittas: skriv det explicit och lyft kvarvarande risker/testluckor. Om inga relevanta filer hittas:
- Ignorera filer som inte är relevanta (t.ex. node_modules, .git, binärfiler). - Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
- Prioritera körbarhet: föreslagna åtgärder ska kunna göras i denna kodbas utan större arkitekturprojekt. - Ge nästa konkreta steg:
- Undvik generiska råd. Allt ska vara kopplat till faktisk kod i scope. - `git add <filer>`
- När både frontend och backend finns i scope: dela upp fynd per delsystem. - `git diff --cached --name-only`
- Om endast backendfiler finns i scope: lägg huvudfokus på sektion **2b** och prioritera säkerhet/scope före stilfrågor. - Kör analysen igen.
--- ---
### **Outputformat (obligatoriskt)** ## 6. Kontext för projektet
1. `Scope` - Backend: NestJS + Prisma + MariaDB (Docker).
2. `Gate-beslut` (`PASS` eller `BLOCK`) - Frontend: Next.js + TypeScript + Flutter.
3. `1. Allmän kodkvalitet` (blocking issues) - Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data.
4. `1b. Performance-optimeringar` (informational)
5. `2. Säkerhetsanalys` (blocking issues) ---
6. `2b. Backend-specifik kontroll` (blocking + informational) ## 7. CI-koppling
7. `3. Sammanfattning` (topprioriteringar, tidskattning) - Detta är lokalt pre-commit-steg.
- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader.
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".
+72 -39
View File
@@ -2,7 +2,7 @@ class AuthApiPaths {
static const login = '/auth/login'; static const login = '/auth/login';
} }
class ProductApiPaths { class ProductApiPaths {
static const list = '/products'; static const list = '/products';
static const mine = '/products/mine'; static const mine = '/products/mine';
static const createPrivate = '/products/private'; static const createPrivate = '/products/private';
@@ -13,14 +13,15 @@ class ProductApiPaths {
static const deleted = '/products/deleted'; static const deleted = '/products/deleted';
static const merge = '/products/merge'; static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge'; static const mergePrivate = '/products/private/merge';
static String updateMineCategory(int id) => '/products/mine/$id/category'; static String updateMineCategory(int id) => '/products/mine/$id/category';
static const backfillMineCategories = '/products/mine/backfill-categories'; static const backfillMineCategories = '/products/mine/backfill-categories';
static String mergePreview(int sourceProductId, int targetProductId) => static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status'; static String setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id'; static String update(int id) => '/products/$id';
static String canonicalName(int id) => '/products/$id/canonical-name'; 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 remove(int id) => '/products/$id';
static String restore(int id) => '/products/$id/restore'; static String restore(int id) => '/products/$id/restore';
static const bulkUpdate = '/products/bulk-update'; static const bulkUpdate = '/products/bulk-update';
@@ -28,37 +29,63 @@ class ProductApiPaths {
class AiApiPaths { class AiApiPaths {
static const models = '/ai/models'; static const models = '/ai/models';
static String traces({
required String source,
int? limit,
String? cursor,
String? period,
bool onlyErrors = false,
}) {
final params = <String, String>{'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 { class CategoryApiPaths {
static const tree = '/categories/tree'; static const tree = '/categories/tree';
} }
class ReceiptImportApiPaths { class ReceiptImportApiPaths {
static const refreshCategories = '/receipt-import/refresh-categories'; static const refreshCategories = '/receipt-import/refresh-categories';
static const unitMappings = '/receipt-import/unit-mappings'; static const unitMappings = '/receipt-import/unit-mappings';
} }
class FlyerImportApiPaths { class FlyerImportApiPaths {
static const parse = '/flyer-import/parse'; static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest'; static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId'; static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source'; static String sourceBySession(int sessionId) =>
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId'; '/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'; class FlyerSelectionApiPaths {
static String planToShoppingListBySession(int sessionId) => static String bySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list'; '/flyer-sessions/$sessionId/selections';
static const open = '/flyer-selections/open'; static String bulkBySession(int sessionId) =>
} '/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
class ShoppingListApiPaths { '/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const items = '/shopping-list/items'; static const open = '/flyer-selections/open';
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status'; }
}
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) =>
'/shopping-list/items/$itemId/status';
}
class HelpTextApiPaths { class HelpTextApiPaths {
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}'; 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 remove(int id) => '/recipes/$id';
static String setVisibility(int id) => '/recipes/$id/visibility'; static String setVisibility(int id) => '/recipes/$id/visibility';
static String share(int id) => '/recipes/$id/share'; 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 inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static String analysis(int id) => '/recipes/$id/analysis'; static String analysis(int id) => '/recipes/$id/analysis';
static String rematch(int id) => '/recipes/$id/rematch'; static String rematch(int id) => '/recipes/$id/rematch';
@@ -92,9 +120,11 @@ class InventoryApiPaths {
static String update(int id) => '/inventory/$id'; static String update(int id) => '/inventory/$id';
static String remove(int id) => '/inventory/$id'; static String remove(int id) => '/inventory/$id';
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry'; 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 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 { class AdminInventoryApiPaths {
@@ -105,10 +135,12 @@ class AdminInventoryApiPaths {
if (sort != null && sort.isNotEmpty) params['sort'] = sort; if (sort != null && sort.isNotEmpty) params['sort'] = sort;
if (params.isEmpty) return list; if (params.isEmpty) return list;
final query = params.entries 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('&'); .join('&');
return '$list?$query'; return '$list?$query';
} }
static String update(int id) => '/inventory/admin/$id'; static String update(int id) => '/inventory/admin/$id';
static String remove(int id) => '/inventory/admin/$id'; static String remove(int id) => '/inventory/admin/$id';
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry'; static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
@@ -121,7 +153,8 @@ class PantryApiPaths {
static const list = '/pantry'; static const list = '/pantry';
static String remove(int id) => '/pantry/$id'; static String remove(int id) => '/pantry/$id';
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory'; 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 adminList = '/pantry/admin';
static const adminCreate = '/pantry/admin'; static const adminCreate = '/pantry/admin';
static String adminUpdate(int id) => '/pantry/admin/$id'; static String adminUpdate(int id) => '/pantry/admin/$id';
@@ -143,14 +176,14 @@ class MealPlanApiPaths {
static const list = '/meal-plan'; static const list = '/meal-plan';
static String listByRange(String from, String to) => 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) => 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) => 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) => static String removeByDate(String date) =>
'$list/${Uri.encodeComponent(date)}'; '$list/${Uri.encodeComponent(date)}';
} }
+40 -32
View File
@@ -49,19 +49,19 @@ class AppShell extends ConsumerWidget {
icon: Icons.storefront_outlined, icon: Icons.storefront_outlined,
label: 'Baslager', label: 'Baslager',
), ),
_AppDestination( _AppDestination(
path: '/import', path: '/import',
title: 'Importera', title: 'Importera',
icon: Icons.upload_file_outlined, icon: Icons.upload_file_outlined,
label: 'Importera', label: 'Importera',
), ),
_AppDestination( _AppDestination(
path: '/inkopslista', path: '/inkopslista',
title: 'Inköpslista', title: 'Inköpslista',
icon: Icons.shopping_cart_outlined, icon: Icons.shopping_cart_outlined,
label: 'Inköpslista', label: 'Inköpslista',
), ),
]; ];
List<_AppDestination> _destinations() => _baseDestinations; List<_AppDestination> _destinations() => _baseDestinations;
@@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget {
} }
} }
final isRecipesRoute = location.startsWith('/recipes') && final isRecipesRoute =
!location.startsWith('/recipes/'); location.startsWith('/recipes') && !location.startsWith('/recipes/');
final isImportRoute = location == '/import'; final isImportRoute = location == '/import';
final isAdminRoute = location.startsWith('/admin'); final isAdminRoute = location.startsWith('/admin');
final adminTab = AdminViewTabX.fromQuery( final adminTab = AdminViewTabX.fromQuery(
@@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget {
selected: adminTab == AdminViewTab.database, selected: adminTab == AdminViewTab.database,
onSelected: (_) => navigateToAdminTab(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( Widget shell = Scaffold(
appBar: AppBar( appBar: AppBar(
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title), title:
isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
bottom: isImportRoute bottom: isImportRoute
? const TabBar( ? const TabBar(
tabs: [ tabs: [
@@ -148,17 +155,17 @@ class AppShell extends ConsumerWidget {
icon: Icon(Icons.restaurant_menu_outlined), icon: Icon(Icons.restaurant_menu_outlined),
text: 'Recept', text: 'Recept',
), ),
Tab( Tab(
icon: Icon(Icons.receipt_long_outlined), icon: Icon(Icons.receipt_long_outlined),
text: 'Kvitto', text: 'Kvitto',
), ),
Tab( Tab(
icon: Icon(Icons.local_offer_outlined), icon: Icon(Icons.local_offer_outlined),
text: 'Flyer', text: 'Flyer',
), ),
], ],
) )
: null, : null,
actions: [ actions: [
if (isRecipesRoute) if (isRecipesRoute)
Consumer( Consumer(
@@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget {
PopupMenuButton<int>( PopupMenuButton<int>(
icon: const Icon(Icons.grid_view), icon: const Icon(Icons.grid_view),
tooltip: 'Välj antal kolumner', tooltip: 'Välj antal kolumner',
onSelected: (columns) => onSelected: (columns) => ref
ref.read(recipesViewProvider.notifier).setColumns(columns), .read(recipesViewProvider.notifier)
.setColumns(columns),
itemBuilder: (context) => const [ itemBuilder: (context) => const [
PopupMenuItem(value: 2, child: Text('2 kolumner')), PopupMenuItem(value: 2, child: Text('2 kolumner')),
PopupMenuItem(value: 4, child: Text('4 kolumner')), PopupMenuItem(value: 4, child: Text('4 kolumner')),
@@ -288,9 +296,9 @@ class AppShell extends ConsumerWidget {
), ),
); );
if (isImportRoute) { if (isImportRoute) {
shell = DefaultTabController(length: 3, child: shell); shell = DefaultTabController(length: 3, child: shell);
} }
return shell; return shell;
} }
@@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart';
import '../domain/admin_pantry_item.dart'; import '../domain/admin_pantry_item.dart';
import '../domain/admin_inventory_item.dart'; import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.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/ai_model_info.dart';
import '../domain/pending_product.dart'; import '../domain/pending_product.dart';
import '../domain/receipt_alias.dart'; import '../domain/receipt_alias.dart';
@@ -145,7 +147,8 @@ class AdminRepository {
(data['data'] as List<dynamic>?) ?? (data['data'] as List<dynamic>?) ??
const []; const [];
if (raw.isEmpty && data.isNotEmpty) { if (raw.isEmpty && data.isNotEmpty) {
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}'); debugPrint(
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
} }
} else { } else {
raw = const []; raw = const [];
@@ -172,7 +175,8 @@ class AdminRepository {
Future<UserAdmin> setRecipeSharing(int userId, Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) => {required bool canShareRecipes}) =>
_patch(UserApiPaths.setRecipeSharing(userId), _patch(UserApiPaths.setRecipeSharing(userId),
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson); body: {'canShareRecipes': canShareRecipes},
parse: UserAdmin.fromJson);
Future<void> updateEmail(int userId, String email) => Future<void> updateEmail(int userId, String email) =>
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email}); _patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
@@ -194,7 +198,8 @@ class AdminRepository {
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>), parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
); );
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId)); Future<void> deleteUser(int userId) =>
_deleteVoid(UserApiPaths.delete(userId));
/// Returns `{ temporaryPassword, to, subject, body }`. /// Returns `{ temporaryPassword, to, subject, body }`.
Future<Map<String, dynamic>> resetPassword(int userId) => Future<Map<String, dynamic>> resetPassword(int userId) =>
@@ -203,7 +208,8 @@ class AdminRepository {
// ── Produkter ────────────────────────────────────────────────────────────── // ── Produkter ──────────────────────────────────────────────────────────────
Future<List<AdminProduct>> listProducts() => Future<List<AdminProduct>> listProducts() =>
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); _getList(ProductApiPaths.list, AdminProduct.fromJson,
requiresAuth: false);
@Deprecated('Use listProducts(). Kept for temporary compatibility.') @Deprecated('Use listProducts(). Kept for temporary compatibility.')
Future<List<AdminProduct>> listGlobalProducts() => listProducts(); Future<List<AdminProduct>> listGlobalProducts() => listProducts();
@@ -249,7 +255,8 @@ class AdminRepository {
final list = merged.values.toList(); final list = merged.values.toList();
list.sort( list.sort(
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), (a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
); );
_selectableProductsCache = List<AdminProduct>.from(list); _selectableProductsCache = List<AdminProduct>.from(list);
_selectableProductsCacheAt = now; _selectableProductsCacheAt = now;
@@ -263,7 +270,8 @@ class AdminRepository {
_getList(ProductApiPaths.pending, PendingProduct.fromJson); _getList(ProductApiPaths.pending, PendingProduct.fromJson);
Future<void> setProductStatus(int productId, String status) => Future<void> setProductStatus(int productId, String status) =>
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) { _patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
.then((_) {
_invalidateSelectableProductsCache(); _invalidateSelectableProductsCache();
}); });
@@ -271,14 +279,16 @@ class AdminRepository {
_post<AdminProduct>( _post<AdminProduct>(
ProductApiPaths.promotePrivate(productId), ProductApiPaths.promotePrivate(productId),
body: null, body: null,
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)), parse: (d) =>
AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
).then((value) { ).then((value) {
_invalidateSelectableProductsCache(); _invalidateSelectableProductsCache();
return value; return value;
}); });
Future<void> setProductCategory(int productId, {required int? categoryId}) => Future<void> setProductCategory(int productId, {required int? categoryId}) =>
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) { _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
.then((_) {
_invalidateSelectableProductsCache(); _invalidateSelectableProductsCache();
}); });
@@ -301,7 +311,8 @@ class AdminRepository {
_invalidateSelectableProductsCache(); _invalidateSelectableProductsCache();
}); });
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => Future<void> updateCanonicalNamePrivate(
int productId, String canonicalName) =>
_patchVoid( _patchVoid(
ProductApiPaths.canonicalNamePrivate(productId), ProductApiPaths.canonicalNamePrivate(productId),
{'canonicalName': canonicalName.trim()}, {'canonicalName': canonicalName.trim()},
@@ -336,7 +347,8 @@ class AdminRepository {
int _parseUpdatedCount(dynamic data) { int _parseUpdatedCount(dynamic data) {
if (data is! Map) { if (data is! Map) {
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}'); debugPrint(
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
return 0; return 0;
} }
final map = Map<String, dynamic>.from(data); final map = Map<String, dynamic>.from(data);
@@ -391,8 +403,7 @@ class AdminRepository {
// ── Kategorier ───────────────────────────────────────────────────────────── // ── Kategorier ─────────────────────────────────────────────────────────────
Future<List<AdminCategoryNode>> listCategoryTree() => Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
_getList(
CategoryApiPaths.tree, CategoryApiPaths.tree,
AdminCategoryNode.fromJson, AdminCategoryNode.fromJson,
requiresAuth: false, requiresAuth: false,
@@ -404,6 +415,26 @@ class AdminRepository {
Future<List<AiModelInfo>> listAiModels() => Future<List<AiModelInfo>> listAiModels() =>
_getList(AiApiPaths.models, AiModelInfo.fromJson); _getList(AiApiPaths.models, AiModelInfo.fromJson);
Future<AdminAiTraceListResponse> 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<AdminAiTraceDetail> getAiTraceById(String traceId) =>
_getMap(AiApiPaths.traceById(traceId)).then(AdminAiTraceDetail.fromJson);
// ── Kvittoalias (admin/global fallback) ─────────────────────────────────── // ── Kvittoalias (admin/global fallback) ───────────────────────────────────
Future<List<ReceiptAlias>> listReceiptAliases() => Future<List<ReceiptAlias>> listReceiptAliases() =>
@@ -543,7 +574,8 @@ class AdminRepository {
if (location != null && location.trim().isNotEmpty) if (location != null && location.trim().isNotEmpty)
'location': location.trim(), 'location': location.trim(),
}, },
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)), parse: (d) =>
AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
); );
} }
@@ -582,6 +614,7 @@ class AdminRepository {
required int targetInventoryId, required int targetInventoryId,
}) => }) =>
_getMap( _getMap(
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId), AdminInventoryApiPaths.mergePreview(
sourceInventoryId, targetInventoryId),
); );
} }
@@ -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<String, dynamic> 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<AdminAiTraceListItem> items;
final String? nextCursor;
const AdminAiTraceListResponse({
required this.items,
required this.nextCursor,
});
factory AdminAiTraceListResponse.fromJson(Map<String, dynamic> json) {
final rawItems = (json['items'] as List<dynamic>?) ?? const [];
return AdminAiTraceListResponse(
items: rawItems
.whereType<Map>()
.map((entry) =>
AdminAiTraceListItem.fromJson(Map<String, dynamic>.from(entry)))
.toList(),
nextCursor: json['nextCursor']?.toString(),
);
}
}
@@ -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<String> warnings;
final String? error;
final String? prompt;
final String? rawOutput;
final Map<String, dynamic>? normalizedOutput;
final Map<String, dynamic> 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<String, dynamic> json) {
final warningsRaw = (json['warnings'] as List<dynamic>?) ?? const [];
final normalizedOutputMap = json['normalizedOutput'] is Map
? Map<String, dynamic>.from(json['normalizedOutput'] as Map)
: null;
final summaryMap = json['summary'] is Map
? Map<String, dynamic>.from(json['summary'] as Map)
: const <String, dynamic>{};
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,
);
}
}
@@ -1,139 +1,537 @@
import 'package:flutter/material.dart'; import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import '../../../core/api/api_error_mapper.dart'; import 'package:flutter/services.dart';
import '../../../core/l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/admin_repository.dart';
import '../domain/ai_model_info.dart'; import '../../../core/api/api_error_mapper.dart';
import '../data/admin_repository.dart';
class AdminAiPanel extends ConsumerStatefulWidget { import '../domain/admin_ai_trace.dart';
final bool embedded; import '../domain/admin_ai_trace_detail.dart';
const AdminAiPanel({super.key, this.embedded = false}); class AdminAiPanel extends ConsumerStatefulWidget {
final bool embedded;
@override
ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState(); const AdminAiPanel({super.key, this.embedded = false});
}
@override
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> { ConsumerState<AdminAiPanel> createState() => _AdminAiPanelState();
bool _isLoading = true; }
String? _error;
List<AiModelInfo> _models = []; class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
bool _isLoading = true;
@override String? _error;
void initState() {
super.initState(); AdminAiTraceSource _source = AdminAiTraceSource.flyer;
_load(); String _period = '7d';
} bool _onlyErrors = false;
Future<void> _load() async { List<AdminAiTraceListItem> _items = const [];
setState(() { String? _nextCursor;
_isLoading = true; String? _selectedId;
_error = null; AdminAiTraceDetail? _selected;
}); bool _isDetailLoading = false;
try { bool _promptExpanded = false;
final models = await ref.read(adminRepositoryProvider).listAiModels(); String? _cachedOutputTraceId;
if (!mounted) return; String? _cachedOutputPrettyJson;
setState(() => _models = models);
} catch (e) { @override
if (!mounted) return; void initState() {
setState(() => _error = mapErrorToUserMessage(e, context)); super.initState();
} finally { _load();
if (mounted) setState(() => _isLoading = false); }
}
} Future<void> _load() async {
setState(() {
Color _chipColor(String value, ColorScheme scheme) { _isLoading = true;
final lower = value.toLowerCase(); _error = null;
if (lower.contains('admin')) return scheme.primaryContainer; });
if (lower.contains('premium')) return scheme.tertiaryContainer; try {
return scheme.secondaryContainer; final response = await ref.read(adminRepositoryProvider).listAiTraces(
} source: _source,
limit: 30,
@override period: _period,
Widget build(BuildContext context) { onlyErrors: _onlyErrors,
final theme = Theme.of(context); );
if (_isLoading) return const Center(child: CircularProgressIndicator()); if (!mounted) return;
if (_error != null) { final selectedId =
return buildCopyableErrorPanel( response.items.isEmpty ? null : response.items.first.id;
context: context, setState(() {
message: _error!, _items = response.items;
onRetry: _load, _nextCursor = response.nextCursor;
title: 'Kunde inte läsa AI-modeller', _selectedId = selectedId;
); _selected = null;
} _promptExpanded = false;
_cachedOutputTraceId = null;
return Column( _cachedOutputPrettyJson = null;
crossAxisAlignment: CrossAxisAlignment.start, });
children: [ if (selectedId != null) {
Card( await _loadDetail(selectedId);
child: Padding( }
padding: const EdgeInsets.all(16), } catch (e) {
child: Column( if (!mounted) return;
crossAxisAlignment: CrossAxisAlignment.start, setState(() => _error = mapErrorToUserMessage(e, context));
children: [ } finally {
Text('AI', style: theme.textTheme.titleMedium), if (mounted) {
const SizedBox(height: 8), setState(() => _isLoading = false);
Text( }
context.l10n.adminAiDescription, }
style: theme.textTheme.bodyMedium, }
),
const SizedBox(height: 8), Future<void> _loadMore() async {
const Wrap( if (_nextCursor == null || _nextCursor!.isEmpty) return;
spacing: 8, try {
runSpacing: 8, final response = await ref.read(adminRepositoryProvider).listAiTraces(
children: [ source: _source,
Chip(label: Text('Models')), limit: 30,
Chip(label: Text('Access')), cursor: _nextCursor,
Chip(label: Text('Trigger')), period: _period,
], onlyErrors: _onlyErrors,
), );
], if (!mounted) return;
), setState(() {
), _items = [..._items, ...response.items];
), _nextCursor = response.nextCursor;
const SizedBox(height: 12), });
if (_models.isEmpty) } catch (_) {
Card( // Ignore soft pagination failures.
child: Padding( }
padding: const EdgeInsets.all(16), }
child: Text(
'Inga AI-modeller hittades.', Future<void> _loadDetail(String id) async {
style: theme.textTheme.bodyMedium, setState(() {
), _isDetailLoading = true;
), _selected = null;
), _cachedOutputTraceId = null;
..._models.map( _cachedOutputPrettyJson = null;
(model) => Card( });
child: Padding( try {
padding: const EdgeInsets.all(16), final detail = await ref.read(adminRepositoryProvider).getAiTraceById(id);
child: Column( if (!mounted) return;
crossAxisAlignment: CrossAxisAlignment.start, setState(() => _selected = detail);
children: [ } catch (_) {
Text(model.name, style: theme.textTheme.titleMedium), if (!mounted) return;
const SizedBox(height: 8), setState(() => _selected = null);
Text(model.description), } finally {
const SizedBox(height: 12), if (mounted) {
Wrap( setState(() => _isDetailLoading = false);
spacing: 8, }
runSpacing: 8, }
children: [ }
Chip(label: Text(model.model)),
Chip( String _formatDateTime(DateTime value) {
label: Text(model.access), final local = value.toLocal();
backgroundColor: _chipColor(model.access, theme.colorScheme), String two(int n) => n.toString().padLeft(2, '0');
), return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
Chip(label: Text(model.trigger)), }
],
), Future<void> _copyText(String value, String label) async {
const SizedBox(height: 8), await Clipboard.setData(ClipboardData(text: value));
Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall), 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 <String, dynamic>{}
: {'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'),
),
),
),
],
),
),
);
}
}
@@ -5,7 +5,6 @@ import 'dart:async';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart'; import '../../../core/realtime/realtime_sync.dart';
import 'admin_ai_panel.dart';
import 'admin_aliases_panel.dart'; import 'admin_aliases_panel.dart';
import 'admin_inventory_panel.dart'; import 'admin_inventory_panel.dart';
import 'admin_pantry_panel.dart'; import 'admin_pantry_panel.dart';
@@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart';
import 'admin_products_panel.dart'; import 'admin_products_panel.dart';
import '../../profile/data/profile_repository.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 { class _DatabaseTabConfig {
final _DatabaseTab tab; final _DatabaseTab tab;
@@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
title: 'Alias', title: 'Alias',
panel: const AdminAliasesPanel(embedded: true), panel: const AdminAliasesPanel(embedded: true),
), ),
_DatabaseTabConfig(
tab: _DatabaseTab.ai,
title: 'AI',
panel: const AdminAiPanel(embedded: true),
),
]; ];
Future<void> _refreshCategories() async { Future<void> _refreshCategories() async {
@@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab); final currentTab =
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
final header = Card( final header = Card(
child: Padding( child: Padding(
@@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
child: ChoiceChip( child: ChoiceChip(
label: Text(config.title), label: Text(config.title),
selected: _activeTab == config.tab, selected: _activeTab == config.tab,
onSelected: (_) => setState(() => _activeTab = config.tab), onSelected: (_) =>
setState(() => _activeTab = config.tab),
), ),
), ),
) )
@@ -157,7 +160,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: 'Uppdatera kategorier', tooltip: 'Uppdatera kategorier',
onPressed: _isRefreshingCategories ? null : _refreshCategories, onPressed:
_isRefreshingCategories ? null : _refreshCategories,
icon: _isRefreshingCategories icon: _isRefreshingCategories
? const SizedBox( ? const SizedBox(
height: 16, height: 16,
@@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
child: KeyedSubtree( child: KeyedSubtree(
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'), key:
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
child: currentTab.panel, child: currentTab.panel,
), ),
), ),
@@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
); );
} }
} }
@@ -1,36 +1,42 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'admin_database_panel.dart'; import 'admin_ai_panel.dart';
import 'admin_users_panel.dart'; import 'admin_database_panel.dart';
import 'admin_users_panel.dart';
enum AdminViewTab { users, database }
enum AdminViewTab { users, database, ai }
extension AdminViewTabX on AdminViewTab {
static AdminViewTab fromQuery(String? value) { extension AdminViewTabX on AdminViewTab {
return switch (value) { static AdminViewTab fromQuery(String? value) {
'database' => AdminViewTab.database, return switch (value) {
_ => AdminViewTab.users, 'database' => AdminViewTab.database,
}; 'ai' => AdminViewTab.ai,
} _ => AdminViewTab.users,
};
String get queryValue => this == AdminViewTab.database ? 'database' : 'users'; }
}
String get queryValue => switch (this) {
class AdminScreen extends StatelessWidget { AdminViewTab.users => 'users',
final AdminViewTab initialTab; AdminViewTab.database => 'database',
AdminViewTab.ai => 'ai',
const AdminScreen({super.key, this.initialTab = AdminViewTab.users}); };
}
@override
Widget build(BuildContext context) { class AdminScreen extends StatelessWidget {
final activePanel = switch (initialTab) { final AdminViewTab initialTab;
AdminViewTab.users => const AdminUsersPanel(embedded: true),
AdminViewTab.database => const AdminDatabasePanel(embedded: true), const AdminScreen({super.key, this.initialTab = AdminViewTab.users});
};
@override
return Padding( Widget build(BuildContext context) {
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), final activePanel = switch (initialTab) {
child: activePanel, 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,
);
}
}
+130 -39
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/admin/data/admin_repository.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_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_category_node.dart';
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart'; import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart'; import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
@@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository {
TestAdminRepositoryWrapper(this._fakeRepo); TestAdminRepositoryWrapper(this._fakeRepo);
@override @override
Future<List<ReceiptAlias>> listReceiptAliases() => _fakeRepo.listReceiptAliases(); Future<List<ReceiptAlias>> listReceiptAliases() =>
_fakeRepo.listReceiptAliases();
@override @override
Future<List<AdminProduct>> listGlobalProducts() => _fakeRepo.listGlobalProducts(); Future<List<AdminProduct>> listGlobalProducts() =>
_fakeRepo.listGlobalProducts();
@override @override
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) => _fakeRepo.updateReceiptAlias(id, receiptName: receiptName, productId: productId, isGlobal: isGlobal); Future<void> updateReceiptAlias(int id,
{String? receiptName, int? productId, bool? isGlobal}) =>
_fakeRepo.updateReceiptAlias(id,
receiptName: receiptName, productId: productId, isGlobal: isGlobal);
// Stub implementations for other required methods // Stub implementations for other required methods
@override @override
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({List<int>? productIds}) => throw UnimplementedError(); Future<List<AdminAiCategorizeResult>> aiCategorizeBulk(
{List<int>? productIds}) =>
throw UnimplementedError();
@override @override
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) => throw UnimplementedError(); Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
throw UnimplementedError();
@override @override
Future<AdminInventoryItem> 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<AdminInventoryItem> 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 @override
Future<AdminPantryItem> createAdminPantry({int? userId, required int productId, String? location}) => throw UnimplementedError(); Future<AdminPantryItem> createAdminPantry(
{int? userId, required int productId, String? location}) =>
throw UnimplementedError();
@override @override
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => throw UnimplementedError(); Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
throw UnimplementedError();
@override @override
Future<UserAdmin> createUser({required String username, required String email, required String password, String role = 'user'}) => throw UnimplementedError(); Future<UserAdmin> createUser(
{required String username,
required String email,
required String password,
String role = 'user'}) =>
throw UnimplementedError();
@override @override
Future<void> deleteUser(int userId) => throw UnimplementedError(); Future<void> deleteUser(int userId) => throw UnimplementedError();
@override @override
Future<List<AdminInventoryItem>> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError(); Future<List<AdminInventoryItem>> listAdminInventory(
{int? userId, String? sort}) =>
throw UnimplementedError();
@override @override
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) => throw UnimplementedError(); Future<List<AdminPantryItem>> listAdminPantry({int? userId}) =>
throw UnimplementedError();
@override @override
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError(); Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
@override @override
Future<List<AdminCategoryNode>> listCategoryTree() => throw UnimplementedError(); Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
throw UnimplementedError();
@override @override
Future<List<AdminProduct>> listDeletedProducts() => throw UnimplementedError(); Future<AdminAiTraceListResponse> listAiTraces(
{required AdminAiTraceSource source,
int limit = 25,
String? cursor,
String? period,
bool onlyErrors = false}) =>
throw UnimplementedError();
@override @override
Future<List<PendingProduct>> listPendingProducts() => throw UnimplementedError(); Future<List<AdminCategoryNode>> listCategoryTree() =>
throw UnimplementedError();
@override @override
Future<List<PendingProduct>> listPrivateProducts() => throw UnimplementedError(); Future<List<AdminProduct>> listDeletedProducts() =>
throw UnimplementedError();
@override
Future<List<PendingProduct>> listPendingProducts() =>
throw UnimplementedError();
@override
Future<List<PendingProduct>> listPrivateProducts() =>
throw UnimplementedError();
@override @override
Future<List<AdminProduct>> listProducts() => throw UnimplementedError(); Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
@override @override
Future<List<AdminProduct>> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError(); Future<List<AdminProduct>> listSelectableProductsForAdmin(
{bool forceRefresh = false}) =>
throw UnimplementedError();
@override @override
Future<List<UserAdmin>> listUsers() => throw UnimplementedError(); Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
@override @override
Future<void> mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError(); Future<void> mergeAdminInventory(
{required int sourceInventoryId, required int targetInventoryId}) =>
throw UnimplementedError();
@override @override
Future<void> mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); Future<void> mergeProducts(
{required int sourceProductId, required int targetProductId}) =>
throw UnimplementedError();
@override @override
Future<void> mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); Future<void> mergeProductsPrivate(
{required int sourceProductId, required int targetProductId}) =>
throw UnimplementedError();
@override @override
Future<void> moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError(); Future<void> moveAdminInventoryToPantry(int inventoryId) =>
throw UnimplementedError();
@override @override
Future<void> moveAdminPantryToInventory(int pantryItemId, Map<String, dynamic> body) => throw UnimplementedError(); Future<void> moveAdminPantryToInventory(
int pantryItemId, Map<String, dynamic> body) =>
throw UnimplementedError();
@override @override
Future<Map<String, dynamic>> previewAdminInventoryMerge({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError(); Future<Map<String, dynamic>> previewAdminInventoryMerge(
{required int sourceInventoryId, required int targetInventoryId}) =>
throw UnimplementedError();
@override @override
Future<Map<String, dynamic>> previewMerge({required int sourceProductId, required int targetProductId}) => throw UnimplementedError(); Future<Map<String, dynamic>> previewMerge(
{required int sourceProductId, required int targetProductId}) =>
throw UnimplementedError();
@override @override
Future<AdminProduct> promotePrivateProduct(int productId) => throw UnimplementedError(); Future<AdminProduct> promotePrivateProduct(int productId) =>
throw UnimplementedError();
@override @override
Future<void> removeAdminInventory(int inventoryId) => throw UnimplementedError(); Future<void> removeAdminInventory(int inventoryId) =>
throw UnimplementedError();
@override @override
Future<void> removeAdminPantryItem(int pantryItemId) => throw UnimplementedError(); Future<void> removeAdminPantryItem(int pantryItemId) =>
throw UnimplementedError();
@override @override
Future<void> removeProduct(int productId) => throw UnimplementedError(); Future<void> removeProduct(int productId) => throw UnimplementedError();
@override @override
Future<void> removeReceiptAlias(int id) => throw UnimplementedError(); Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
@override @override
Future<Map<String, dynamic>> resetPassword(int userId) => throw UnimplementedError(); Future<Map<String, dynamic>> resetPassword(int userId) =>
throw UnimplementedError();
@override @override
Future<void> restoreProduct(int productId) => throw UnimplementedError(); Future<void> restoreProduct(int productId) => throw UnimplementedError();
@override @override
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) => throw UnimplementedError(); Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
throw UnimplementedError();
@override @override
Future<void> setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError(); Future<void> setProductCategory(int productId, {required int? categoryId}) =>
throw UnimplementedError();
@override @override
Future<void> setProductStatus(int productId, String status) => throw UnimplementedError(); Future<void> setProductStatus(int productId, String status) =>
throw UnimplementedError();
@override @override
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError(); Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) =>
throw UnimplementedError();
@override @override
Future<UserAdmin> setRole(int userId, String newRole) => throw UnimplementedError(); Future<UserAdmin> setRole(int userId, String newRole) =>
throw UnimplementedError();
@override @override
Future<AdminInventoryItem> updateAdminInventory(int inventoryId, {int? productId, double? quantity, String? unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment}) => throw UnimplementedError(); Future<AdminInventoryItem> updateAdminInventory(int inventoryId,
{int? productId,
double? quantity,
String? unit,
String? location,
String? brand,
String? receiptName,
String? suitableFor,
String? comment}) =>
throw UnimplementedError();
@override @override
Future<AdminPantryItem> updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError(); Future<AdminPantryItem> updateAdminPantry(int pantryItemId,
{int? productId, String? location}) =>
throw UnimplementedError();
@override @override
Future<void> updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError(); Future<void> updateCanonicalName(int productId, String canonicalName) =>
throw UnimplementedError();
@override @override
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError(); Future<void> updateCanonicalNamePrivate(
int productId, String canonicalName) =>
throw UnimplementedError();
@override @override
Future<void> updateEmail(int userId, String email) => throw UnimplementedError(); Future<void> updateEmail(int userId, String email) =>
throw UnimplementedError();
@override @override
Future<void> upsertReceiptAlias({required String receiptName, required int productId, bool isGlobal = false}) => throw UnimplementedError(); Future<void> upsertReceiptAlias(
{required String receiptName,
required int productId,
bool isGlobal = false}) =>
throw UnimplementedError();
} }
// Simple fake that only implements the methods we need // Simple fake that only implements the methods we need
@@ -128,7 +218,8 @@ class FakeAdminRepository {
return _products; return _products;
} }
Future<void> updateReceiptAlias(int id, {String? receiptName, int? productId, bool? isGlobal}) async { Future<void> updateReceiptAlias(int id,
{String? receiptName, int? productId, bool? isGlobal}) async {
// Find and update alias // Find and update alias
final index = _aliases.indexWhere((a) => a.id == id); final index = _aliases.indexWhere((a) => a.id == id);
if (index >= 0) { if (index >= 0) {
@@ -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<String, AdminAiTraceDetail> details;
_FakeAdminRepository({
required this.flyerList,
required this.receiptList,
required this.details,
});
@override
Future<AdminAiTraceListResponse> listAiTraces({
required AdminAiTraceSource source,
int limit = 25,
String? cursor,
String? period,
bool onlyErrors = false,
}) async {
return source == AdminAiTraceSource.flyer ? flyerList : receiptList;
}
@override
Future<AdminAiTraceDetail> 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));
});
});
}