Files
Cibello-app/apps/api/src/routes/scans.ts
T
Claude c72c7ec25a fix(skann): dubblett-koll aven inom samma foto (intra-batch)
Tva traffar pa samma vara i ETT foto (t.ex. "helmjolk 4dl" + "helmjolk 8dl")
dubbellagrades, eftersom activeItems hamtades en gang fore loopen och nyss
inlagda varor inte lades tillbaka. Nu pushas varje inlagd vara till
activeItems sa resten av batchen slar ihop mot den. Cross-scan-dedup fanns
redan; det har tacker fallet inom samma bild.
2026-08-18 17:48:01 +00:00

501 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ScanType, JobType> = {
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<Record<ScanType, string>> = {
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 §4546) 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<unknown> }).lookupBarcode(
gtin,
);
if (result && typeof result === "object") {
const p = result as {
gtin: string;
name?: string;
brand?: string;
ingredientsText?: string;
nutrimentsPer100g: Record<string, number | undefined>;
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<string, unknown> | 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<string, unknown> = { 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,
});
}