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[]
flyerSelections FlyerSelection[]
shoppingListItems ShoppingListItem[]
aiTraces AiTrace[]
}
model Product {
@@ -388,3 +389,25 @@ model ShoppingListItem {
@@index([productId, unit, status])
@@index([categoryId])
}
model AiTrace {
id Int @id @default(autoincrement())
source String
userId Int?
sessionId Int?
model String?
prompt String? @db.LongText
rawOutput String? @db.LongText
normalizedOutput Json?
status String
error String? @db.Text
durationMs Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([source, createdAt])
@@index([userId, createdAt])
@@index([status, createdAt])
}
+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,
},
};
}
}
+30 -1
View File
@@ -1,6 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { AI_CATEGORIZATION_MODEL } from './ai.service';
import { AiTraceService } from './ai-trace.service';
import { ListAiTracesQueryDto } from './dto/list-ai-traces.query.dto';
const RECEIPT_IMPORT_MODEL = 'mistral-small-2603';
@@ -16,6 +19,8 @@ export interface AiModelInfo {
@Controller('ai')
export class AiController {
constructor(private readonly aiTraceService: AiTraceService) {}
@Get('models')
@Public()
getModels(): AiModelInfo[] {
@@ -67,4 +72,28 @@ export class AiController {
},
];
}
@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}`);
}
}
+4 -1
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
import { AiController } from './ai.controller';
import { AiTraceService } from './ai-trace.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AiController],
providers: [AiService],
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;
}
@@ -13,6 +13,7 @@ describe('ReceiptImportService parseReceipt flow', () => {
];
const prismaMock = {
aiTrace: { create: jest.fn() },
receiptAlias: { findMany: jest.fn() },
product: { findMany: jest.fn() },
unitMapping: { findMany: jest.fn() },
@@ -82,12 +83,19 @@ describe('ReceiptImportService parseReceipt flow', () => {
jest
.spyOn(service as any, 'parseReceiptViaImporter')
.mockResolvedValue([
.mockResolvedValue({
items: [
{ rawName: 'MIXAD VARA', quantity: 1, unit: 'st' },
{ rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' },
{ rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' },
{ rawName: 'helt okänd vara', quantity: 1, unit: 'st' },
]);
],
trace: {
prompt: 'test prompt',
rawOutput: '{"items":[]}',
normalizedOutput: { items: [] },
},
});
const file = {
buffer: Buffer.from('dummy'),
@@ -22,6 +22,8 @@ import { FlyerSelectionService } from '../flyer-selection/flyer-selection.servic
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
const RECEIPT_IMPORT_MODEL = 'importer-receipt-ai';
const WEAK_DESCRIPTORS = new Set([
'rokt',
'rökt',
@@ -134,19 +136,61 @@ export class ReceiptImportService {
) {}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
const parseStartedAt = Date.now();
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
const rawItems = await this.parseReceiptViaImporter(file);
let rawItems: ParsedReceiptItem[];
try {
const importer = await this.parseReceiptViaImporter(file);
rawItems = importer.items;
tracePrompt = importer.trace.prompt;
traceRawOutput = importer.trace.rawOutput;
traceNormalizedOutput = importer.trace.normalizedOutput;
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
await this.persistReceiptTrace({
userId,
model: RECEIPT_IMPORT_MODEL,
prompt: tracePrompt,
rawOutput: traceRawOutput,
normalizedOutput: traceNormalizedOutput,
status: 'error',
error: parseError,
durationMs: Date.now() - parseStartedAt,
});
throw err;
}
// Steg 2 & 3: Unified matching + categorization
// Samla context en gång för alla items
const context = await this.prepareMatchingContext(userId);
// Mappa alla items genom unified matcher
return Promise.all(
rawItems.map((item) =>
this.matchAndEnrichReceiptItem(item, context),
),
const parsedItems = await Promise.all(
rawItems.map((item) => this.matchAndEnrichReceiptItem(item, context)),
);
await this.persistReceiptTrace({
userId,
model: RECEIPT_IMPORT_MODEL,
prompt: tracePrompt,
rawOutput: traceRawOutput,
normalizedOutput: {
importer: traceNormalizedOutput,
enrichedItems: parsedItems,
},
status: parsedItems.length == 0 ? 'error' : 'success',
error: parsedItems.length == 0
? 'Inga kvittorader kunde tolkas av importer-tjänsten.'
: null,
durationMs: Date.now() - parseStartedAt,
});
return parsedItems;
}
private async prepareMatchingContext(userId?: number): Promise<MatchingContext> {
@@ -573,7 +617,14 @@ export class ReceiptImportService {
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();
form.append(
'file',
@@ -608,8 +659,111 @@ export class ReceiptImportService {
throw new BadRequestException(message);
}
const items = (await response.json()) as ParsedReceiptItem[];
return items.filter((item) => !isIgnoredReceiptName(item.rawName));
const body = (await response.json()) as
| ParsedReceiptItem[]
| {
items?: ParsedReceiptItem[];
prompt?: unknown;
rawOutput?: unknown;
normalizedOutput?: Record<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)}`,
);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+108 -123
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:
- Detta är en pre-commit quality gate som ska användas innan commit.
- Ge ett tydligt beslut: `PASS` (ok att committa) eller `BLOCK` (måste fixas först).
- Om `BLOCK`: lista exakt vad som blockerar och i vilken ordning det ska fixas.
- Detta är en pre-commit quality gate innan commit.
- Leverera ett tydligt gate-beslut: `PASS`, `PASS_WITH_WARNINGS` eller `BLOCK`.
- Vid `BLOCK`: lista exakta blockerare och fixordning.
Arbetsordning för filurval:
---
## 0. Deterministiska gate-regler (källa till sanning)
### 0.1 Filurval (delta-first)
1. Primärt: analysera alla staged filer.
2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked).
3. Exkludera alltid irrelevanta filer: node_modules, .git, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
3. Exkludera alltid: `node_modules`, `.git`, build/cache-artifacts, binärfiler, genererade filer som inte ska committas.
4. Fokusera blockerande bedömning på förändrad kod (delta). Legacy-problem i opåverkade delar rapporteras som teknisk skuld (ej blockerande i denna gate).
Inled rapporten med en kort Scope-sektion som anger:
- Vilken urvalsregel som användes (staged eller commit-kandidater).
- Exakt vilka filer som analyserades.
- Vilka filer som exkluderades och varför.
### 0.2 Severity och beslut
- **Critical**: säkerhetshål/scope-brist med hög impact (t.ex. IDOR, auth bypass, PII-läckage, injection).
- **High**: allvarlig korrektness-/driftsrisk i produktion.
- **Medium/Low**: informativa förbättringar (blockerar inte).
Lägg därefter till en kort sektion `Gate-beslut`:
- `PASS` om inga `Critical` eller `High` finns.
- `BLOCK` om minst en `Critical` eller `High` finns.
- Vid `BLOCK`, ge en kort checklista med konkreta fixar.
**Beslutslogik (deterministisk):**
- `BLOCK` om minst 1 `Critical`.
- `BLOCK` om 2 eller fler `High`.
- `PASS_WITH_WARNINGS` om exakt 1 `High` utan `Critical`.
- `PASS` om inga `Critical`/`High`.
Ge en detaljerad rapport enligt följande struktur:
### 0.3 Evidenskrav för blockerande fynd
Varje `Critical`/`High` måste ha:
- `Evidence`: `code`, `test`, eller `runtime`.
- Fil + radreferens.
- Konkreta fixsteg.
Fynd med endast antagande märks `Needs verification` och får inte ensamt orsaka `BLOCK`, om inte risken är uppenbart kritisk.
### 0.4 Stop-early-regel (effektivitet)
- Vid första tydliga `Critical`: sätt preliminärt `BLOCK`, identifiera max 3 ytterligare blockerare, avsluta sedan djupanalys.
### 0.5 Rapportbudget
- Rapportera max 5 informativa fynd (`Medium/Low`), prioriterade efter högst nytta/lägst kostnad.
---
### **1. Allmän kodkvalitet**
## 1. Analysfokus
- **Läsbarhet/underhållbarhet** (kan blockera om allvarligt):
- Finns det bristande namngivning (variabler, funktioner, klasser)?
- Saknas kommentarer för komplex logik?
- Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)?
- Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)?
### 1.1 Allmän kodkvalitet
- Läsbarhet/underhållbarhet: namngivning, modularisering, komplexitet.
- TypeScript/Flutter best practices.
- Kommentarer för icke-obvious logik.
### 1.2 Performance-optimeringar (informational)
- Algoritmisk effektivitet.
- Onödiga kopior/serialiseringar.
- Databasfrågor, N+1-risk, ineffektiva `include/select`.
### 1.3 Säkerhetsanalys
- Injection/XSS/CSRF/insecure deserialization.
- Inputvalidering och filuppladdningar.
- Secrets i kod/loggar.
- Känslig data i klartext eller otillräckligt maskerad.
### 1.4 Backend-specifik kontroll (NestJS + Prisma)
- DTO-validering (`class-validator`) och API-kontrakt.
- Auktorisation/scope (IDOR-skydd, admin-guards).
- Prisma-scope i `where`, transaktioner vid multipla writes.
- Timeout/retry/rate limiting och robust felhantering.
---
### **1b. Performance-optimeringar** (INFORMATIONAL)
## 2. Krav på varje fynd
Använd följande mall:
- **Severity**: `Critical|High|Medium|Low`
- **Evidence**: `code|test|runtime|Needs verification`
- **Delsystem**: `backend|frontend|db|infra`
- **Fil**: `<path:line>`
- **Risk**: kort riskbeskrivning
- **Varför**: varför detta är ett problem
- **Åtgärd**: konkret, realistisk fix
- **Verifiering**: kommando/test för att bekräfta fix
Dessa rapporteras men blockerar inte commit. Kan adresseras i senare iteration:
- **Algoritm-effektivitet**:
- Finns det O(n²) eller värre algoritmer som kan vara O(n)?
- Finns onödig kod (död kod, duplicerad logik)?
- **Resurser**:
- Kan minne eller CPU-användning reduceras (t.ex. undvika djupa kopior, använda streams)?
- Kan loopar eller databaserfrågor (Prisma) optimeras (t.ex. med caching, batch-behandling)?
- Finns N+1-frågor eller ineffektiva `include/select`-mönster?
**Severity**: `Low` eller `Medium` beroende på påverkan. Blockerar aldrig commit.
Blocking-fynd (`Critical/High`) listas först, därefter informational (`Medium/Low`).
---
### **2. Säkerhetsanalys**
- **Sårbarheter**:
- Finns det risk för SQL-injection (Prisma), XSS, CSRF, eller insecure deserialization?
- Används osäkra bibliotek (t.ex. föråldrade versioner av `axios`, `lodash`, `express`)?
- Finns det hårdkodade lösenord, API-nycklar eller tokens?
- Saknas input-validering (t.ex. för filupp laddningar, användarinmatning)?
## 3. Obligatoriskt outputformat
Returnera exakt i denna ordning:
- **Autentisering/auktorisation**:
- Finns det brister i JWT-hantering (t.ex. svaga algoritmer, saknade `exp`-fält)?
- Används HTTP istället för HTTPS?
- Saknas rate limiting för känsliga endpoints?
- **Datahantering**:
- Lagras känslig data (t.ex. lösenord) i klartext?
- Finns det loggning av känslig data?
- Används säkra krypteringsmetoder (t.ex. AES-256, bcrypt)?
---
### **2b. Backend-specifik kontroll (NestJS + Prisma)**
- **API-kontrakt och validering**:
- Kontrollera DTO-validering (`class-validator`) på indata till controllers.
- Kontrollera att controllers inte accepterar osanerad payload direkt till service/Prisma.
- Kontrollera att felhantering använder korrekta HTTP-statuskoder (inte generiska 500/400 i onödan).
- **Auktorisation och scope**:
- Kontrollera att user-scope upprätthålls i queries/mutationer (ingen IDOR).
- Kontrollera att admin-endpoints skyddas med rätt guards/roller.
- Kontrollera att privata resurser inte kan nås via andras ID.
- **Prisma och dataintegritet**:
- Kontrollera att `where`-villkor inkluderar rätt scope (t.ex. `userId`) där det krävs.
- Kontrollera transaction-användning vid multipla skrivoperationer.
- Kontrollera risk för N+1-frågor och föreslå `include/select`-optimering där relevant.
- **Drift och robusthet**:
- Kontrollera rate limiting/throttling på känsliga endpoints.
- Kontrollera att loggar inte exponerar tokens, lösenord eller fulla stacktraces i produktion.
- Kontrollera timeout/retry-strategi vid anrop till externa tjänster.
---
### **3. Sammanfattning**
- **Topp 6 kritiska åtgärder** (prioriterade efter risk/vinst).
- **Uppskattad tid** för att implementera förslagen.
- **Rekommenderade verktyg** för automatiserade kontroller (t.ex. `ESLint`, `Prisma Lint`, `OWASP Dependency-Check`).
---
### **Klassificering av fynd (Severity)**
**BLOCKING** (hindrar commit):
- `Critical`: Säkerhetshål, scope-brister (IDOR), SQL-injection, XSS, eller data-loss risk.
- `High`: Allvarlig korrektness-fel, felaktig autentisering/auktorisation, eller felaktig felhantering som påverkar produktion.
**INFORMATIONAL** (rapporteras, men blockerar inte):
- `Medium`: Code-quality, läsbarhet, testluckor, eller mindre performance-optimeringar.
- `Low`: Stilfrågor, dokumentation, eller nice-to-have refactor.
**Regel**: Gate-beslut = `PASS` om inga `Critical` eller `High` finns. `BLOCK` annars.
---
### **Regler för analysen**
- Var **specifik**: Ge **kod-exempel** för varje förslag.
- Var **praktisk**: Fokusera på **realistiska förbättringar** som kan implementeras nu.
- Var **kritisk**: Peka ut **allvarliga risker** (t.ex. säkerhetshål) först.
- Använd **severity** per fynd enligt klassificering ovan: `Critical`, `High`, `Medium`, `Low`.
- För varje fynd: ange fil, kort riskbeskrivning, varför det är ett problem, severity, och konkret åtgärd.
- **Separa fynd efter severity**: Listet först alla `Critical`/`High` (blocking), sedan `Medium`/`Low` (informational).
- Om inga allvarliga risker hittas: skriv det explicit och lyft kvarvarande risker/testluckor.
- Ignorera filer som inte är relevanta (t.ex. node_modules, .git, binärfiler).
- Prioritera körbarhet: föreslagna åtgärder ska kunna göras i denna kodbas utan större arkitekturprojekt.
- Undvik generiska råd. Allt ska vara kopplat till faktisk kod i scope.
- När både frontend och backend finns i scope: dela upp fynd per delsystem.
- Om endast backendfiler finns i scope: lägg huvudfokus på sektion **2b** och prioritera säkerhet/scope före stilfrågor.
---
### **Outputformat (obligatoriskt)**
1. `Scope`
2. `Gate-beslut` (`PASS` eller `BLOCK`)
3. `1. Allmän kodkvalitet` (blocking issues)
4. `1b. Performance-optimeringar` (informational)
5. `2. Säkerhetsanalys` (blocking issues)
6. `2b. Backend-specifik kontroll` (blocking + informational)
7. `3. Sammanfattning` (topprioriteringar, tidskattning)
- Urvalsregel: `staged` eller `working-tree`
- Analyserade filer (exakt lista)
- Exkluderade filer (med orsak)
2. `Gate-beslut`
- `PASS|PASS_WITH_WARNINGS|BLOCK`
- Antal per severity: `Critical`, `High`, `Medium`, `Low`
- Kort motivering
3. `Blocking Findings (Critical/High)`
- Om inga finns: skriv `Inga blockerande fynd`.
4. `Informational Findings (Medium/Low)`
- Max 5 fynd.
5. `Fixplan (vid BLOCK eller PASS_WITH_WARNINGS)`
- Numrerad ordning, konkreta steg.
6. `Sammanfattning`
- Topp 3 åtgärder efter risk/vinst
- Tidsestimat
- Rekommenderade automatiserade kontroller
---
## 4. Konsistenskontroller (måste uppfyllas)
- Om `Gate-beslut=PASS` får inga `Critical/High` listas.
- Om `Gate-beslut=BLOCK` måste `Fixplan` innehålla minst 1 konkret blockerande åtgärd.
- Om `PASS_WITH_WARNINGS` används måste exakt 1 `High` finnas och 0 `Critical`.
---
## 5. Fallback: inget att analysera
Om inga relevanta filer hittas:
- Skriv `Inget att analysera` och varför (t.ex. tom staged + tom working tree).
- Föreslå nästa konkreta steg (t.ex. stagea filer och kör prompten igen).
- Ge nästa konkreta steg:
- `git add <filer>`
- `git diff --cached --name-only`
- Kör analysen 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.
## 6. Kontext för projektet
- Backend: NestJS + Prisma + MariaDB (Docker).
- Frontend: Next.js + TypeScript + Flutter.
- Mål: produktion, låg teknisk skuld, säkrad hantering av känslig data.
---
### **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".
## 7. CI-koppling
- Detta är lokalt pre-commit-steg.
- Samma kvalitetskrav bör speglas i CI (push/PR) för att minska miljöskillnader.
+44 -11
View File
@@ -20,7 +20,8 @@ class ProductApiPaths {
static String setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id';
static String canonicalName(int id) => '/products/$id/canonical-name';
static String canonicalNamePrivate(int id) => '/products/private/$id/canonical-name';
static String canonicalNamePrivate(int id) =>
'/products/private/$id/canonical-name';
static String remove(int id) => '/products/$id';
static String restore(int id) => '/products/$id/restore';
static const bulkUpdate = '/products/bulk-update';
@@ -28,6 +29,27 @@ class ProductApiPaths {
class AiApiPaths {
static const models = '/ai/models';
static String traces({
required String source,
int? limit,
String? cursor,
String? period,
bool onlyErrors = false,
}) {
final params = <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 {
@@ -43,13 +65,17 @@ class FlyerImportApiPaths {
static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
static String sourceBySession(int sessionId) =>
'/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) =>
'/flyer-import/sessions/$sessionId/items/$itemId';
}
class FlyerSelectionApiPaths {
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
static String bySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const open = '/flyer-selections/open';
@@ -57,7 +83,8 @@ class FlyerSelectionApiPaths {
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
static String updateStatus(int itemId) =>
'/shopping-list/items/$itemId/status';
}
class HelpTextApiPaths {
@@ -77,7 +104,8 @@ class RecipeApiPaths {
static String remove(int id) => '/recipes/$id';
static String setVisibility(int id) => '/recipes/$id/visibility';
static String share(int id) => '/recipes/$id/share';
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
static String unshare(int id, String username) =>
'/recipes/$id/share/${Uri.encodeComponent(username)}';
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static String analysis(int id) => '/recipes/$id/analysis';
static String rematch(int id) => '/recipes/$id/rematch';
@@ -92,9 +120,11 @@ class InventoryApiPaths {
static String update(int id) => '/inventory/$id';
static String remove(int id) => '/inventory/$id';
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry';
static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry';
static String moveToPantryAdmin(int id) =>
'/inventory/admin/$id/move-to-pantry';
static String consume(int id) => '/inventory/$id/consume';
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
static String consumptionHistory(int id) =>
'/inventory/$id/consumption-history';
}
class AdminInventoryApiPaths {
@@ -105,10 +135,12 @@ class AdminInventoryApiPaths {
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
if (params.isEmpty) return list;
final query = params.entries
.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
.map((e) =>
'${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
return '$list?$query';
}
static String update(int id) => '/inventory/admin/$id';
static String remove(int id) => '/inventory/admin/$id';
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
@@ -121,7 +153,8 @@ class PantryApiPaths {
static const list = '/pantry';
static String remove(int id) => '/pantry/$id';
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory';
static String moveToInventoryAdmin(int id) =>
'/pantry/admin/$id/move-to-inventory';
static const adminList = '/pantry/admin';
static const adminCreate = '/pantry/admin';
static String adminUpdate(int id) => '/pantry/admin/$id';
+13 -5
View File
@@ -101,8 +101,8 @@ class AppShell extends ConsumerWidget {
}
}
final isRecipesRoute = location.startsWith('/recipes') &&
!location.startsWith('/recipes/');
final isRecipesRoute =
location.startsWith('/recipes') && !location.startsWith('/recipes/');
final isImportRoute = location == '/import';
final isAdminRoute = location.startsWith('/admin');
final adminTab = AdminViewTabX.fromQuery(
@@ -133,6 +133,12 @@ class AppShell extends ConsumerWidget {
selected: adminTab == AdminViewTab.database,
onSelected: (_) => navigateToAdminTab(AdminViewTab.database),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('AI'),
selected: adminTab == AdminViewTab.ai,
onSelected: (_) => navigateToAdminTab(AdminViewTab.ai),
),
],
),
);
@@ -140,7 +146,8 @@ class AppShell extends ConsumerWidget {
Widget shell = Scaffold(
appBar: AppBar(
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
title:
isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
bottom: isImportRoute
? const TabBar(
tabs: [
@@ -184,8 +191,9 @@ class AppShell extends ConsumerWidget {
PopupMenuButton<int>(
icon: const Icon(Icons.grid_view),
tooltip: 'Välj antal kolumner',
onSelected: (columns) =>
ref.read(recipesViewProvider.notifier).setColumns(columns),
onSelected: (columns) => ref
.read(recipesViewProvider.notifier)
.setColumns(columns),
itemBuilder: (context) => const [
PopupMenuItem(value: 2, child: Text('2 kolumner')),
PopupMenuItem(value: 4, child: Text('4 kolumner')),
@@ -9,6 +9,8 @@ import '../domain/admin_category_node.dart';
import '../domain/admin_pantry_item.dart';
import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.dart';
import '../domain/admin_ai_trace.dart';
import '../domain/admin_ai_trace_detail.dart';
import '../domain/ai_model_info.dart';
import '../domain/pending_product.dart';
import '../domain/receipt_alias.dart';
@@ -145,7 +147,8 @@ class AdminRepository {
(data['data'] as List<dynamic>?) ??
const [];
if (raw.isEmpty && data.isNotEmpty) {
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
debugPrint(
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
}
} else {
raw = const [];
@@ -172,7 +175,8 @@ class AdminRepository {
Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) =>
_patch(UserApiPaths.setRecipeSharing(userId),
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
body: {'canShareRecipes': canShareRecipes},
parse: UserAdmin.fromJson);
Future<void> updateEmail(int userId, String email) =>
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
@@ -194,7 +198,8 @@ class AdminRepository {
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 }`.
Future<Map<String, dynamic>> resetPassword(int userId) =>
@@ -203,7 +208,8 @@ class AdminRepository {
// ── Produkter ──────────────────────────────────────────────────────────────
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.')
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
@@ -249,7 +255,8 @@ class AdminRepository {
final list = merged.values.toList();
list.sort(
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
_selectableProductsCache = List<AdminProduct>.from(list);
_selectableProductsCacheAt = now;
@@ -263,7 +270,8 @@ class AdminRepository {
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
Future<void> setProductStatus(int productId, String status) =>
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
.then((_) {
_invalidateSelectableProductsCache();
});
@@ -271,14 +279,16 @@ class AdminRepository {
_post<AdminProduct>(
ProductApiPaths.promotePrivate(productId),
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) {
_invalidateSelectableProductsCache();
return value;
});
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
.then((_) {
_invalidateSelectableProductsCache();
});
@@ -301,7 +311,8 @@ class AdminRepository {
_invalidateSelectableProductsCache();
});
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
Future<void> updateCanonicalNamePrivate(
int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalNamePrivate(productId),
{'canonicalName': canonicalName.trim()},
@@ -336,7 +347,8 @@ class AdminRepository {
int _parseUpdatedCount(dynamic data) {
if (data is! Map) {
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
debugPrint(
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
return 0;
}
final map = Map<String, dynamic>.from(data);
@@ -391,8 +403,7 @@ class AdminRepository {
// ── Kategorier ─────────────────────────────────────────────────────────────
Future<List<AdminCategoryNode>> listCategoryTree() =>
_getList(
Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
CategoryApiPaths.tree,
AdminCategoryNode.fromJson,
requiresAuth: false,
@@ -404,6 +415,26 @@ class AdminRepository {
Future<List<AiModelInfo>> listAiModels() =>
_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) ───────────────────────────────────
Future<List<ReceiptAlias>> listReceiptAliases() =>
@@ -543,7 +574,8 @@ class AdminRepository {
if (location != null && location.trim().isNotEmpty)
'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,
}) =>
_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,10 +1,13 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../data/admin_repository.dart';
import '../domain/ai_model_info.dart';
import '../domain/admin_ai_trace.dart';
import '../domain/admin_ai_trace_detail.dart';
class AdminAiPanel extends ConsumerStatefulWidget {
final bool embedded;
@@ -18,7 +21,19 @@ class AdminAiPanel extends ConsumerStatefulWidget {
class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
bool _isLoading = true;
String? _error;
List<AiModelInfo> _models = [];
AdminAiTraceSource _source = AdminAiTraceSource.flyer;
String _period = '7d';
bool _onlyErrors = false;
List<AdminAiTraceListItem> _items = const [];
String? _nextCursor;
String? _selectedId;
AdminAiTraceDetail? _selected;
bool _isDetailLoading = false;
bool _promptExpanded = false;
String? _cachedOutputTraceId;
String? _cachedOutputPrettyJson;
@override
void initState() {
@@ -32,108 +47,491 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
_error = null;
});
try {
final models = await ref.read(adminRepositoryProvider).listAiModels();
final response = await ref.read(adminRepositoryProvider).listAiTraces(
source: _source,
limit: 30,
period: _period,
onlyErrors: _onlyErrors,
);
if (!mounted) return;
setState(() => _models = models);
final selectedId =
response.items.isEmpty ? null : response.items.first.id;
setState(() {
_items = response.items;
_nextCursor = response.nextCursor;
_selectedId = selectedId;
_selected = null;
_promptExpanded = false;
_cachedOutputTraceId = null;
_cachedOutputPrettyJson = null;
});
if (selectedId != null) {
await _loadDetail(selectedId);
}
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Color _chipColor(String value, ColorScheme scheme) {
final lower = value.toLowerCase();
if (lower.contains('admin')) return scheme.primaryContainer;
if (lower.contains('premium')) return scheme.tertiaryContainer;
return scheme.secondaryContainer;
Future<void> _loadMore() async {
if (_nextCursor == null || _nextCursor!.isEmpty) return;
try {
final response = await ref.read(adminRepositoryProvider).listAiTraces(
source: _source,
limit: 30,
cursor: _nextCursor,
period: _period,
onlyErrors: _onlyErrors,
);
if (!mounted) return;
setState(() {
_items = [..._items, ...response.items];
_nextCursor = response.nextCursor;
});
} catch (_) {
// Ignore soft pagination failures.
}
}
Future<void> _loadDetail(String id) async {
setState(() {
_isDetailLoading = true;
_selected = null;
_cachedOutputTraceId = null;
_cachedOutputPrettyJson = null;
});
try {
final detail = await ref.read(adminRepositoryProvider).getAiTraceById(id);
if (!mounted) return;
setState(() => _selected = detail);
} catch (_) {
if (!mounted) return;
setState(() => _selected = null);
} finally {
if (mounted) {
setState(() => _isDetailLoading = false);
}
}
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _copyText(String value, String label) async {
await Clipboard.setData(ClipboardData(text: value));
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$label kopierad')));
}
String _prettyJson(Object? data) {
if (data == null) return '{}';
return const JsonEncoder.withIndent(' ').convert(data);
}
Color _statusColor(AdminAiTraceStatus status, ColorScheme scheme) {
return switch (status) {
AdminAiTraceStatus.success => Colors.green.shade700,
AdminAiTraceStatus.warning => Colors.orange.shade700,
AdminAiTraceStatus.error => scheme.error,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa AI-modeller',
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.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
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.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('AI', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
context.l10n.adminAiDescription,
style: theme.textTheme.bodyMedium,
_buildTopFilters(),
const SizedBox(height: 12),
Expanded(child: content),
],
),
const SizedBox(height: 8),
const Wrap(
);
}
Widget _buildTopFilters() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text('Models')),
Chip(label: Text('Access')),
Chip(label: Text('Trigger')),
],
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();
},
),
],
),
),
),
const SizedBox(height: 12),
if (_models.isEmpty)
Card(
);
}
Widget _buildTraceList() {
final theme = Theme.of(context);
if (_items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Inga AI-modeller hittades.',
_source == AdminAiTraceSource.receipt
? 'Receipt trace-data saknas i recipe-api i denna fas.'
: 'Inga importer matchar valda filter.',
style: theme.textTheme.bodyMedium,
),
),
),
..._models.map(
(model) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
);
}
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(model.name, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(model.description),
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),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(model.model)),
Chip(
label: Text(model.access),
backgroundColor: _chipColor(model.access, theme.colorScheme),
),
Chip(label: Text(model.trigger)),
],
),
const SizedBox(height: 8),
Text('${context.l10n.adminPagePrefix}${model.path}', style: theme.textTheme.bodySmall),
],
),
),
_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/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import 'admin_ai_panel.dart';
import 'admin_aliases_panel.dart';
import 'admin_inventory_panel.dart';
import 'admin_pantry_panel.dart';
@@ -14,7 +13,14 @@ import 'admin_pending_products_panel.dart';
import 'admin_products_panel.dart';
import '../../profile/data/profile_repository.dart';
enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai }
enum _DatabaseTab {
inventory,
pantry,
products,
privateProducts,
pending,
aliases
}
class _DatabaseTabConfig {
final _DatabaseTab tab;
@@ -98,11 +104,6 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
title: 'Alias',
panel: const AdminAliasesPanel(embedded: true),
),
_DatabaseTabConfig(
tab: _DatabaseTab.ai,
title: 'AI',
panel: const AdminAiPanel(embedded: true),
),
];
Future<void> _refreshCategories() async {
@@ -125,7 +126,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
@override
Widget build(BuildContext context) {
final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab);
final currentTab =
_tabConfigs.firstWhere((config) => config.tab == _activeTab);
final header = Card(
child: Padding(
@@ -146,7 +148,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
child: ChoiceChip(
label: Text(config.title),
selected: _activeTab == config.tab,
onSelected: (_) => setState(() => _activeTab = config.tab),
onSelected: (_) =>
setState(() => _activeTab = config.tab),
),
),
)
@@ -157,7 +160,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(width: 8),
IconButton(
tooltip: 'Uppdatera kategorier',
onPressed: _isRefreshingCategories ? null : _refreshCategories,
onPressed:
_isRefreshingCategories ? null : _refreshCategories,
icon: _isRefreshingCategories
? const SizedBox(
height: 16,
@@ -183,7 +187,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
const SizedBox(height: 12),
Expanded(
child: KeyedSubtree(
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
key:
ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
child: currentTab.panel,
),
),
@@ -192,4 +197,3 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
);
}
}
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'admin_ai_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) {
return switch (value) {
'database' => AdminViewTab.database,
'ai' => AdminViewTab.ai,
_ => AdminViewTab.users,
};
}
String get queryValue => this == AdminViewTab.database ? 'database' : 'users';
String get queryValue => switch (this) {
AdminViewTab.users => 'users',
AdminViewTab.database => 'database',
AdminViewTab.ai => 'ai',
};
}
class AdminScreen extends StatelessWidget {
@@ -25,6 +31,7 @@ class AdminScreen extends StatelessWidget {
final activePanel = switch (initialTab) {
AdminViewTab.users => const AdminUsersPanel(embedded: true),
AdminViewTab.database => const AdminDatabasePanel(embedded: true),
AdminViewTab.ai => const AdminAiPanel(embedded: true),
};
return Padding(
@@ -33,4 +40,3 @@ class AdminScreen extends StatelessWidget {
);
}
}
+130 -39
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/admin/data/admin_repository.dart';
import 'package:recipe_flutter/features/admin/domain/admin_ai_categorize_result.dart';
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace.dart';
import 'package:recipe_flutter/features/admin/domain/admin_ai_trace_detail.dart';
import 'package:recipe_flutter/features/admin/domain/admin_category_node.dart';
import 'package:recipe_flutter/features/admin/domain/admin_inventory_item.dart';
import 'package:recipe_flutter/features/admin/domain/admin_pantry_item.dart';
@@ -20,99 +22,187 @@ class TestAdminRepositoryWrapper implements AdminRepository {
TestAdminRepositoryWrapper(this._fakeRepo);
@override
Future<List<ReceiptAlias>> listReceiptAliases() => _fakeRepo.listReceiptAliases();
Future<List<ReceiptAlias>> listReceiptAliases() =>
_fakeRepo.listReceiptAliases();
@override
Future<List<AdminProduct>> listGlobalProducts() => _fakeRepo.listGlobalProducts();
Future<List<AdminProduct>> listGlobalProducts() =>
_fakeRepo.listGlobalProducts();
@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
@override
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({List<int>? productIds}) => throw UnimplementedError();
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk(
{List<int>? productIds}) =>
throw UnimplementedError();
@override
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) => throw UnimplementedError();
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
throw UnimplementedError();
@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
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
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => throw UnimplementedError();
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
throw UnimplementedError();
@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
Future<void> deleteUser(int userId) => throw UnimplementedError();
@override
Future<List<AdminInventoryItem>> listAdminInventory({int? userId, String? sort}) => throw UnimplementedError();
Future<List<AdminInventoryItem>> listAdminInventory(
{int? userId, String? sort}) =>
throw UnimplementedError();
@override
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) => throw UnimplementedError();
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) =>
throw UnimplementedError();
@override
Future<List<AiModelInfo>> listAiModels() => throw UnimplementedError();
@override
Future<List<AdminCategoryNode>> listCategoryTree() => throw UnimplementedError();
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
throw UnimplementedError();
@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
Future<List<PendingProduct>> listPendingProducts() => throw UnimplementedError();
Future<List<AdminCategoryNode>> listCategoryTree() =>
throw UnimplementedError();
@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
Future<List<AdminProduct>> listProducts() => throw UnimplementedError();
@override
Future<List<AdminProduct>> listSelectableProductsForAdmin({bool forceRefresh = false}) => throw UnimplementedError();
Future<List<AdminProduct>> listSelectableProductsForAdmin(
{bool forceRefresh = false}) =>
throw UnimplementedError();
@override
Future<List<UserAdmin>> listUsers() => throw UnimplementedError();
@override
Future<void> mergeAdminInventory({required int sourceInventoryId, required int targetInventoryId}) => throw UnimplementedError();
Future<void> mergeAdminInventory(
{required int sourceInventoryId, required int targetInventoryId}) =>
throw UnimplementedError();
@override
Future<void> mergeProducts({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
Future<void> mergeProducts(
{required int sourceProductId, required int targetProductId}) =>
throw UnimplementedError();
@override
Future<void> mergeProductsPrivate({required int sourceProductId, required int targetProductId}) => throw UnimplementedError();
Future<void> mergeProductsPrivate(
{required int sourceProductId, required int targetProductId}) =>
throw UnimplementedError();
@override
Future<void> moveAdminInventoryToPantry(int inventoryId) => throw UnimplementedError();
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
throw UnimplementedError();
@override
Future<void> moveAdminPantryToInventory(int pantryItemId, Map<String, dynamic> body) => throw UnimplementedError();
Future<void> moveAdminPantryToInventory(
int pantryItemId, Map<String, dynamic> body) =>
throw UnimplementedError();
@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
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
Future<AdminProduct> promotePrivateProduct(int productId) => throw UnimplementedError();
Future<AdminProduct> promotePrivateProduct(int productId) =>
throw UnimplementedError();
@override
Future<void> removeAdminInventory(int inventoryId) => throw UnimplementedError();
Future<void> removeAdminInventory(int inventoryId) =>
throw UnimplementedError();
@override
Future<void> removeAdminPantryItem(int pantryItemId) => throw UnimplementedError();
Future<void> removeAdminPantryItem(int pantryItemId) =>
throw UnimplementedError();
@override
Future<void> removeProduct(int productId) => throw UnimplementedError();
@override
Future<void> removeReceiptAlias(int id) => throw UnimplementedError();
@override
Future<Map<String, dynamic>> resetPassword(int userId) => throw UnimplementedError();
Future<Map<String, dynamic>> resetPassword(int userId) =>
throw UnimplementedError();
@override
Future<void> restoreProduct(int productId) => throw UnimplementedError();
@override
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) => throw UnimplementedError();
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
throw UnimplementedError();
@override
Future<void> setProductCategory(int productId, {required int? categoryId}) => throw UnimplementedError();
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
throw UnimplementedError();
@override
Future<void> setProductStatus(int productId, String status) => throw UnimplementedError();
Future<void> setProductStatus(int productId, String status) =>
throw UnimplementedError();
@override
Future<UserAdmin> setRecipeSharing(int userId, {required bool canShareRecipes}) => throw UnimplementedError();
Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) =>
throw UnimplementedError();
@override
Future<UserAdmin> setRole(int userId, String newRole) => throw UnimplementedError();
Future<UserAdmin> setRole(int userId, String newRole) =>
throw UnimplementedError();
@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
Future<AdminPantryItem> updateAdminPantry(int pantryItemId, {int? productId, String? location}) => throw UnimplementedError();
Future<AdminPantryItem> updateAdminPantry(int pantryItemId,
{int? productId, String? location}) =>
throw UnimplementedError();
@override
Future<void> updateCanonicalName(int productId, String canonicalName) => throw UnimplementedError();
Future<void> updateCanonicalName(int productId, String canonicalName) =>
throw UnimplementedError();
@override
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) => throw UnimplementedError();
Future<void> updateCanonicalNamePrivate(
int productId, String canonicalName) =>
throw UnimplementedError();
@override
Future<void> updateEmail(int userId, String email) => throw UnimplementedError();
Future<void> updateEmail(int userId, String email) =>
throw UnimplementedError();
@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
@@ -128,7 +218,8 @@ class FakeAdminRepository {
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
final index = _aliases.indexWhere((a) => a.id == id);
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));
});
});
}