Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 §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,
|
||||
});
|
||||
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[] = [];
|
||||
for (const item of input.items) {
|
||||
if (item.action === "reject") {
|
||||
await recordCorrection(app, job, item.tempId ?? null, { action: "reject" });
|
||||
continue;
|
||||
}
|
||||
if (item.action === "edit" || item.action === "add") {
|
||||
await recordCorrection(app, job, item.tempId ?? null, {
|
||||
action: item.action,
|
||||
corrected: { name: item.displayName, quantity: item.quantity, unit: item.unit },
|
||||
});
|
||||
}
|
||||
const locationId = item.storageLocationId ?? fallbackLocation;
|
||||
if (!locationId)
|
||||
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
|
||||
|
||||
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();
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function recordCorrection(
|
||||
app: FastifyInstance,
|
||||
job: {
|
||||
id: string;
|
||||
userId: string;
|
||||
jobType: string;
|
||||
result: unknown;
|
||||
modelVersion: string | null;
|
||||
promptVersion: string | null;
|
||||
},
|
||||
tempId: string | null,
|
||||
correction: Record<string, unknown>,
|
||||
) {
|
||||
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]));
|
||||
await app.db.insert(schema.aiCorrections).values({
|
||||
scanJobId: job.id,
|
||||
userId: job.userId,
|
||||
taskType: job.jobType,
|
||||
aiOutput: { tempId, raw: job.result },
|
||||
userCorrection: correction,
|
||||
modelVersion: job.modelVersion,
|
||||
promptVersion: job.promptVersion,
|
||||
consentSnapshot: snapshot,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "AI_CORRECTED",
|
||||
payload: {
|
||||
scanJobId: job.id,
|
||||
taskType: job.jobType,
|
||||
field: String(correction.action ?? "unknown"),
|
||||
},
|
||||
userId: job.userId,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user