import type { FastifyInstance } from "fastify"; import { and, eq, gt, isNull } from "drizzle-orm"; import { markMilestone, schema, trackProductAnalytics } from "@app/database"; import { scanStarted } from "@app/analytics"; import type { JobType, ScanType } from "@app/shared-types"; import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation"; import { errors, parse } from "../lib/errors.js"; import { emitEvent, requireActiveHousehold } from "../lib/helpers.js"; import { consumeAiScan } from "../lib/entitlements.js"; import { normalizeItemName } from "@app/inventory-engine"; /** * Skanningsflödet (spec §50): * App → POST /v1/scans (kvotkontroll + presignade upload-URL:er) * → PUT bild(er) till storage * → POST /v1/scans/:id/start (läggs på kö → worker → AAMOS) * → GET /v1/scans/:id (poll: status + resultat) * → POST /v1/scans/:id/confirm (användaren godkänner → lagret uppdateras) * * Användarbekräftelse är obligatorisk innan något skrivs till Food Twin * (spec §10, §61.5). Korrigeringar sparas som ai_corrections (spec §33). */ const SCAN_TO_JOB: Record = { fridge: "ANALYZE_FRIDGE_IMAGE", freezer: "ANALYZE_FRIDGE_IMAGE", pantry: "ANALYZE_PANTRY_IMAGE", ingredients: "ANALYZE_PANTRY_IMAGE", plate: "ANALYZE_MEAL_IMAGE", receipt: "READ_RECEIPT", barcode: "NORMALIZE_PRODUCTS", expiry_date: "READ_EXPIRY_DATE", nutrition_label: "READ_NUTRITION_LABEL", product_package: "READ_NUTRITION_LABEL", }; const S3_PREFIX: Partial> = { fridge: "fridge-scans", freezer: "fridge-scans", pantry: "pantry-scans", ingredients: "pantry-scans", plate: "meal-scans", receipt: "receipts", expiry_date: "product-images", nutrition_label: "product-images", product_package: "product-images", }; export async function scanRoutes(app: FastifyInstance) { const auth = { preHandler: [app.authenticate] }; app.post("/v1/scans", auth, async (req, reply) => { const input = parse(createScanInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); // Streckkod är gratis uppslag utan AI – hanteras direkt (spec §11: lokalt + databas). if (input.scanType === "barcode") { if (!input.barcode) throw errors.badRequest("barcode krävs för streckkodsskanning."); const product = await lookupBarcode(app, input.barcode); const [job] = await app.db .insert(schema.scanJobs) .values({ userId: req.userId, householdId, scanType: "barcode", jobType: "NORMALIZE_PRODUCTS", status: product ? "completed" : "failed", result: product ? { product } : null, error: product ? null : "Produkten hittades inte. Fota framsida + näringsdeklaration så lägger vi till den.", completedAt: new Date(), }) .returning(); return reply.status(201).send({ scan: job, product }); } // AI-skanning: kvotkontroll (fair use, spec §45–46) och presignade URL:er. await consumeAiScan(app.db, req.userId); const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`; const uploads = []; for (let i = 0; i < Math.max(1, input.imageCount); i++) { uploads.push(await app.storage.presignUpload(prefix, input.contentType)); } const [job] = await app.db .insert(schema.scanJobs) .values({ userId: req.userId, householdId, scanType: input.scanType, jobType: SCAN_TO_JOB[input.scanType], status: "queued", s3Keys: uploads.map((u) => u.key), context: input.context ?? null, }) .returning(); return reply.status(201).send({ scan: job, uploads }); }); app.post("/v1/scans/:id/start", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const job = await getOwnedScan(app, id, req.userId); if (job.status !== "queued") throw errors.conflict(`Jobbet är redan ${job.status}.`); await app.jobQueue.add(job.jobType, { scanJobId: job.id, jobType: job.jobType, correlationId: req.correlationId, }); await trackProductAnalytics(app.db, req.userId, { ...scanStarted(), householdId: job.householdId ?? undefined, properties: { scanType: job.scanType, jobType: job.jobType, }, }); return { ok: true, status: "queued" }; }); app.get("/v1/scans/:id", auth, async (req) => { const { id } = parse(idParamSchema, req.params); return getOwnedScan(app, id, req.userId); }); app.post("/v1/scans/:id/confirm", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const input = parse(confirmScanInputSchema, req.body); const job = await getOwnedScan(app, id, req.userId); if (job.status !== "awaiting_confirmation" && job.status !== "completed") { throw errors.conflict("Jobbet har inget resultat att bekräfta ännu."); } const householdId = job.householdId ?? (await requireActiveHousehold(app.db, req.userId)); const fallbackLocation = input.storageLocationId ?? (await defaultLocation(app, householdId, job.scanType)); const created: string[] = []; const proposals = extractProposals(job); // Aktiva varor i hushållet, för dubblett-hopslagning vid bekräftelse. const activeRows = await app.db .select({ id: schema.inventoryItems.id, canonicalIngredientId: schema.inventoryItems.canonicalIngredientId, displayName: schema.inventoryItems.displayName, }) .from(schema.inventoryItems) .where( and( eq(schema.inventoryItems.householdId, householdId), gt(schema.inventoryItems.quantity, 0), ), ); const activeItems = activeRows.map((r) => ({ ...r, norm: normalizeItemName(r.displayName) })); for (const item of input.items) { const proposal = findProposal(proposals, item.tempId); await recordCorrection(app, job, item, proposal); if (item.action === "reject") continue; const locationId = item.storageLocationId ?? fallbackLocation; if (!locationId) throw errors.badRequest("storageLocationId saknas och ingen standardplats finns."); // Dedup (#1): slå ihop om varan redan finns aktiv i hushållet, så att // överlappande foton / omfotografering av samma hylla inte dubbellagras. // Matcha på katalog-id ELLER normaliserat namn — Vision skriver sällan // exakt samma namn två gånger ("Mjölk" vs "mjölk" vs "Mjölk 1L"). const wantNorm = normalizeItemName(item.displayName); const dupe = activeItems.find( (r) => (item.canonicalIngredientId != null && r.canonicalIngredientId === item.canonicalIngredientId) || r.norm === wantNorm, ); if (dupe) { await app.db .update(schema.inventoryItems) .set({ quantity: item.quantity, unit: item.unit, brand: item.brand ?? null, bestBeforeDate: item.bestBeforeDate ?? null, useByDate: item.useByDate ?? null, lastVerifiedAt: new Date(), verifiedByUser: true, updatedAt: new Date(), }) .where(eq(schema.inventoryItems.id, dupe.id)); created.push(dupe.id); continue; } const [inv] = await app.db .insert(schema.inventoryItems) .values({ householdId, canonicalIngredientId: item.canonicalIngredientId ?? null, displayName: item.displayName, brand: item.brand ?? null, quantity: item.quantity, unit: item.unit, storageLocationId: locationId, sublocation: item.sublocation ?? null, bestBeforeDate: item.bestBeforeDate ?? null, useByDate: item.useByDate ?? null, priceMinor: item.priceMinor ?? null, purchasedAt: new Date().toISOString().slice(0, 10), source: scanSource(job.scanType), confidence: item.action === "accept" ? 0.9 : 1, verifiedByUser: true, lastVerifiedAt: new Date(), modelVersion: job.modelVersion, promptVersion: job.promptVersion, }) .returning(); // Gör den nyss inlagda varan sökbar för resten av SAMMA batch, så att // två träffar på samma vara i ett och samma foto (t.ex. "helmjölk 4dl" // + "helmjölk 8dl") slås ihop i stället för att dubbellagras. activeItems.push({ id: inv!.id, canonicalIngredientId: item.canonicalIngredientId ?? null, displayName: item.displayName, norm: wantNorm, }); await app.db.insert(schema.inventoryTransactions).values({ householdId, inventoryItemId: inv!.id, type: "purchase", quantityDelta: item.quantity, unit: item.unit, refType: "scan", refId: job.id, actorUserId: req.userId, valueMinor: item.priceMinor ?? null, }); await emitEvent(app.db, { type: "PRODUCT_ADDED", payload: { inventoryItemId: inv!.id, canonicalIngredientId: item.canonicalIngredientId ?? null, quantity: item.quantity, unit: item.unit, source: scanSource(job.scanType), }, userId: req.userId, householdId, correlationId: req.correlationId, }); created.push(inv!.id); } await app.db .update(schema.scanJobs) .set({ status: "completed", updatedAt: new Date() }) .where(eq(schema.scanJobs.id, id)); const acceptedCount = input.items.filter((i) => i.action !== "reject").length; await markMilestone(app.db, householdId, "firstScanCompletedAt"); if (acceptedCount >= 5) { await markMilestone(app.db, householdId, "fifthItemConfirmedAt"); } return { ok: true, createdItemIds: created }; }); app.get("/v1/scans", auth, async (req) => { const jobs = await app.db .select() .from(schema.scanJobs) .where(eq(schema.scanJobs.userId, req.userId)) .orderBy((await import("drizzle-orm")).desc(schema.scanJobs.createdAt)) .limit(30); return { scans: jobs }; }); } async function getOwnedScan(app: FastifyInstance, id: string, userId: string) { const [job] = await app.db .select() .from(schema.scanJobs) .where(eq(schema.scanJobs.id, id)) .limit(1); if (!job || job.userId !== userId) throw errors.notFound("Skanningen finns inte."); return job; } async function lookupBarcode(app: FastifyInstance, gtin: string) { // 1. Egen produktdatabas (aktuell version) const [own] = await app.db .select() .from(schema.products) .where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo))) .limit(1); if (own) return own; // 2. Open Food Facts (laglig öppen källa, spec §11) const off = app.connectors.get("open-food-facts"); if (off && "lookupBarcode" in off) { try { const result = await (off as { lookupBarcode(g: string): Promise }).lookupBarcode( gtin, ); if (result && typeof result === "object") { const p = result as { gtin: string; name?: string; brand?: string; ingredientsText?: string; nutrimentsPer100g: Record; imageUrl?: string; }; if (!p.name) return null; const n = p.nutrimentsPer100g; const [saved] = await app.db .insert(schema.products) .values({ gtin: p.gtin, name: p.name, brand: p.brand ?? null, ingredientsText: p.ingredientsText ?? null, nutrition: n.kcal != null ? { basis: "per_100_g", values: { kcal: n.kcal ?? 0, proteinG: n.proteinG ?? 0, carbsG: n.carbsG ?? 0, fatG: n.fatG ?? 0, saturatedFatG: n.saturatedFatG ?? 0, fiberG: n.fiberG ?? 0, sugarG: n.sugarG ?? 0, saltG: n.saltG ?? 0, }, } : null, imageUrls: p.imageUrl ? [p.imageUrl] : [], dataSource: "open_food_facts", verificationStatus: "unverified", }) .onConflictDoNothing() .returning(); return saved ?? null; } } catch (err) { app.log.warn({ err, gtin }, "OFF-uppslag misslyckades"); } } return null; } function scanSource(scanType: ScanType) { switch (scanType) { case "fridge": return "fridge_photo" as const; case "freezer": return "freezer_photo" as const; case "pantry": return "pantry_photo" as const; case "ingredients": return "ingredient_photo" as const; case "receipt": return "receipt" as const; case "barcode": return "barcode" as const; default: return "label_photo" as const; } } async function defaultLocation(app: FastifyInstance, householdId: string, scanType: ScanType) { const wanted = scanType === "freezer" ? "freezer" : scanType === "pantry" || scanType === "ingredients" ? "pantry" : "fridge"; const [loc] = await app.db .select({ id: schema.storageLocations.id }) .from(schema.storageLocations) .where( and( eq(schema.storageLocations.householdId, householdId), eq(schema.storageLocations.type, wanted), ), ) .limit(1); return loc?.id ?? null; } type ProposalItem = { tempId?: string; detectedName?: string; canonicalIngredientId?: string | null; brand?: string | null; estimatedQuantity?: number | null; unit?: string | null; bestBeforeDate?: string | null; confidence?: number; requiresConfirmation?: boolean; }; function extractProposals(job: { result: unknown }): ProposalItem[] { const result = job.result as Record | null; if (!result || !Array.isArray(result.items)) return []; return result.items.map((it, idx) => ({ tempId: String(it.tempId ?? idx), detectedName: String(it.detectedName ?? ""), canonicalIngredientId: it.canonicalIngredientId ?? null, brand: it.brand ?? null, estimatedQuantity: it.estimatedQuantity ?? null, unit: it.unit ?? null, bestBeforeDate: it.bestBeforeDate ?? null, confidence: typeof it.confidence === "number" ? it.confidence : null, requiresConfirmation: typeof it.requiresConfirmation === "boolean" ? it.requiresConfirmation : null, })); } function findProposal(proposals: ProposalItem[], tempId: string | undefined): ProposalItem | null { if (tempId == null) return null; return proposals.find((p) => p.tempId === tempId) ?? null; } async function recordCorrection( app: FastifyInstance, job: { id: string; userId: string; jobType: string; result: unknown; s3Keys: string[]; modelVersion: string | null; promptVersion: string | null; }, item: { tempId?: string; action: "accept" | "edit" | "reject" | "add"; displayName: string; quantity: number; unit: string; brand?: string; canonicalIngredientId?: string; bestBeforeDate?: string; useByDate?: string; }, proposal: ProposalItem | null, ) { const consents = await app.db .select() .from(schema.userConsents) .where(eq(schema.userConsents.userId, job.userId)); const snapshot = Object.fromEntries(consents.map((c) => [c.kind, c.status])); const hasImageConsent = snapshot.image_training === "granted"; const userCorrection: Record = { action: item.action }; if (item.action !== "reject") { userCorrection.corrected = { displayName: item.displayName, canonicalIngredientId: item.canonicalIngredientId ?? null, brand: item.brand ?? null, quantity: item.quantity, unit: item.unit, bestBeforeDate: item.bestBeforeDate ?? null, useByDate: item.useByDate ?? null, }; } await app.db.insert(schema.aiCorrections).values({ scanJobId: job.id, userId: job.userId, taskType: job.jobType, aiOutput: { raw: job.result }, proposal: proposal, userCorrection, imageS3Key: hasImageConsent && job.s3Keys.length > 0 ? job.s3Keys[0] : null, modelVersion: job.modelVersion, promptVersion: job.promptVersion, consentSnapshot: snapshot, }); await emitEvent(app.db, { type: "AI_CORRECTED", payload: { scanJobId: job.id, taskType: job.jobType, field: item.action, }, userId: job.userId, }); }