Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+368
View File
@@ -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 §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,
});
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,
});
}