Files
Cibello-app/apps/api/src/routes/scans.ts
T
Claude 15e48c8704 feat(priser): kvittopris -> lager + blandat marknadspris per ingrediens
Bekraftade kvittovaror far sitt netto-pris (efter rabatt) i lagret. Prisupp-
skattningar anvander nu ett robust marknadssnitt: median av observerade pris/kg
fran lagret, klampat mot schablonen och viktat efter antal observationer -> ett
'realtidsnara' pris dar en extremt dyr/billig butik inte drar ivag snittet.
2026-08-19 20:18:01 +00:00

582 lines
20 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 { findDuplicateCandidates, 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). Debiteras per bild
// 6 foton = 6 skanningar. Streckkod (ovan) är gratis och drar inget.
await consumeAiScan(app.db, req.userId, Math.max(1, input.imageCount));
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);
const job = await getOwnedScan(app, id, req.userId);
return annotateScanDuplicates(app, job);
});
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 ?? proposal?.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 ?? proposal?.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;
}
/**
* Flaggar troliga dubbletter i ett skann-resultat INNAN användaren bekräftar:
* jämför varje föreslagen vara mot aktivt lager OCH mot tidigare varor i samma
* skanning (så samma hylla från två vinklar fångas). Ren hint appen slår
* aldrig ihop utan bekräftelse (spec §9); användaren väljer i granskningen.
*/
async function annotateScanDuplicates(
app: FastifyInstance,
job: Awaited<ReturnType<typeof getOwnedScan>>,
) {
const result = job.result as { items?: Array<Record<string, unknown>> } | null;
if (!result?.items?.length || !job.householdId) return job;
const rows = await app.db
.select({
id: schema.inventoryItems.id,
canonicalIngredientId: schema.inventoryItems.canonicalIngredientId,
displayName: schema.inventoryItems.displayName,
brand: schema.inventoryItems.brand,
quantity: schema.inventoryItems.quantity,
source: schema.inventoryItems.source,
createdAt: schema.inventoryItems.createdAt,
})
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, job.householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const existing = rows.map((r) => ({
id: r.id,
canonicalIngredientId: r.canonicalIngredientId,
displayName: r.displayName,
brand: r.brand,
quantity: Number(r.quantity) || 0,
source: r.source,
createdAt: r.createdAt.toISOString(),
}));
const source = scanSource(job.scanType);
const now = new Date().toISOString();
// Jämför bara mot TIDIGARE bilder i samma skanning när det finns FLERA foton
// (överlappande vinklar). I ett enda foto är två liknande behållare oftast
// två riktiga varor flagga dem aldrig mot varandra.
const multiImage = (job.s3Keys?.length ?? 0) > 1;
const seen: typeof existing = [];
const items = result.items.map((it, idx) => {
const input = {
canonicalIngredientId: (it.canonicalIngredientId as string | null) ?? null,
displayName: String(it.detectedName ?? ""),
brand: (it.brand as string | null) ?? null,
quantity: typeof it.estimatedQuantity === "number" ? it.estimatedQuantity : 1,
source,
createdAt: now,
};
const pool = multiImage ? [...existing, ...seen] : existing;
const [top] = findDuplicateCandidates(input, pool);
if (multiImage) seen.push({ id: String(it.tempId ?? idx), ...input });
if (!top) return { ...it, possibleDuplicate: null };
const match = pool.find((p) => p.id === top.itemId);
return {
...it,
possibleDuplicate: {
name: match?.displayName ?? "",
reason: top.reasons[0] ?? "liknande vara",
score: top.score,
},
};
});
return { ...job, result: { ...result, items } };
}
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;
priceMinor?: number | null;
};
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,
priceMinor: typeof it.priceMinor === "number" ? it.priceMinor : 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,
});
}