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
+427
View File
@@ -0,0 +1,427 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { z } from "zod";
import { adminGrantInputSchema, totpCodeInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit } from "../lib/helpers.js";
import { generateTotpSecret, otpauthUrl, verifyTotp } from "../lib/totp.js";
import { BRAND } from "@app/shared-types";
/** Adminpanelens API (spec §57). Alla anrop kräver admin-roll och auditloggas. */
export async function adminRoutes(app: FastifyInstance) {
const admin = { preHandler: [app.requireAdmin] };
// --- Users ---
app.get("/admin/v1/users", admin, async (req) => {
const q = z
.object({
search: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const conditions = q.search ? ilike(schema.users.email, `%${q.search}%`) : undefined;
const users = await app.db
.select({
id: schema.users.id,
email: schema.users.email,
displayName: schema.users.displayName,
role: schema.users.role,
onboardingCompleted: schema.users.onboardingCompleted,
createdAt: schema.users.createdAt,
deletedAt: schema.users.deletedAt,
})
.from(schema.users)
.where(conditions)
.orderBy(desc(schema.users.createdAt))
.limit(q.limit)
.offset(q.offset);
return { users };
});
// --- Receptmoderering (spec §35 steg 4) ---
app.get("/admin/v1/moderation/recipes", admin, async () => {
const pending = await app.db
.select()
.from(schema.recipes)
.where(sql`${schema.recipes.status} IN ('submitted', 'ai_checked', 'in_moderation')`)
.orderBy(schema.recipes.createdAt)
.limit(50);
return { recipes: pending };
});
app.post("/admin/v1/moderation/recipes/:id", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z
.object({
action: z.enum(["approve", "reject", "request_changes"]),
note: z.string().max(1000).optional(),
})
.parse(req.body);
const status =
body.action === "approve" ? "published" : body.action === "reject" ? "rejected" : "draft";
const [recipe] = await app.db
.update(schema.recipes)
.set({ status, moderationNote: body.note ?? null, updatedAt: new Date() })
.where(eq(schema.recipes.id, params.id))
.returning();
if (!recipe) throw errors.notFound();
if (body.action === "approve" && recipe.creatorUserId) {
await app.db
.insert(schema.creatorStats)
.values({ userId: recipe.creatorUserId, publishedRecipes: 1 })
.onConflictDoUpdate({
target: schema.creatorStats.userId,
set: {
publishedRecipes: sql`${schema.creatorStats.publishedRecipes} + 1`,
updatedAt: new Date(),
},
});
}
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: `moderation.recipe_${body.action}`,
targetType: "recipe",
targetId: params.id,
metadata: { note: body.note },
});
return recipe;
});
// --- Feature flags (spec §57) ---
app.get("/admin/v1/flags", admin, async () => {
return {
flags: await app.db.select().from(schema.featureFlags).orderBy(schema.featureFlags.key),
};
});
app.put("/admin/v1/flags/:key", admin, async (req) => {
const params = z.object({ key: z.string().max(60) }).parse(req.params);
const body = z
.object({
enabled: z.boolean(),
rolloutPercent: z.number().int().min(0).max(100).default(100),
descriptionSv: z.string().max(300).optional(),
})
.parse(req.body);
const [flag] = await app.db
.insert(schema.featureFlags)
.values({ key: params.key, ...body })
.onConflictDoUpdate({
target: schema.featureFlags.key,
set: { ...body, updatedAt: new Date() },
})
.returning();
app.flags.invalidate();
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "flags.updated",
targetType: "feature_flag",
targetId: params.key,
metadata: body,
});
return flag;
});
// --- Subscriptions ---
app.get("/admin/v1/subscriptions", admin, async () => {
const subs = await app.db
.select()
.from(schema.subscriptions)
.orderBy(desc(schema.subscriptions.updatedAt))
.limit(100);
return { subscriptions: subs };
});
app.post("/admin/v1/subscriptions/grant", admin, async (req) => {
const input = parse(adminGrantInputSchema, req.body);
if (input.plan === "free")
throw errors.badRequest("Använd delete i stället för att sätta free.");
const [sub] = await app.db
.insert(schema.subscriptions)
.values({
userId: input.userId,
provider: "promo",
productId: `promo_${input.plan}`,
plan: input.plan,
status: "active",
purchasedAt: new Date(),
expiresAt: new Date(Date.now() + input.days * 86_400_000),
lastVerifiedAt: new Date(),
})
.returning();
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "subscription.granted",
targetType: "user",
targetId: input.userId,
metadata: { plan: input.plan, days: input.days, reason: input.reason },
});
return sub;
});
// --- Jobb & systemhälsa (spec §5758) ---
app.get("/admin/v1/jobs/overview", admin, async () => {
const scanStats = await app.db
.select({ status: schema.scanJobs.status, count: sql<number>`count(*)` })
.from(schema.scanJobs)
.groupBy(schema.scanJobs.status);
const queueCounts = await app.jobQueue.getJobCounts();
const unpublishedEvents = await app.db
.select({ count: sql<number>`count(*)` })
.from(schema.domainEvents)
.where(sql`${schema.domainEvents.publishedAt} IS NULL`);
return {
scanJobs: scanStats,
queue: queueCounts,
outboxPending: Number(unpublishedEvents[0]?.count ?? 0),
};
});
app.get("/admin/v1/system/health", admin, async () => {
const checks: Record<string, { ok: boolean; detail?: string }> = {};
try {
await app.db.execute(sql`SELECT 1`);
checks.database = { ok: true };
} catch (err) {
checks.database = { ok: false, detail: String(err) };
}
try {
const pong = await app.redis.ping();
checks.redis = { ok: pong === "PONG" };
} catch (err) {
checks.redis = { ok: false, detail: String(err) };
}
checks.aamos = await app.aamos.healthCheck();
checks.connectors = { ok: true, detail: `${app.connectors.list().length} registrerade` };
return { checks, timestamp: new Date().toISOString() };
});
// --- Audit logs ---
app.get("/admin/v1/audit-logs", admin, async (req) => {
const q = z
.object({
action: z.string().max(60).optional(),
limit: z.coerce.number().int().min(1).max(200).default(100),
})
.parse(req.query);
const conditions = q.action ? ilike(schema.auditLogs.action, `%${q.action}%`) : undefined;
const logs = await app.db
.select()
.from(schema.auditLogs)
.where(conditions)
.orderBy(desc(schema.auditLogs.createdAt))
.limit(q.limit);
return { logs };
});
// --- AI-korrigeringar & evals (spec §3334) ---
app.get("/admin/v1/ai/corrections", admin, async () => {
const corrections = await app.db
.select()
.from(schema.aiCorrections)
.orderBy(desc(schema.aiCorrections.createdAt))
.limit(100);
return { corrections };
});
app.get("/admin/v1/ai/eval-runs", admin, async () => {
const runs = await app.db
.select()
.from(schema.aiEvalRuns)
.orderBy(desc(schema.aiEvalRuns.createdAt))
.limit(50);
return { runs };
});
// --- Admin-2FA (TOTP, RFC 6238) ---
/**
* Starta 2FA-setup: nytt secret + otpauth-URL för autentiseringsappen.
* OBS: kräver befintlig admin-session; kan köras om tills enable bekräftats.
*/
app.post("/admin/v1/2fa/setup", admin, async (req) => {
const [existing] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (existing?.enabledAt) {
throw errors.badRequest("2FA är redan aktiverat. Inaktivera först för att byta secret.");
}
const secret = generateTotpSecret();
const [user] = await app.db
.select({ email: schema.users.email })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
await app.db
.insert(schema.adminTotp)
.values({ userId: req.userId, secretBase32: secret })
.onConflictDoUpdate({
target: schema.adminTotp.userId,
set: { secretBase32: secret, enabledAt: null, lastUsedStep: null },
});
return {
secret,
otpauthUrl: otpauthUrl(secret, user?.email ?? "admin", `${BRAND.name} Admin`),
};
});
/** Bekräfta setup med första koden först nu börjar tvånget gälla. */
app.post("/admin/v1/2fa/enable", admin, async (req) => {
const body = parse(totpCodeInputSchema, req.body);
const [row] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (!row) throw errors.badRequest("Kör setup först.");
if (row.enabledAt) return { enabled: true };
const step = verifyTotp(row.secretBase32, body.code);
if (step == null)
throw errors.unauthorized("Fel engångskod kontrollera appen och försök igen.");
await app.db
.update(schema.adminTotp)
.set({ enabledAt: new Date(), lastUsedStep: step })
.where(eq(schema.adminTotp.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "admin.2fa_enabled",
});
return { enabled: true };
});
/** Inaktivera 2FA (kräver giltig kod aldrig bara en session). */
app.post("/admin/v1/2fa/disable", admin, async (req) => {
const body = parse(totpCodeInputSchema, req.body);
const [row] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (!row?.enabledAt) return { enabled: false };
if (verifyTotp(row.secretBase32, body.code) == null) {
throw errors.unauthorized("Fel engångskod.");
}
await app.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "admin.2fa_disabled",
});
return { enabled: false };
});
/** Status för inloggad admin (visas i adminpanelen). */
app.get("/admin/v1/2fa/status", admin, async (req) => {
const [row] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
return { enabled: Boolean(row?.enabledAt) };
});
// --- Receptöversättningar (i18n-spec §1314, M3) ---
/** Beställ AI-utkast för ett recept och språk. Idempotent (upsert i worker). */
app.post("/admin/v1/recipes/:id/translate", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z.object({ languageTag: z.string().min(2).max(35) }).parse(req.body);
const [recipe] = await app.db
.select({ id: schema.recipes.id })
.from(schema.recipes)
.where(eq(schema.recipes.id, params.id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
await app.jobQueue.add("TRANSLATE_RECIPE", {
jobType: "TRANSLATE_RECIPE",
recipeId: params.id,
targetLanguageTag: body.languageTag,
});
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "recipe.translate.requested",
targetType: "recipe",
targetId: params.id,
metadata: { languageTag: body.languageTag },
});
return { queued: true, recipeId: params.id, languageTag: body.languageTag };
});
/** Lista översättningar (med verifieringsresultat) för granskning. */
app.get("/admin/v1/recipes/:id/translations", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const translations = await app.db
.select()
.from(schema.recipeTranslations)
.where(eq(schema.recipeTranslations.recipeId, params.id));
const steps = await app.db
.select()
.from(schema.recipeStepTranslations)
.where(eq(schema.recipeStepTranslations.recipeId, params.id))
.orderBy(schema.recipeStepTranslations.stepNumber);
return {
translations: translations.map((t) => ({
...t,
steps: steps.filter((s) => s.languageTag === t.languageTag),
})),
};
});
/** Publicera/underkänn en översättning efter mänsklig granskning. */
app.post("/admin/v1/recipes/:id/translations/:languageTag", admin, async (req) => {
const params = z
.object({ id: z.uuid(), languageTag: z.string().min(2).max(35) })
.parse(req.params);
const body = z
.object({
action: z.enum(["publish", "back_to_draft", "in_review"]),
/** Redaktören kan rätta texten i samma steg. */
title: z.string().min(1).optional(),
description: z.string().nullable().optional(),
})
.parse(req.body);
const status =
body.action === "publish"
? ("published" as const)
: body.action === "in_review"
? ("in_review" as const)
: ("draft_ai" as const);
const [row] = await app.db
.update(schema.recipeTranslations)
.set({
status,
...(body.title ? { title: body.title, source: "human" as const } : {}),
...(body.description !== undefined ? { description: body.description } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(schema.recipeTranslations.recipeId, params.id),
eq(schema.recipeTranslations.languageTag, params.languageTag),
),
)
.returning();
if (!row) throw errors.notFound("Översättningen finns inte.");
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "recipe.translation.moderated",
targetType: "recipe",
targetId: params.id,
metadata: { languageTag: params.languageTag, action: body.action },
});
return row;
});
}
+482
View File
@@ -0,0 +1,482 @@
import type { FastifyInstance } from "fastify";
import { randomBytes, randomUUID } from "node:crypto";
import { and, eq, isNull } from "drizzle-orm";
import { schema } from "@app/database";
import {
loginInputSchema,
refreshInputSchema,
registerInputSchema,
changePasswordInputSchema,
forgotPasswordInputSchema,
resetPasswordInputSchema,
verifyEmailInputSchema,
totpVerifyInputSchema,
} from "@app/validation";
import { trialEndsAt } from "@app/subscriptions";
import { BRAND, DEFAULT_LOCALE_PREFERENCES, localeDefaultsForRegion } from "@app/shared-types";
import { renderMail } from "../lib/mailer.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import { errors, parse } from "../lib/errors.js";
import { hashPassword, verifyPassword } from "../lib/passwords.js";
import { verifyTotp } from "../lib/totp.js";
import { audit, sha256 } from "../lib/helpers.js";
/**
* Auth: registrering, inloggning, roterande refresh-tokens, utloggning.
* Refresh-tokens lagras hashade; återanvändning av roterad token
* ogiltigförklarar hela familjen (token theft detection).
*/
export async function authRoutes(app: FastifyInstance) {
const strictLimit = { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } };
app.post("/v1/auth/register", strictLimit, async (req, reply) => {
const input = parse(registerInputSchema, req.body);
const [existing] = await app.db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.email, input.email))
.limit(1);
if (existing) throw errors.conflict("E-postadressen är redan registrerad.");
const [user] = await app.db
.insert(schema.users)
.values({ email: input.email, displayName: input.displayName, locale: input.locale })
.returning();
if (!user) throw errors.internal();
await app.db
.insert(schema.userCredentials)
.values({ userId: user.id, passwordHash: await hashPassword(input.password) });
await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing();
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: user.id })
.onConflictDoNothing();
// 7 dagars trial utan kort startar direkt (spec §46)
const now = new Date();
await app.db
.insert(schema.trials)
.values({ userId: user.id, startedAt: now, endsAt: trialEndsAt(now) });
await audit(app.db, {
actorUserId: user.id,
action: "auth.register",
ip: req.ip,
correlationId: req.correlationId,
});
// D-031: enhetens språk följer med registreringen → locale-preferenser skapas
// FÖRE välkomstmejlet, så att spanjorens första mejl kommer på spanska.
const regionFromLocale = input.locale.split("-")[1]?.toUpperCase();
const localeDefaults = regionFromLocale
? localeDefaultsForRegion(regionFromLocale)
: DEFAULT_LOCALE_PREFERENCES;
await app.db
.insert(schema.userLocalePreferences)
.values({
userId: user.id,
languageTag: input.locale,
regionCode: localeDefaults.regionCode,
timeZone: localeDefaults.timeZone,
measurementSystem: localeDefaults.measurementSystem,
temperatureUnit: localeDefaults.temperatureUnit,
currencyCode: localeDefaults.currencyCode,
})
.onConflictDoNothing();
// E-postverifiering (icke-blockerande): mejlet skickas, kontot fungerar direkt.
await sendVerificationMail(app, user.id, user.email);
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
return reply.status(201).send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
const input = parse(loginInputSchema, req.body);
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.email, input.email), isNull(schema.users.deletedAt)))
.limit(1);
const [creds] = user
? await app.db
.select()
.from(schema.userCredentials)
.where(eq(schema.userCredentials.userId, user.id))
.limit(1)
: [];
const ok = user && creds ? await verifyPassword(input.password, creds.passwordHash) : false;
if (!ok || !user) {
// Konstant svar oavsett om kontot finns ingen user enumeration.
throw errors.unauthorized("Fel e-post eller lösenord.");
}
// Admin-2FA (step-up): rätt lösenord räcker inte om TOTP är aktiverat.
const [totp] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, user.id))
.limit(1);
if (totp?.enabledAt) {
const preAuthToken = app.jwt.sign(
{ sub: user.id, type: "preauth" },
{ expiresIn: 300 }, // 5 minuter att ange koden
);
await audit(app.db, {
actorUserId: user.id,
action: "auth.login_totp_required",
ip: req.ip,
correlationId: req.correlationId,
});
return reply.send({ totpRequired: true, preAuthToken });
}
await audit(app.db, {
actorUserId: user.id,
action: "auth.login",
ip: req.ip,
correlationId: req.correlationId,
});
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
return reply.send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
/** Steg 2 av admin-inloggning: preauth-token + TOTP-kod → riktiga tokens. */
app.post("/v1/auth/totp-verify", strictLimit, async (req, reply) => {
const input = parse(totpVerifyInputSchema, req.body);
let payload: { sub?: string; type?: string };
try {
payload = app.jwt.verify(input.preAuthToken);
} catch {
throw errors.unauthorized("Ogiltig eller utgången inloggning. Börja om.");
}
if (payload.type !== "preauth" || !payload.sub) throw errors.unauthorized();
const [totp] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, payload.sub))
.limit(1);
if (!totp?.enabledAt) throw errors.unauthorized();
const step = verifyTotp(totp.secretBase32, input.code);
if (step == null || (totp.lastUsedStep != null && step <= totp.lastUsedStep)) {
await audit(app.db, {
actorUserId: payload.sub,
action: "auth.totp_failed",
ip: req.ip,
});
throw errors.unauthorized("Fel engångskod.");
}
await app.db
.update(schema.adminTotp)
.set({ lastUsedStep: step })
.where(eq(schema.adminTotp.userId, payload.sub));
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.id, payload.sub), isNull(schema.users.deletedAt)))
.limit(1);
if (!user) throw errors.unauthorized();
await audit(app.db, {
actorUserId: user.id,
action: "auth.login",
metadata: { mfa: true },
ip: req.ip,
correlationId: req.correlationId,
});
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip, {
mfa: true,
});
return reply.send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
/** Bekräfta e-postadress (länken i välkomstmejlet). Engångs, 24 h TTL. */
app.post("/v1/auth/verify-email", strictLimit, async (req, reply) => {
const input = parse(verifyEmailInputSchema, req.body);
const [stored] = await app.db
.select()
.from(schema.emailVerificationTokens)
.where(eq(schema.emailVerificationTokens.tokenHash, sha256(input.token)))
.limit(1);
if (!stored || stored.usedAt || stored.expiresAt < new Date()) {
throw errors.unauthorized("Ogiltig eller utgången verifieringslänk. Begär en ny.");
}
await app.db
.update(schema.emailVerificationTokens)
.set({ usedAt: new Date() })
.where(eq(schema.emailVerificationTokens.id, stored.id));
await app.db
.update(schema.users)
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.users.id, stored.userId));
await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip });
return reply.send({ ok: true });
});
/** Skicka nytt verifieringsmejl (inloggad, ej redan verifierad). */
app.post(
"/v1/auth/resend-verification",
{ preHandler: [app.authenticate], ...strictLimit },
async (req, reply) => {
const [user] = await app.db
.select({ email: schema.users.email, verifiedAt: schema.users.emailVerifiedAt })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (!user) throw errors.unauthorized();
if (!user.verifiedAt) await sendVerificationMail(app, req.userId, user.email);
return reply.send({ ok: true });
},
);
app.post("/v1/auth/refresh", strictLimit, async (req, reply) => {
const input = parse(refreshInputSchema, req.body);
const tokenHash = sha256(input.refreshToken);
const [stored] = await app.db
.select()
.from(schema.refreshTokens)
.where(eq(schema.refreshTokens.tokenHash, tokenHash))
.limit(1);
if (!stored) throw errors.unauthorized("Ogiltig refresh-token.");
if (stored.revokedAt) {
// Token-återanvändning → hela familjen ogiltigförklaras.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(eq(schema.refreshTokens.familyId, stored.familyId));
await audit(app.db, {
actorUserId: stored.userId,
action: "auth.refresh_reuse_detected",
metadata: { familyId: stored.familyId },
ip: req.ip,
});
throw errors.unauthorized("Sessionen har återkallats. Logga in igen.");
}
if (stored.expiresAt < new Date()) throw errors.unauthorized("Sessionen har gått ut.");
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.id, stored.userId), isNull(schema.users.deletedAt)))
.limit(1);
if (!user) throw errors.unauthorized();
// Rotera: revokera gamla, utfärda ny i samma familj.
const next = await issueTokens(
app,
user.id,
user.role,
req.headers["user-agent"],
req.ip,
stored.familyId,
);
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date(), replacedByTokenId: next.refreshTokenId })
.where(eq(schema.refreshTokens.id, stored.id));
return reply.send({
accessToken: next.accessToken,
refreshToken: next.refreshToken,
accessTokenExpiresIn: next.accessTokenExpiresIn,
});
});
app.post("/v1/auth/logout", { preHandler: [app.authenticate] }, async (req, reply) => {
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, { actorUserId: req.userId, action: "auth.logout", ip: req.ip });
return reply.send({ ok: true });
});
/**
* Lösenordsåterställning steg 1 (spec §56: säker kontohantering).
* Svarar ALLTID { ok: true } avslöjar aldrig om kontot finns
* (anti-enumeration). Token: 32 slumpbytes, lagras sha256-hashad,
* 30 min TTL, engångsbruk; tidigare oanvända tokens ogiltigförklaras.
*/
app.post("/v1/auth/forgot-password", strictLimit, async (req, reply) => {
const input = parse(forgotPasswordInputSchema, req.body);
const [user] = await app.db
.select({ id: schema.users.id, email: schema.users.email })
.from(schema.users)
.where(eq(schema.users.email, input.email))
.limit(1);
if (user) {
// Ogiltigförklara tidigare oanvända tokens.
await app.db
.update(schema.passwordResetTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(schema.passwordResetTokens.userId, user.id),
isNull(schema.passwordResetTokens.usedAt),
),
);
const token = randomBytes(32).toString("hex");
await app.db.insert(schema.passwordResetTokens).values({
userId: user.id,
tokenHash: sha256(token),
expiresAt: new Date(Date.now() + 30 * 60_000),
});
const languageTag = (await loadLocalePreferences(app.db, user.id)).languageTag;
const mail = renderMail("auth.password_reset", languageTag, {
email: user.email,
link: `${BRAND.urlScheme}://reset-password?token=${token}`,
});
await app.mailer.send({ to: user.email, ...mail });
await audit(app.db, {
actorUserId: user.id,
action: "auth.password_reset_requested",
ip: req.ip,
});
}
// Samma svar oavsett och ingen tidsskillnad stor nog att mäta via rate limit.
return reply.send({ ok: true });
});
/** Lösenordsåterställning steg 2: token + nytt lösenord. Engångsbruk. */
app.post("/v1/auth/reset-password", strictLimit, async (req, reply) => {
const input = parse(resetPasswordInputSchema, req.body);
const [stored] = await app.db
.select()
.from(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.tokenHash, sha256(input.token)))
.limit(1);
if (!stored || stored.usedAt || stored.expiresAt < new Date()) {
throw errors.unauthorized("Ogiltig eller utgången återställningslänk. Begär en ny.");
}
await app.db
.update(schema.passwordResetTokens)
.set({ usedAt: new Date() })
.where(eq(schema.passwordResetTokens.id, stored.id));
await app.db
.update(schema.userCredentials)
.set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() })
.where(eq(schema.userCredentials.userId, stored.userId));
// Logga ut ALLA sessioner ett återställt konto börjar om från noll.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, stored.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, {
actorUserId: stored.userId,
action: "auth.password_reset_completed",
ip: req.ip,
});
return reply.send({ ok: true });
});
app.post(
"/v1/auth/change-password",
{ preHandler: [app.authenticate], ...strictLimit },
async (req, reply) => {
const input = parse(changePasswordInputSchema, req.body);
const [creds] = await app.db
.select()
.from(schema.userCredentials)
.where(eq(schema.userCredentials.userId, req.userId))
.limit(1);
if (!creds || !(await verifyPassword(input.currentPassword, creds.passwordHash))) {
throw errors.unauthorized("Fel nuvarande lösenord.");
}
await app.db
.update(schema.userCredentials)
.set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() })
.where(eq(schema.userCredentials.userId, req.userId));
// Logga ut alla andra sessioner.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, { actorUserId: req.userId, action: "auth.change_password", ip: req.ip });
return reply.send({ ok: true });
},
);
}
async function issueTokens(
app: FastifyInstance,
userId: string,
role: string,
userAgent: string | undefined,
ip: string | undefined,
familyIdOrOpts?: string | { mfa?: boolean },
opts?: { mfa?: boolean },
) {
const familyId = typeof familyIdOrOpts === "string" ? familyIdOrOpts : undefined;
const mfa = (typeof familyIdOrOpts === "object" ? familyIdOrOpts.mfa : opts?.mfa) ?? false;
const accessToken = app.jwt.sign({ sub: userId, role, type: "access", ...(mfa ? { mfa } : {}) });
const refreshToken = randomUUID() + "." + randomUUID();
const family = familyId ?? randomUUID();
const [row] = await app.db
.insert(schema.refreshTokens)
.values({
userId,
tokenHash: sha256(refreshToken),
familyId: family,
expiresAt: new Date(Date.now() + app.config.JWT_REFRESH_TTL_SECONDS * 1000),
userAgent: userAgent?.slice(0, 300) ?? null,
ip: ip ?? null,
})
.returning({ id: schema.refreshTokens.id });
return {
accessToken,
refreshToken,
refreshTokenId: row!.id,
accessTokenExpiresIn: app.config.JWT_ACCESS_TTL_SECONDS,
};
}
/** Skapa verifieringstoken + skicka mejl på användarens språk (24 h TTL). */
async function sendVerificationMail(app: FastifyInstance, userId: string, email: string) {
await app.db
.update(schema.emailVerificationTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(schema.emailVerificationTokens.userId, userId),
isNull(schema.emailVerificationTokens.usedAt),
),
);
const token = randomBytes(32).toString("hex");
await app.db.insert(schema.emailVerificationTokens).values({
userId,
tokenHash: sha256(token),
expiresAt: new Date(Date.now() + 24 * 3600_000),
});
const languageTag = (await loadLocalePreferences(app.db, userId)).languageTag;
const mail = renderMail("auth.verify_email", languageTag, {
email,
link: `${BRAND.urlScheme}://verify-email?token=${token}`,
});
await app.mailer.send({ to: email, ...mail });
}
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { requireActiveHousehold } from "../lib/helpers.js";
/** Budget & matsvinn (spec §26): vecka/månad, kostnad per måltid, svinnvärde. */
export async function budgetRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/budget/summary", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const now = new Date();
// UTC-kalender (samma policy som motorerna): identiska summor oavsett serverns tidszon.
const mondayOffset = (now.getUTCDay() + 6) % 7; // 0 = måndag
const weekStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset),
);
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
// Inköpskostnad = summa purchase-transaktioner med värde
const [weekPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Matsvinnsvärde = summa discard-transaktioner (spec §12, §26)
const [weekWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Kostnad per hemlagad måltid: lagade recept denna månad med kostnadsdata
const cooks = await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
portions: schema.recipeCooks.portionsCooked,
})
.from(schema.recipeCooks)
.where(
and(
eq(schema.recipeCooks.householdId, householdId),
gte(schema.recipeCooks.cookedAt, monthStart),
),
);
let mealCostTotal = 0;
let mealPortions = 0;
if (cooks.length > 0) {
const recipes = await app.db
.select({ id: schema.recipes.id, cost: schema.recipes.estimatedCostMinorPerPortion })
.from(schema.recipes)
.where(inArray(schema.recipes.id, [...new Set(cooks.map((c) => c.recipeId))]));
const costMap = new Map(recipes.map((r) => [r.id, r.cost]));
for (const cook of cooks) {
const cost = costMap.get(cook.recipeId);
if (cost != null) {
mealCostTotal += cost * cook.portions;
mealPortions += cook.portions;
}
}
}
const [household] = await app.db
.select({
budget: schema.households.weeklyBudgetMinor,
currencyCode: schema.households.currencyCode,
})
.from(schema.households)
.where(eq(schema.households.id, householdId))
.limit(1);
// Alla belopp i minor units (heltal) i hushållets valuta (i18n-spec §20).
const currency = household?.currencyCode ?? "SEK";
return {
currency,
week: {
purchasedMinor: Math.round(Number(weekPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(weekWaste?.total ?? 0)),
wasteCount: Number(weekWaste?.count ?? 0),
budgetMinor: household?.budget ?? null,
},
month: {
purchasedMinor: Math.round(Number(monthPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(monthWaste?.total ?? 0)),
wasteCount: Number(monthWaste?.count ?? 0),
estimatedCostPerPortionMinor:
mealPortions > 0 ? Math.round(mealCostTotal / mealPortions) : null,
cookedPortions: mealPortions,
},
note: "Kostnader bygger på kvitton, angivna priser och schablonpriser uppskattningar, inte bokföring.",
};
});
}
+165
View File
@@ -0,0 +1,165 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
/**
* Community & creators (spec §3538).
* Rankinglistor bakom feature flag; ALDRIG ranking på vikt/kalorier (spec §38).
*/
export async function communityRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/creators/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [stats] = await app.db
.select()
.from(schema.creatorStats)
.where(eq(schema.creatorStats.userId, id))
.limit(1);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, id))
.limit(1);
if (!user) throw errors.notFound("Profilen finns inte.");
if (stats?.visibility === "private" && id !== req.userId) {
throw errors.forbidden("Profilen är privat.");
}
const recipes = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
ratingAverage: schema.recipes.ratingAverage,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
imageUrls: schema.recipes.imageUrls,
})
.from(schema.recipes)
.where(and(eq(schema.recipes.creatorUserId, id), eq(schema.recipes.status, "published")))
.orderBy(desc(schema.recipes.cookCount))
.limit(30);
return {
userId: id,
displayName: user.displayName,
level: stats?.level ?? "beginner",
followers: stats?.followers ?? 0,
publishedRecipes: stats?.publishedRecipes ?? recipes.length,
totalCooks: stats?.totalCooks ?? 0,
averageRating: stats?.averageRating ?? null,
badges: stats?.badges ?? [],
recipes,
};
});
app.post("/v1/creators/:id/follow", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
if (id === req.userId) throw errors.badRequest("Du kan inte följa dig själv.");
await app.db
.insert(schema.creatorFollows)
.values({ followerUserId: req.userId, creatorUserId: id })
.onConflictDoNothing();
await app.db
.insert(schema.creatorStats)
.values({ userId: id, followers: 1 })
.onConflictDoUpdate({
target: schema.creatorStats.userId,
set: { followers: sql`${schema.creatorStats.followers} + 1`, updatedAt: new Date() },
});
return { ok: true };
});
app.delete("/v1/creators/:id/follow", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.delete(schema.creatorFollows)
.where(
and(
eq(schema.creatorFollows.followerUserId, req.userId),
eq(schema.creatorFollows.creatorUserId, id),
),
);
await app.db
.update(schema.creatorStats)
.set({ followers: sql`GREATEST(${schema.creatorStats.followers} - 1, 0)` })
.where(eq(schema.creatorStats.userId, id));
return { ok: true };
});
/** Topplistor (spec §38) kräver flagga + minst 5 betyg för att synas. */
app.get("/v1/rankings/:kind", auth, async (req) => {
if (!(await app.flags.isEnabled("creator_rankings", req.userId))) {
return { enabled: false, entries: [] };
}
const kind = (req.params as { kind: string }).kind;
const MIN_RATINGS = 5;
if (kind === "most-cooked") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.cookCount,
})
.from(schema.recipes)
.where(eq(schema.recipes.status, "published"))
.orderBy(desc(schema.recipes.cookCount))
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "top-rated") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.ratingAverage,
})
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${schema.recipes.ratingCount} >= ${MIN_RATINGS}`,
),
)
.orderBy(desc(schema.recipes.ratingAverage))
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "budget") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.estimatedCostMinorPerPortion,
})
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${schema.recipes.estimatedCostMinorPerPortion} IS NOT NULL`,
),
)
.orderBy(schema.recipes.estimatedCostMinorPerPortion)
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "protein") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: sql<number>`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`,
})
.from(schema.recipes)
.where(eq(schema.recipes.status, "published"))
.orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`))
.limit(20);
return { enabled: true, kind, entries: rows };
}
// Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI.
throw errors.badRequest("Okänd rankingtyp.");
});
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance } from "fastify";
import { sql } from "drizzle-orm";
import { BRAND } from "@app/shared-types";
/** Liveness/readiness för lastbalanserare och Docker healthchecks (spec §58). */
export async function healthRoutes(app: FastifyInstance) {
app.get("/healthz", { config: { rateLimit: false } }, async () => ({
ok: true,
service: `${BRAND.slug}-api`,
timestamp: new Date().toISOString(),
}));
app.get("/readyz", { config: { rateLimit: false } }, async (_req, reply) => {
try {
await app.db.execute(sql`SELECT 1`);
return { ok: true };
} catch {
return reply.status(503).send({ ok: false, reason: "database" });
}
});
/** Enkel endpointöversikt i stället för tung OpenAPI-generering (se beslutslogg D-011). */
app.get("/docs", { config: { rateLimit: false } }, async (_req, reply) => {
const routes = app.routeCatalog
.filter((r) => !r.url.startsWith("/v1/mock-s3"))
.sort((a, b) => a.url.localeCompare(b.url) || a.method.localeCompare(b.method));
const rows = routes
.map((r) => `<tr><td><code>${r.method}</code></td><td><code>${r.url}</code></td></tr>`)
.join("\n");
reply.header("content-type", "text/html; charset=utf-8");
return `<!doctype html><html lang="sv"><head><meta charset="utf-8"><title>API</title>
<style>body{font-family:system-ui;margin:2rem auto;max-width:820px;color:#1a202c;padding:0 1rem}
table{border-collapse:collapse;width:100%}td{padding:.35rem .75rem;border-bottom:1px solid #e2e8f0}
code{background:#f7fafc;padding:.1rem .3rem;border-radius:4px}</style></head>
<body><h1>${BRAND.name} API v1</h1>
<p>${routes.length} endpoints. Auth: <code>Authorization: Bearer &lt;accessToken&gt;</code>.
Fullständig referens: <code>docs/api-referens.md</code> i repot.</p>
<table>${rows}</table></body></html>`;
});
}
+230
View File
@@ -0,0 +1,230 @@
import type { FastifyInstance } from "fastify";
import { and, eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
createHouseholdInputSchema,
createStorageLocationInputSchema,
idParamSchema,
joinHouseholdInputSchema,
memberParamSchema,
updateHouseholdInputSchema,
updateMemberInputSchema,
updateStorageLocationInputSchema,
} from "@app/validation";
import { loadEntitlements } from "../lib/entitlements.js";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, generateInviteCode, requireMembership } from "../lib/helpers.js";
/** Hushåll (spec §7): delat lager/plan/lista, individuella mål och roller. */
export async function householdRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/households", auth, async (req) => {
const rows = await app.db
.select({
household: schema.households,
role: schema.householdMembers.role,
portionFactor: schema.householdMembers.portionFactor,
})
.from(schema.householdMembers)
.innerJoin(schema.households, eq(schema.householdMembers.householdId, schema.households.id))
.where(eq(schema.householdMembers.userId, req.userId));
return rows.map((r) => ({ ...r.household, myRole: r.role, myPortionFactor: r.portionFactor }));
});
app.post("/v1/households", auth, async (req, reply) => {
const input = parse(createHouseholdInputSchema, req.body);
const [household] = await app.db
.insert(schema.households)
.values({
name: input.name,
inviteCode: generateInviteCode(),
weeklyBudgetMinor: input.weeklyBudgetMinor ?? null,
...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),
})
.returning();
await app.db
.insert(schema.householdMembers)
.values({ householdId: household!.id, userId: req.userId, role: "owner" });
await app.db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
return reply.status(201).send(household);
});
app.get("/v1/households/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await requireMembership(app.db, id, req.userId);
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.id, id))
.limit(1);
if (!household) throw errors.notFound();
const members = await app.db
.select({
userId: schema.householdMembers.userId,
role: schema.householdMembers.role,
portionFactor: schema.householdMembers.portionFactor,
joinedAt: schema.householdMembers.joinedAt,
displayName: schema.users.displayName,
})
.from(schema.householdMembers)
.innerJoin(schema.users, eq(schema.householdMembers.userId, schema.users.id))
.where(eq(schema.householdMembers.householdId, id));
const locations = await app.db
.select()
.from(schema.storageLocations)
.where(eq(schema.storageLocations.householdId, id))
.orderBy(schema.storageLocations.sortOrder);
// OBS: individuella hälsomål/allergier exponeras INTE här (spec §7, §56).
return { ...household, members, storageLocations: locations };
});
app.patch("/v1/households/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
if (membership.role !== "owner" && membership.role !== "adult") {
throw errors.forbidden("Endast vuxna medlemmar kan ändra hushållet.");
}
const input = parse(updateHouseholdInputSchema, req.body);
const [row] = await app.db
.update(schema.households)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.households.id, id))
.returning();
return row;
});
app.post("/v1/households/join", auth, async (req) => {
const input = parse(joinHouseholdInputSchema, req.body);
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.inviteCode, input.inviteCode.toUpperCase()))
.limit(1);
if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden.");
// Kontrollera plangräns: max medlemmar styrs av ägarens plan (spec §45).
const [owner] = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, household.id),
eq(schema.householdMembers.role, "owner"),
),
)
.limit(1);
const members = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, household.id));
if (owner) {
const ent = await loadEntitlements(app.db, owner.userId);
if (members.length >= ent.maxHouseholdMembers) {
throw errors.paymentRequired(
`Hushållet har nått maxantalet medlemmar (${ent.maxHouseholdMembers}) för sin plan.`,
);
}
}
await app.db
.insert(schema.householdMembers)
.values({ householdId: household.id, userId: req.userId, role: "adult" })
.onConflictDoNothing();
await emitEvent(app.db, {
type: "HOUSEHOLD_MEMBER_ADDED",
payload: { householdId: household.id, newUserId: req.userId, role: "adult" },
userId: req.userId,
householdId: household.id,
correlationId: req.correlationId,
});
return { ok: true, household: { id: household.id, name: household.name } };
});
app.patch("/v1/households/:id/members/:userId", auth, async (req) => {
const { id, userId } = parse(memberParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
const isSelf = userId === req.userId;
if (!isSelf && membership.role !== "owner" && membership.role !== "adult") {
throw errors.forbidden("Endast vuxna kan ändra andra medlemmar.");
}
const input = parse(updateMemberInputSchema, req.body);
if (input.role && membership.role !== "owner") {
throw errors.forbidden("Endast ägaren kan ändra roller.");
}
const [row] = await app.db
.update(schema.householdMembers)
.set(input)
.where(
and(
eq(schema.householdMembers.householdId, id),
eq(schema.householdMembers.userId, userId),
),
)
.returning();
if (!row) throw errors.notFound("Medlemmen finns inte.");
return row;
});
app.delete("/v1/households/:id/members/:userId", auth, async (req) => {
const { id, userId } = parse(memberParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
const isSelf = userId === req.userId;
if (!isSelf && membership.role !== "owner") {
throw errors.forbidden("Endast ägaren kan ta bort andra medlemmar.");
}
await app.db
.delete(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, id),
eq(schema.householdMembers.userId, userId),
),
);
await audit(app.db, {
actorUserId: req.userId,
action: "household.member_removed",
targetType: "user",
targetId: userId,
});
return { ok: true };
});
// --- Förvaringsplatser (spec §8) ---
app.post("/v1/households/:id/storage-locations", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
await requireMembership(app.db, id, req.userId);
const input = parse(createStorageLocationInputSchema, req.body);
const [row] = await app.db
.insert(schema.storageLocations)
.values({ householdId: id, ...input })
.returning();
return reply.status(201).send(row);
});
app.patch("/v1/households/:id/storage-locations/:locationId", auth, async (req) => {
const params = req.params as { id: string; locationId: string };
await requireMembership(app.db, params.id, req.userId);
const input = parse(updateStorageLocationInputSchema, req.body);
const [row] = await app.db
.update(schema.storageLocations)
.set(input)
.where(
and(
eq(schema.storageLocations.id, params.locationId),
eq(schema.storageLocations.householdId, params.id),
),
)
.returning();
if (!row) throw errors.notFound();
return row;
});
}
+406
View File
@@ -0,0 +1,406 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, isNull, or } from "drizzle-orm";
import { schema } from "@app/database";
import {
createInventoryItemInputSchema,
idParamSchema,
inventoryQuerySchema,
inventoryTransactionInputSchema,
updateInventoryItemInputSchema,
} from "@app/validation";
import { classifyExpiry, findDuplicateCandidates, normalizeDelta } from "@app/inventory-engine";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
/**
* Food Twin lagret (spec §8). Transaktionsbaserat: varje förändring skrivs
* som inventory_transaction och saldot uppdateras atomiskt.
*/
export async function inventoryRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/inventory", auth, async (req) => {
const query = parse(inventoryQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const conditions = [
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
];
if (query.storageLocationId) {
conditions.push(eq(schema.inventoryItems.storageLocationId, query.storageLocationId));
}
if (query.search) {
const pattern = `%${query.search}%`;
conditions.push(
or(
ilike(schema.inventoryItems.displayName, pattern),
ilike(schema.inventoryItems.brand, pattern),
)!,
);
}
const rows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
locationName: schema.storageLocations.name,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(and(...conditions))
.orderBy(desc(schema.inventoryItems.updatedAt))
.limit(query.limit)
.offset(query.offset);
const items = rows.map((r) => {
const expiry = classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
});
return {
...r.item,
locationName: r.locationName,
locationType: r.locationType,
expiry,
};
});
const filtered = query.expiryStatus
? items.filter((i) => i.expiry.status === query.expiryStatus)
: items;
return { items: filtered };
});
/** Varor som bör användas snart driver "använd först" (spec §4.4, §40). */
app.get("/v1/inventory/expiring", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const rows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const withExpiry = rows
.map((r) => ({
...r.item,
expiry: classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}),
}))
.filter(
(i) =>
i.expiry.status === "expiring" ||
i.expiry.status === "use_soon" ||
i.expiry.status === "expired",
)
.sort((a, b) => (a.expiry.daysLeft ?? 99) - (b.expiry.daysLeft ?? 99));
return { items: withExpiry };
});
app.post("/v1/inventory/items", auth, async (req, reply) => {
const input = parse(createInventoryItemInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [location] = await app.db
.select()
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.id, input.storageLocationId),
eq(schema.storageLocations.householdId, householdId),
),
)
.limit(1);
if (!location) throw errors.badRequest("Förvaringsplatsen tillhör inte ditt hushåll.");
// Dubblettkontroll (spec §9) varna, blockera inte.
const existing = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
)
.limit(200);
const duplicates = findDuplicateCandidates(
{
canonicalIngredientId: input.canonicalIngredientId ?? null,
displayName: input.displayName,
brand: input.brand ?? null,
quantity: input.quantity,
source: input.source,
createdAt: new Date().toISOString(),
},
existing.map((e) => ({
id: e.id,
canonicalIngredientId: e.canonicalIngredientId,
displayName: e.displayName,
brand: e.brand,
quantity: e.quantity,
source: e.source,
createdAt: e.createdAt.toISOString(),
})),
);
const [item] = await app.db
.insert(schema.inventoryItems)
.values({
householdId,
canonicalIngredientId: input.canonicalIngredientId ?? null,
productId: input.productId ?? null,
displayName: input.displayName,
brand: input.brand ?? null,
quantity: input.quantity,
unit: input.unit,
storageLocationId: input.storageLocationId,
sublocation: input.sublocation ?? null,
purchasedAt: input.purchasedAt ?? null,
openedAt: input.openedAt ?? null,
bestBeforeDate: input.bestBeforeDate ?? null,
useByDate: input.useByDate ?? null,
dateKind: input.dateKind ?? null,
frozenAt: input.frozenAt ?? null,
priceMinor: input.priceMinor ?? null,
source: input.source,
confidence: input.source === "manual_search" || input.source === "free_text" ? 1 : 0.9,
verifiedByUser: true,
lastVerifiedAt: new Date(),
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item!.id,
type: "purchase",
quantityDelta: input.quantity,
unit: input.unit,
refType: "manual",
actorUserId: req.userId,
valueMinor: input.priceMinor ?? null,
});
await emitEvent(app.db, {
type: "PRODUCT_ADDED",
payload: {
inventoryItemId: item!.id,
canonicalIngredientId: input.canonicalIngredientId ?? null,
quantity: input.quantity,
unit: input.unit,
source: input.source,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return reply.status(201).send({ item, duplicateCandidates: duplicates });
});
app.patch("/v1/inventory/items/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(updateInventoryItemInputSchema, req.body);
const item = await getOwnedItem(app, id, req.userId);
const { verifiedByUser, quantity, ...fields } = input;
const updates: Record<string, unknown> = { ...fields, updatedAt: new Date() };
if (verifiedByUser) {
updates.verifiedByUser = true;
updates.lastVerifiedAt = new Date();
}
// Mängdändring går ALLTID via transaktion (spec §8).
if (quantity != null && quantity !== item.quantity) {
const delta = quantity - item.quantity;
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: "adjust",
quantityDelta: delta,
unit: item.unit,
refType: "manual",
actorUserId: req.userId,
note: "Manuell justering",
});
updates.quantity = quantity;
}
const [row] = await app.db
.update(schema.inventoryItems)
.set(updates)
.where(eq(schema.inventoryItems.id, id))
.returning();
await emitEvent(app.db, {
type: "PRODUCT_UPDATED",
payload: { inventoryItemId: id, changes: updates },
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
return row;
});
/** Konsumera/släng/justera kärnan i transaktionsmodellen. */
app.post("/v1/inventory/items/:id/transactions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(inventoryTransactionInputSchema, req.body);
const item = await getOwnedItem(app, id, req.userId);
const delta = normalizeDelta(input.type, input.quantityDelta);
const newQuantity = Math.max(0, Math.round((item.quantity + delta) * 1000) / 1000);
const actualDelta = newQuantity - item.quantity;
const valueMinor =
input.type === "discard" && item.priceMinor != null && item.quantity > 0
? Math.round(Math.abs(actualDelta / item.quantity) * item.priceMinor * 100) / 100
: null;
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: input.type,
quantityDelta: actualDelta,
unit: item.unit,
refType: "manual",
actorUserId: req.userId,
note: input.note ?? null,
valueMinor,
});
const [updated] = await app.db
.update(schema.inventoryItems)
.set({
quantity: newQuantity,
depletedAt: newQuantity <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, id))
.returning();
const eventType =
input.type === "discard"
? "PRODUCT_DISCARDED"
: input.type === "consume"
? "PRODUCT_CONSUMED"
: null;
if (eventType === "PRODUCT_DISCARDED") {
await emitEvent(app.db, {
type: "PRODUCT_DISCARDED",
payload: {
inventoryItemId: id,
quantity: Math.abs(actualDelta),
unit: item.unit,
valueMinor,
reason: input.note ?? null,
},
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
} else if (eventType === "PRODUCT_CONSUMED") {
await emitEvent(app.db, {
type: "PRODUCT_CONSUMED",
payload: {
inventoryItemId: id,
quantity: Math.abs(actualDelta),
unit: item.unit,
refType: "manual",
},
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
}
return { item: updated };
});
app.get("/v1/inventory/items/:id/transactions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await getOwnedItem(app, id, req.userId);
const txs = await app.db
.select()
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, id))
.orderBy(desc(schema.inventoryTransactions.createdAt))
.limit(100);
return { transactions: txs };
});
app.delete("/v1/inventory/items/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const item = await getOwnedItem(app, id, req.userId);
// Radering = correction till 0 + arkivering (historiken bevaras, spec §8).
if (item.quantity > 0) {
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: "correction",
quantityDelta: -item.quantity,
unit: item.unit,
actorUserId: req.userId,
note: "Post borttagen av användare",
});
}
await app.db
.update(schema.inventoryItems)
.set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.inventoryItems.id, id));
return { ok: true };
});
}
async function getOwnedItem(app: FastifyInstance, itemId: string, userId: string) {
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
if (!item) throw errors.notFound("Lagerposten finns inte.");
await requireMembership(app.db, item.householdId, userId);
return item;
}
+336
View File
@@ -0,0 +1,336 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
consentInputSchema,
onboardingInputSchema,
updateHealthProfileInputSchema,
updateMeInputSchema,
updatePreferencesInputSchema,
} from "@app/validation";
import { computeDailyTargets, DEFAULT_TARGETS } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js";
import { loadEntitlementsWithToken } from "../lib/entitlements.js";
/** Profil, preferenser, samtycken, dagsmål, GDPR-export/-radering (spec §6, §56). */
export async function meRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/me", auth, async (req) => {
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (!user) throw errors.notFound();
const householdId = await getActiveHouseholdId(app.db, req.userId);
return {
id: user.id,
email: user.email,
emailVerified: user.emailVerifiedAt != null,
displayName: user.displayName,
role: user.role,
locale: user.locale,
precisionMode: user.precisionMode,
onboardingCompleted: user.onboardingCompleted,
activeHouseholdId: householdId,
};
});
app.patch("/v1/me", auth, async (req) => {
const input = parse(updateMeInputSchema, req.body);
const [user] = await app.db
.update(schema.users)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId))
.returning();
return {
id: user!.id,
displayName: user!.displayName,
locale: user!.locale,
precisionMode: user!.precisionMode,
};
});
// --- Hälsoprofil (separerad domän, spec §56) ---
app.get("/v1/me/health-profile", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
return profile ?? null;
});
app.patch("/v1/me/health-profile", auth, async (req) => {
const input = parse(updateHealthProfileInputSchema, req.body);
const [row] = await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Preferenser ---
app.get("/v1/me/preferences", auth, async (req) => {
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
return prefs ?? null;
});
app.patch("/v1/me/preferences", auth, async (req) => {
const input = parse(updatePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Onboarding i ett svep (spec §6) ---
app.post("/v1/me/onboarding", auth, async (req) => {
const input = parse(onboardingInputSchema, req.body);
if (input.healthProfile) {
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input.healthProfile })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input.healthProfile, updatedAt: new Date() },
});
}
if (input.preferences) {
await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input.preferences })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input.preferences, updatedAt: new Date() },
});
}
let householdId: string | null = await getActiveHouseholdId(app.db, req.userId);
if (input.householdChoice.kind === "create" && !householdId) {
const [household] = await app.db
.insert(schema.households)
.values({ name: input.householdChoice.name, inviteCode: generateInviteCode() })
.returning();
await app.db.insert(schema.householdMembers).values({
householdId: household!.id,
userId: req.userId,
role: "owner",
});
// Standardplatser: kyl, frys, skafferi (spec §8)
await app.db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
householdId = household!.id;
} else if (input.householdChoice.kind === "join") {
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.inviteCode, input.householdChoice.inviteCode.toUpperCase()))
.limit(1);
if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden.");
await app.db
.insert(schema.householdMembers)
.values({ householdId: household.id, userId: req.userId, role: "adult" })
.onConflictDoNothing();
householdId = household.id;
}
await app.db
.update(schema.users)
.set({ precisionMode: input.precisionMode, onboardingCompleted: true, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId));
return { ok: true, householdId };
});
// --- Samtycken (spec §33: separata) ---
app.get("/v1/me/consents", auth, async (req) => {
return app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, req.userId));
});
app.put("/v1/me/consents", auth, async (req) => {
const input = parse(consentInputSchema, req.body);
const now = new Date();
const [row] = await app.db
.insert(schema.userConsents)
.values({
userId: req.userId,
kind: input.kind,
status: input.granted ? "granted" : "denied",
grantedAt: input.granted ? now : null,
revokedAt: input.granted ? null : now,
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: {
status: input.granted ? "granted" : "revoked",
...(input.granted ? { grantedAt: now, revokedAt: null } : { revokedAt: now }),
updatedAt: now,
},
})
.returning();
await audit(app.db, {
actorUserId: req.userId,
action: `consent.${input.granted ? "granted" : "revoked"}`,
targetType: "consent",
targetId: input.kind,
});
return row;
});
// --- Dagsmål: beräknas deterministiskt, med transparent grund (spec §21) ---
app.get("/v1/me/daily-targets", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
if (!profile?.weightKg || !profile.heightCm || !profile.birthYear) {
return {
targets: DEFAULT_TARGETS,
basis: null,
note: "Schablonmål fyll i längd, vikt och födelseår för personliga mål.",
};
}
const result = computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: new Date().getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: prefs?.primaryGoal ?? undefined,
});
return {
...result,
note: "Uppskattning enligt MifflinSt Jeor. Appen är inte medicinsk rådgivning.",
};
});
// --- Entitlements (spec §47) ---
app.get("/v1/me/entitlements", auth, async (req) => {
return loadEntitlementsWithToken(app, req.userId);
});
// --- Locale-preferenser (i18n-spec §6): språk ≠ region ≠ enheter ---
app.get("/v1/me/locale-preferences", auth, async (req) => {
const { loadLocalePreferences } = await import("../lib/localeContext.js");
return loadLocalePreferences(app.db, req.userId);
});
app.patch("/v1/me/locale-preferences", auth, async (req) => {
const { updateLocalePreferencesInputSchema } = await import("@app/validation");
const input = parse(updateLocalePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userLocalePreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userLocalePreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- GDPR: export (spec §56) ---
app.get("/v1/me/export", auth, async (req) => {
const userId = req.userId;
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.limit(1);
const [health] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const consents = await app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const meals = await app.db.select().from(schema.meals).where(eq(schema.meals.userId, userId));
const memory = await app.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const ratings = await app.db
.select()
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.export", ip: req.ip });
return {
exportedAt: new Date().toISOString(),
user,
healthProfile: health ?? null,
preferences: prefs ?? null,
consents,
meals,
memory,
ratings,
};
});
// --- GDPR: radera konto (spec §56, §32) ---
app.delete("/v1/me", auth, async (req) => {
const userId = req.userId;
// Hård radering av persondata via FK-cascade; users-raden anonymiseras
// och soft-deletas för att bevara referensintegritet i aggregat.
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId));
await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId));
await app.db
.delete(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId));
await app.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await app.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, userId));
await app.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, userId));
await app.db
.update(schema.users)
.set({
email: `deleted-${userId}@anonymized.invalid`,
displayName: "Raderad användare",
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.users.id, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip });
return {
ok: true,
message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.",
};
});
}
+334
View File
@@ -0,0 +1,334 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
consumeMealBoxInputSchema,
createMealBoxInputSchema,
dayQuerySchema,
idParamSchema,
logMealInputSchema,
} from "@app/validation";
import {
computeItemNutrition,
DEFAULT_TARGETS,
computeDailyTargets,
scaleNutrition,
summarizeDay,
sumNutrition,
} from "@app/nutrition-engine";
import { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
/**
* Måltidsloggning + "Min dag" (spec §4.3, §23) och matlådor (spec §24).
* Näringsvärden härleds deterministiskt aldrig av AI (spec §61.1).
*/
export async function mealRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/meals", auth, async (req, reply) => {
const input = parse(logMealInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId).catch(() => null);
let nutrition: NutritionValues;
let isEstimate = false;
let estimateMin: number | null = null;
let estimateMax: number | null = null;
if (input.nutritionOverride) {
// Användarens egen inmatning eller bekräftat foto-intervall.
nutrition = { ...EMPTY_NUTRITION, ...input.nutritionOverride };
isEstimate = input.source === "plate_photo";
} else if (input.recipeId) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, input.recipeId))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
nutrition = scaleNutrition(recipe.nutritionPerPortion, input.portionFraction);
} else if (input.items.length > 0) {
const parts: NutritionValues[] = [];
for (const item of input.items) {
if (item.nutrition) {
parts.push({ ...EMPTY_NUTRITION, ...item.nutrition });
continue;
}
if (item.canonicalIngredientId && item.quantity != null && item.unit) {
const [ing] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, item.canonicalIngredientId))
.limit(1);
if (ing) {
const computed = computeItemNutrition(item.quantity, item.unit, ing.nutritionPer100, {
densityGPerMl: ing.densityGPerMl,
gramsPerPiece: ing.gramsPerPiece,
});
if (computed) {
parts.push(computed);
continue;
}
}
}
throw errors.badRequest(
`"${item.displayName}" saknar näringsdata. Ange mängd + känd ingrediens, eller egna värden.`,
);
}
nutrition = sumNutrition(parts);
} else if (input.scanJobId) {
// Tallriksfoto: intervall från AAMOS som användaren bekräftar (spec §22).
const [job] = await app.db
.select()
.from(schema.scanJobs)
.where(and(eq(schema.scanJobs.id, input.scanJobId), eq(schema.scanJobs.userId, req.userId)))
.limit(1);
if (!job?.result) throw errors.badRequest("Skanningen har inget resultat.");
const result = job.result as { kcalRange?: { min: number; max: number; mostLikely: number } };
if (!result.kcalRange) throw errors.badRequest("Skanningen saknar kaloriuppskattning.");
nutrition = { ...EMPTY_NUTRITION, kcal: result.kcalRange.mostLikely };
isEstimate = true;
estimateMin = result.kcalRange.min;
estimateMax = result.kcalRange.max;
} else {
throw errors.badRequest("Ange recept, livsmedel, skanning eller egna näringsvärden.");
}
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: req.userId,
householdId,
date: input.date,
mealType: input.mealType,
source: input.source,
recipeId: input.recipeId ?? null,
titleSv: input.titleSv,
portionFraction: input.portionFraction,
nutrition,
nutritionIsEstimate: isEstimate,
estimateMinKcal: estimateMin,
estimateMaxKcal: estimateMax,
items: input.items.length > 0 ? input.items : null,
scanJobId: input.scanJobId ?? null,
})
.returning();
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: {
mealId: meal!.id,
mealType: input.mealType,
kcal: nutrition.kcal,
source: input.source,
},
userId: req.userId,
householdId: householdId ?? undefined,
correlationId: req.correlationId,
});
return reply.status(201).send(meal);
});
/** "Min dag" (spec §4.3): måltider + summering mot personliga mål. */
app.get("/v1/meals/day", auth, async (req) => {
const { date } = parse(dayQuerySchema, req.query);
const meals = await app.db
.select()
.from(schema.meals)
.where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, date)))
.orderBy(schema.meals.loggedAt);
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
const targets =
profile?.weightKg && profile.heightCm && profile.birthYear
? computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: new Date().getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: prefs?.primaryGoal ?? undefined,
}).targets
: DEFAULT_TARGETS;
const summary = summarizeDay(
meals.map((m) => m.nutrition),
targets,
);
const hasEstimates = meals.some((m) => m.nutritionIsEstimate);
return {
date,
meals,
summary,
note: hasEstimates
? "Dagen innehåller uppskattade värden från foto justera gärna vid behov."
: "Värdena är beräknade ur recept och livsmedelsdata och visas som uppskattningar.",
};
});
app.delete("/v1/meals/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [meal] = await app.db
.select()
.from(schema.meals)
.where(and(eq(schema.meals.id, id), eq(schema.meals.userId, req.userId)))
.limit(1);
if (!meal) throw errors.notFound();
await app.db.delete(schema.meals).where(eq(schema.meals.id, id));
return { ok: true };
});
// ---------------------------------------------------------------------
// Matlådor (spec §24)
// ---------------------------------------------------------------------
app.get("/v1/meal-boxes", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const boxes = await app.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy);
return { mealBoxes: boxes };
});
app.post("/v1/meal-boxes", auth, async (req, reply) => {
const input = parse(createMealBoxInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
let nutritionPerPortion = null;
if (input.recipeId) {
const [recipe] = await app.db
.select({ nutrition: schema.recipes.nutritionPerPortion })
.from(schema.recipes)
.where(eq(schema.recipes.id, input.recipeId))
.limit(1);
nutritionPerPortion = recipe?.nutrition ?? null;
}
const cookedAt = input.cookedAt ?? todayIso();
const useByDays = input.frozen ? 90 : 3;
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: input.recipeId ?? null,
titleSv: input.titleSv,
portions: input.portions,
portionsRemaining: input.portions,
nutritionPerPortion,
cookedAt,
storageLocationId: input.storageLocationId,
frozen: input.frozen,
recommendedUseBy: new Date(Date.parse(cookedAt) + useByDays * 86_400_000)
.toISOString()
.slice(0, 10),
reservedForUserId: input.reservedForUserId ?? null,
})
.returning();
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.portions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return reply.status(201).send(box);
});
app.post("/v1/meal-boxes/:id/consume", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(consumeMealBoxInputSchema, req.body);
const [box] = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.id, id))
.limit(1);
if (!box) throw errors.notFound("Matlådan finns inte.");
await requireMembership(app.db, box.householdId, req.userId);
if (box.portionsRemaining < input.portions) {
throw errors.conflict(`Bara ${box.portionsRemaining} portioner kvar.`);
}
const remaining = box.portionsRemaining - input.portions;
await app.db
.update(schema.mealBoxes)
.set({ portionsRemaining: remaining, status: remaining <= 0 ? "consumed" : box.status })
.where(eq(schema.mealBoxes.id, id));
let mealId: string | null = null;
if (input.logAsMeal && box.nutritionPerPortion) {
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: req.userId,
householdId: box.householdId,
date: input.date ?? todayIso(),
mealType: input.mealType,
source: "meal_box",
recipeId: box.recipeId,
titleSv: box.titleSv,
portionFraction: input.portions,
nutrition: scaleNutrition(box.nutritionPerPortion, input.portions),
nutritionIsEstimate: false,
})
.returning();
mealId = meal!.id;
}
await emitEvent(app.db, {
type: "MEAL_BOX_CONSUMED",
payload: { mealBoxId: id, portions: input.portions },
userId: req.userId,
householdId: box.householdId,
correlationId: req.correlationId,
});
return { ok: true, portionsRemaining: remaining, mealId };
});
app.post("/v1/meal-boxes/:id/discard", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [box] = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.id, id))
.limit(1);
if (!box) throw errors.notFound();
await requireMembership(app.db, box.householdId, req.userId);
await app.db
.update(schema.mealBoxes)
.set({ status: "discarded", portionsRemaining: 0 })
.where(eq(schema.mealBoxes.id, id));
return { ok: true };
});
/** Måltidshistorik ("tidigare måltid" som loggkälla, spec §23). */
app.get("/v1/meals/recent", auth, async (req) => {
const meals = await app.db
.select()
.from(schema.meals)
.where(eq(schema.meals.userId, req.userId))
.orderBy(desc(schema.meals.loggedAt))
.limit(20);
return { meals };
});
}
+143
View File
@@ -0,0 +1,143 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, or } from "drizzle-orm";
import { schema } from "@app/database";
import { buildMemoryOverview } from "@app/memory-client";
import { userLanguageTag } from "../lib/contentLanguage.js";
import { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
/**
* "Vad plattformen vet om mig" (spec §32): full transparens.
* Användaren kan korrigera, pausa, radera varje post och radera allt.
*/
export async function memoryRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/me/memory", auth, async (req) => {
const q = parse(memoryQuerySchema, req.query);
const householdId = await getActiveHouseholdId(app.db, req.userId);
const scope = householdId
? or(
eq(schema.memoryItems.userId, req.userId),
eq(schema.memoryItems.householdId, householdId),
)!
: eq(schema.memoryItems.userId, req.userId);
const conditions = [scope];
if (q.kind) conditions.push(eq(schema.memoryItems.kind, q.kind));
if (!q.includePaused) conditions.push(eq(schema.memoryItems.paused, false));
const items = await app.db
.select()
.from(schema.memoryItems)
.where(and(...conditions))
.orderBy(desc(schema.memoryItems.updatedAt))
.limit(q.limit)
.offset(q.offset);
const overview = buildMemoryOverview(
items.map((i) => ({
id: i.id,
userId: i.userId ?? undefined,
householdId: i.householdId ?? undefined,
kind: i.kind,
key: i.key,
summarySv: i.summarySv,
value: i.value,
origin: i.origin,
confidence: i.confidence,
verifiedByUser: i.verifiedByUser,
paused: i.paused,
createdAt: i.createdAt.toISOString(),
updatedAt: i.updatedAt.toISOString(),
lastUsedAt: i.lastUsedAt?.toISOString(),
expiresAt: i.expiresAt?.toISOString(),
})),
await userLanguageTag(app.db, req.userId),
);
return overview;
});
app.patch("/v1/me/memory/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(updateMemoryItemInputSchema, req.body);
const item = await getOwnedMemory(app, id, req.userId);
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (input.summarySv != null) updates.summarySv = input.summarySv;
if (input.value !== undefined) updates.value = input.value;
if (input.paused != null) updates.paused = input.paused;
if (input.verified != null || input.summarySv != null || input.value !== undefined) {
// Användarkorrigering gör posten verifierad och användarägd (spec §30).
updates.verifiedByUser = true;
updates.origin = "user_stated";
updates.confidence = 1;
}
const [row] = await app.db
.update(schema.memoryItems)
.set(updates)
.where(eq(schema.memoryItems.id, item.id))
.returning();
await emitEvent(app.db, {
type: "MEMORY_UPDATED",
payload: { memoryItemId: id, kind: item.kind, origin: "user_stated" },
userId: req.userId,
correlationId: req.correlationId,
});
return row;
});
app.delete("/v1/me/memory/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const item = await getOwnedMemory(app, id, req.userId);
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.id, item.id));
await audit(app.db, {
actorUserId: req.userId,
action: "memory.deleted",
targetType: "memory_item",
targetId: id,
});
return { ok: true };
});
/** Radera ALLT personligt minne (spec §32: "radera"). */
app.delete("/v1/me/memory", auth, async (req) => {
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, req.userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, req.userId));
await audit(app.db, { actorUserId: req.userId, action: "memory.deleted_all" });
return { ok: true, message: "Allt personligt minne är raderat." };
});
/** Pausa allt minne (spec §32: "pausa"). */
app.post("/v1/me/memory/pause-all", auth, async (req) => {
const body = (req.body ?? {}) as { paused?: boolean };
const paused = body.paused ?? true;
await app.db
.update(schema.memoryItems)
.set({ paused, updatedAt: new Date() })
.where(eq(schema.memoryItems.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
action: paused ? "memory.paused_all" : "memory.resumed_all",
});
return { ok: true, paused };
});
}
async function getOwnedMemory(app: FastifyInstance, id: string, userId: string) {
const [item] = await app.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.id, id))
.limit(1);
if (!item) throw errors.notFound("Minnesposten finns inte.");
if (item.userId && item.userId !== userId) throw errors.forbidden();
if (!item.userId && item.householdId) {
const { requireMembership } = await import("../lib/helpers.js");
await requireMembership(app.db, item.householdId, userId);
}
return item;
}
+165
View File
@@ -0,0 +1,165 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
generateWeekPlanInputSchema,
idParamSchema,
updatePlanEntryInputSchema,
weekPlanQuerySchema,
} from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
/**
* Veckoplanering (spec §25). Planen genereras asynkront av workern
* (GENERATE_WEEK_PLAN) som väger lager, utgångsdatum, matlådor, budget,
* variation och mål med deterministisk kärna och AAMOS som rådgivare.
*/
export async function planningRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/week-plans", auth, async (req) => {
const q = parse(weekPlanQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const conditions = [eq(schema.weekPlans.householdId, householdId)];
if (q.weekStartDate) conditions.push(eq(schema.weekPlans.weekStartDate, q.weekStartDate));
const plans = await app.db
.select()
.from(schema.weekPlans)
.where(and(...conditions))
.orderBy(desc(schema.weekPlans.weekStartDate))
.limit(8);
const result = [];
for (const plan of plans) {
const entries = await app.db
.select()
.from(schema.weekPlanEntries)
.where(eq(schema.weekPlanEntries.weekPlanId, plan.id))
.orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder);
result.push({ ...plan, entries });
}
return { plans: result };
});
app.post("/v1/week-plans/generate", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "weekPlanning", "Veckoplanering");
const input = parse(generateWeekPlanInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [plan] = await app.db
.insert(schema.weekPlans)
.values({
householdId,
weekStartDate: input.weekStartDate,
status: "draft",
generatedBy: "engine",
notes: input.noteSv ?? null,
})
.returning();
await app.jobQueue.add("GENERATE_WEEK_PLAN", {
jobType: "GENERATE_WEEK_PLAN",
weekPlanId: plan!.id,
householdId,
userId: req.userId,
input,
correlationId: req.correlationId,
});
return reply.status(202).send({
plan,
message: "Planen genereras hämta den om en stund via GET /v1/week-plans.",
});
});
app.patch("/v1/week-plans/:id/entries/:entryId", auth, async (req) => {
const params = req.params as { id: string; entryId: string };
const [plan] = await app.db
.select()
.from(schema.weekPlans)
.where(eq(schema.weekPlans.id, params.id))
.limit(1);
if (!plan) throw errors.notFound("Planen finns inte.");
await requireMembership(app.db, plan.householdId, req.userId);
const input = parse(updatePlanEntryInputSchema, req.body);
const updates: Record<string, unknown> = { ...input };
// Dynamisk omplanering med förklaring (spec §25)
if (input.status === "skipped") {
const [entry] = await app.db
.select()
.from(schema.weekPlanEntries)
.where(eq(schema.weekPlanEntries.id, params.entryId))
.limit(1);
if (entry?.recipeId) {
// Flytta rätten till nästa lediga dag om råvaror bör användas.
const later = await app.db
.select()
.from(schema.weekPlanEntries)
.where(
and(
eq(schema.weekPlanEntries.weekPlanId, params.id),
gte(schema.weekPlanEntries.date, entry.date),
eq(schema.weekPlanEntries.status, "planned"),
sql`${schema.weekPlanEntries.id} <> ${params.entryId}`,
),
)
.orderBy(schema.weekPlanEntries.date)
.limit(1);
if (later[0]) {
await app.db
.update(schema.weekPlanEntries)
.set({
recipeId: entry.recipeId,
titleSv: entry.titleSv,
status: "moved",
rescheduleReasonSv: `${entry.titleSv} flyttades hit eftersom råvarorna bör användas först.`,
})
.where(eq(schema.weekPlanEntries.id, later[0].id));
}
}
}
const [row] = await app.db
.update(schema.weekPlanEntries)
.set(updates)
.where(
and(
eq(schema.weekPlanEntries.id, params.entryId),
eq(schema.weekPlanEntries.weekPlanId, params.id),
),
)
.returning();
if (!row) throw errors.notFound("Planposten finns inte.");
await emitEvent(app.db, {
type: "WEEK_PLAN_UPDATED",
payload: { weekPlanId: params.id, reason: input.status ?? null },
userId: req.userId,
householdId: plan.householdId,
correlationId: req.correlationId,
});
return row;
});
app.post("/v1/week-plans/:id/activate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [plan] = await app.db
.select()
.from(schema.weekPlans)
.where(eq(schema.weekPlans.id, id))
.limit(1);
if (!plan) throw errors.notFound();
await requireMembership(app.db, plan.householdId, req.userId);
const [row] = await app.db
.update(schema.weekPlans)
.set({ status: "active", updatedAt: new Date() })
.where(eq(schema.weekPlans.id, id))
.returning();
return row;
});
}
+1031
View File
@@ -0,0 +1,1031 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
cookRecipeInputSchema,
createUserRecipeInputSchema,
idParamSchema,
rateRecipeInputSchema,
recipeQuerySchema,
substitutionQuerySchema,
} from "@app/validation";
import {
checkRecipeSafety,
deriveRecipeAllergens,
scaleIngredients,
type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import {
languageCandidates,
resolveIngredientNames,
resolveRecipeTranslation,
userLanguageTag,
} from "../lib/contentLanguage.js";
import {
emitEvent,
getActiveHouseholdId,
requireActiveHousehold,
todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recipes", auth, async (req) => {
const q = parse(recipeQuerySchema, req.query);
const conditions = [eq(schema.recipes.status, "published")];
if (q.search) {
conditions.push(
or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
)!,
);
}
if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine));
if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`);
if (q.tags)
for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`);
if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`);
if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes));
if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty));
if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId));
if (q.maxKcalPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`,
);
}
if (q.minProteinPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`,
);
}
if (q.maxCostMinorPerPortion) {
conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion));
}
// Deterministisk allergifiltrering på databasnivå (spec §61.2)
if (q.excludeAllergens) {
for (const allergen of q.excludeAllergens) {
conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`);
}
}
const orderBy =
q.sort === "rating"
? desc(schema.recipes.ratingAverage)
: q.sort === "cooked"
? desc(schema.recipes.cookCount)
: q.sort === "newest"
? desc(schema.recipes.createdAt)
: q.sort === "time"
? schema.recipes.totalTimeMinutes
: q.sort === "cost"
? schema.recipes.estimatedCostMinorPerPortion
: desc(schema.recipes.cookCount);
const rows = await app.db
.select({
id: schema.recipes.id,
slug: schema.recipes.slug,
titleSv: schema.recipes.titleSv,
descriptionSv: schema.recipes.descriptionSv,
cuisine: schema.recipes.cuisine,
mealTypes: schema.recipes.mealTypes,
tags: schema.recipes.tags,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
portions: schema.recipes.portions,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
allergens: schema.recipes.allergens,
spiceLevel: schema.recipes.spiceLevel,
estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion,
costCurrency: sql<string>`'SEK'`,
difficulty: schema.recipes.difficulty,
imageUrls: schema.recipes.imageUrls,
ratingAverage: schema.recipes.ratingAverage,
ratingCount: schema.recipes.ratingCount,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
creatorDisplayName: schema.recipes.creatorDisplayName,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(and(...conditions))
.orderBy(orderBy)
.limit(q.limit)
.offset(q.offset);
// Titlar på användarens språk där publicerad översättning finns (i18n M3).
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
let titleMap = new Map<string, string>();
if (!candidates.includes("sv") && rows.length > 0) {
const translations = await app.db
.select({
recipeId: schema.recipeTranslations.recipeId,
languageTag: schema.recipeTranslations.languageTag,
title: schema.recipeTranslations.title,
})
.from(schema.recipeTranslations)
.where(
and(
inArray(
schema.recipeTranslations.recipeId,
rows.map((r) => r.id),
),
inArray(schema.recipeTranslations.languageTag, candidates),
eq(schema.recipeTranslations.status, "published"),
),
);
for (const candidate of [...candidates].reverse()) {
for (const tr of translations)
if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title);
}
}
return {
recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
app.get("/v1/recipes/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const recipe = await loadFullRecipe(app, id);
// Personlig säkerhetskontroll deterministisk (spec §61.2).
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] };
if (prefs) {
const info = await ingredientSafetyMap(
app,
recipe.ingredients.map((i) => i.canonicalIngredientId),
);
const violations = checkRecipeSafety(
{
ingredients: recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: prefs.allergens,
dietPattern: prefs.dietPattern,
religiousRule: prefs.religiousRule,
avoidIngredientIds: prefs.avoidIngredientIds,
spiceLevelMax: prefs.spiceLevelMax,
},
info,
);
safety = { safe: !violations.some((v) => v.severity === "blocker"), violations };
}
// Varianter (spec §16)
const variants = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(
and(
or(
eq(schema.recipes.variantOfRecipeId, id),
recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`,
),
eq(schema.recipes.status, "published"),
),
);
const [myRating] = await app.db
.select()
.from(schema.recipeRatings)
.where(
and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)),
)
.limit(1);
const [favorite] = await app.db
.select()
.from(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
)
.limit(1);
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
app.db,
recipe.ingredients.map((i) => i.canonicalIngredientId),
languageTag,
);
return {
...recipe,
language: translation?.language ?? "sv",
title: translation?.title ?? recipe.titleSv,
description: translation ? translation.description : recipe.descriptionSv,
storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv,
ingredients: recipe.ingredients.map((i) => ({
...i,
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
})),
steps: recipe.steps.map((s) => {
const ts = translation?.steps.get(s.stepNumber);
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
}),
safety,
variants,
myRating: myRating ?? null,
isFavorite: Boolean(favorite),
};
});
/** Skala recept (Cooking Mode: "skala till sex personer"). */
app.get("/v1/recipes/:id/scaled", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const portions = Number((req.query as { portions?: string }).portions ?? 4);
if (!Number.isInteger(portions) || portions < 1 || portions > 24) {
throw errors.badRequest("portions måste vara 124.");
}
const recipe = await loadFullRecipe(app, id);
const scaled = scaleIngredients(
recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
recipe.portions,
portions,
);
return {
recipeId: id,
portions,
ingredients: scaled,
nutritionPerPortion: recipe.nutritionPerPortion,
};
});
app.post("/v1/recipes/:id/rate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(rateRecipeInputSchema, req.body);
await loadFullRecipe(app, id);
await app.db
.insert(schema.recipeRatings)
.values({
recipeId: id,
userId: req.userId,
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
})
.onConflictDoUpdate({
target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId],
set: {
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
updatedAt: new Date(),
},
});
// Uppdatera aggregat
const [agg] = await app.db
.select({
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
count: sql<number>`count(*)`,
})
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.recipeId, id));
await app.db
.update(schema.recipes)
.set({
ratingAverage: agg ? Number(agg.avg) : null,
ratingCount: agg ? Number(agg.count) : 0,
})
.where(eq(schema.recipes.id, id));
// Smaksignaler ur feedback (spec §30) explicit användarsignal.
const tagToAxis: Record<
string,
{ axis: "spice" | "salt" | "acid" | "creaminess"; dir: number }
> = {
too_spicy: { axis: "spice", dir: -1 },
too_mild: { axis: "spice", dir: 1 },
too_salty: { axis: "salt", dir: -1 },
too_sour: { axis: "acid", dir: -1 },
too_little_sauce: { axis: "creaminess", dir: 1 },
};
for (const tag of input.feedbackTags) {
const mapping = tagToAxis[tag];
if (mapping) {
await app.db.insert(schema.tasteSignals).values({
userId: req.userId,
axis: mapping.axis,
direction: mapping.dir,
strength: 0.7,
origin: "user_stated",
refRecipeId: id,
});
}
}
await emitEvent(app.db, {
type: "RECIPE_RATED",
payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags },
userId: req.userId,
correlationId: req.correlationId,
});
return { ok: true };
});
app.post("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.insert(schema.recipeFavorites)
.values({ recipeId: id, userId: req.userId })
.onConflictDoNothing();
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.delete("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.delete(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
);
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.get("/v1/recipes/favorites/mine", auth, async (req) => {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
imageUrls: schema.recipes.imageUrls,
})
.from(schema.recipeFavorites)
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
.where(eq(schema.recipeFavorites.userId, req.userId))
.orderBy(desc(schema.recipeFavorites.createdAt));
return { recipes: rows };
});
/**
* "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO),
* måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen.
*/
app.post("/v1/recipes/:id/cook", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookRecipeInputSchema, req.body);
const recipe = await loadFullRecipe(app, id);
const householdId = await requireActiveHousehold(app.db, req.userId);
const date = input.date ?? todayIso();
// 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum)
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory) {
const factor = input.portionsCooked / recipe.portions;
const overrides = new Map(input.inventoryOverrides.map((o) => [o.canonicalIngredientId, o]));
for (const ing of recipe.ingredients) {
if (ing.optional) continue;
const override = overrides.get(ing.canonicalIngredientId);
const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
const requiredUnit = override ? override.unit : ing.unit;
if (requiredQty <= 0) continue;
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
if (stock.length === 0) continue;
const [info] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
.limit(1);
const allocation = allocateFefo(
requiredQty,
requiredUnit,
stock.map((s) => ({
id: s.id,
canonicalIngredientId: s.canonicalIngredientId,
quantity: s.quantity,
unit: s.unit,
bestBeforeDate: s.bestBeforeDate,
useByDate: s.useByDate,
openedAt: s.openedAt,
frozenAt: s.frozenAt,
thawedAt: s.thawedAt,
purchasedAt: s.purchasedAt,
})),
{ densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
);
for (const alloc of allocation.allocations) {
const item = stock.find((s) => s.id === alloc.itemId)!;
const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: alloc.itemId,
type: "cook_use",
quantityDelta: -(item.quantity - newQty),
unit: item.unit,
refType: "recipe_cook",
refId: id,
actorUserId: req.userId,
});
await app.db
.update(schema.inventoryItems)
.set({
quantity: newQty,
depletedAt: newQty <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, alloc.itemId));
deductions.push({
itemId: alloc.itemId,
quantity: alloc.quantity,
unit: alloc.unit,
name: item.displayName,
});
}
}
}
// 2. Logga måltid per ätare med portionsandel (spec §7: individuellt)
const eaters =
input.eaters.length > 0 ? input.eaters : [{ userId: req.userId, portionFraction: 1 }];
const mealIds: string[] = [];
for (const eater of eaters) {
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: eater.userId,
householdId,
date,
mealType: input.mealType,
source: "cooked_recipe",
recipeId: id,
titleSv: recipe.titleSv,
portionFraction: eater.portionFraction,
nutrition,
nutritionIsEstimate: false,
})
.returning();
mealIds.push(meal!.id);
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: {
mealId: meal!.id,
mealType: input.mealType,
kcal: nutrition.kcal,
source: "cooked_recipe",
},
userId: eater.userId,
householdId,
correlationId: req.correlationId,
});
}
// 3. Matlådor (spec §24)
let mealBoxId: string | null = null;
if (input.mealBoxPortions > 0) {
const locationId =
input.mealBoxStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, householdId),
eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
const useByDays = input.mealBoxFrozen ? 90 : 3;
const recommendedUseBy = new Date(Date.now() + useByDays * 86_400_000)
.toISOString()
.slice(0, 10);
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: id,
titleSv: recipe.titleSv,
portions: input.mealBoxPortions,
portionsRemaining: input.mealBoxPortions,
nutritionPerPortion: recipe.nutritionPerPortion,
cookedAt: date,
storageLocationId: locationId,
frozen: input.mealBoxFrozen,
recommendedUseBy,
})
.returning();
mealBoxId = box!.id;
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.mealBoxPortions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
}
// 4. Statistik + event
await app.db.insert(schema.recipeCooks).values({
recipeId: id,
userId: req.userId,
householdId,
portionsCooked: input.portionsCooked,
});
await app.db
.update(schema.recipes)
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
.where(eq(schema.recipes.id, id));
await emitEvent(app.db, {
type: "RECIPE_COOKED",
payload: {
recipeId: id,
portions: input.portionsCooked,
mealBoxPortions: input.mealBoxPortions,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
});
/** Substitutionsförslag för en ingrediens (spec §20). */
app.get("/v1/substitutions", auth, async (req) => {
const q = parse(substitutionQuerySchema, req.query);
const subs = await app.db
.select({
sub: schema.substitutions,
toName: schema.canonicalIngredients.nameSv,
})
.from(schema.substitutions)
.innerJoin(
schema.canonicalIngredients,
eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id),
)
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
.orderBy(desc(schema.substitutions.priority));
return {
substitutions: subs.map((s) => ({
...s.sub,
toNameSv: s.toName,
contextWarning:
q.context && s.sub.notRecommendedFor.includes(q.context)
? `Rekommenderas inte för ${q.context}.`
: null,
})),
};
});
/**
* Ingrediens-sök (manuell registrering, spec §9; i18n M2).
* Söker i svenska namn/alias OCH i publicerade översättningar för
* användarens språk; svaret bär namn upplöst till användarens språk.
*/
app.get("/v1/ingredients", auth, async (req) => {
const search = String((req.query as { search?: string }).search ?? "").trim();
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
// i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm).
// "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar".
const pattern = `%${search}%`;
const translationMatch = sql`EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = ${schema.canonicalIngredients.id}
AND it.status = 'published'
AND it.language_tag IN ${candidates}
AND (unaccent(it.name) ILIKE unaccent(${pattern})
OR similarity(it.name, ${search}) > 0.35
OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern})))
)`;
const conditions = search
? or(
sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`,
sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`,
sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`,
...(candidates.includes("sv") ? [] : [translationMatch]),
)
: undefined;
const rows = await app.db
.select({
id: schema.canonicalIngredients.id,
nameSv: schema.canonicalIngredients.nameSv,
category: schema.canonicalIngredients.category,
defaultUnit: schema.canonicalIngredients.defaultUnit,
allergens: schema.canonicalIngredients.allergens,
})
.from(schema.canonicalIngredients)
.where(conditions)
.orderBy(
search
? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}`
: schema.canonicalIngredients.nameSv,
)
.limit(30);
const names = await resolveIngredientNames(
app.db,
rows.map((r) => r.id),
languageTag,
);
return {
ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
/**
* Marknadsprofil för näringsvisning + allergenframhävning (i18n M6).
* Fallback: EU. Styr endast VISNING säkerhetsfiltrering per användare
* (spec §61.2) påverkas aldrig av marknadsprofilen.
*/
app.get("/v1/i18n/nutrition-profile", auth, async (req) => {
const requested = String((req.query as { region?: string }).region ?? "").toUpperCase();
const region =
requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase();
const [profile] =
(await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, region))
.limit(1)) ?? [];
const [fallback] = profile
? [profile]
: await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, "EU"))
.limit(1);
const effective = fallback!;
const allergens = await app.db
.select({ allergen: schema.allergenMarketRules.allergen })
.from(schema.allergenMarketRules)
.where(eq(schema.allergenMarketRules.regionCode, effective.regionCode));
return {
regionCode: effective.regionCode,
requestedRegion: region,
energyDisplay: effective.energyDisplay,
saltDisplay: effective.saltDisplay,
energyLabelKey: effective.energyLabelKey,
highlightAllergens: allergens.map((a) => a.allergen).sort(),
};
});
/** Enhetsetiketter per språk (i18n M2) för klienter som inte vill hårdkoda. */
app.get("/v1/i18n/units", auth, async (req) => {
const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv");
const candidates = languageCandidates(languageTag);
const rows = await app.db
.select()
.from(schema.unitTranslations)
.where(inArray(schema.unitTranslations.languageTag, candidates));
const byUnit = new Map<string, (typeof rows)[number]>();
for (const candidate of [...candidates].reverse())
for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r);
return {
languageTag,
units: [...byUnit.values()].map((r) => ({
unitCode: r.unitCode,
abbreviation: r.abbreviation,
name: r.name,
})),
};
});
/**
* Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS
* strukturerar. Näring/allergener beräknas ALLTID deterministiskt här.
* Publiceringsflöde: submitted → AI-kontroll → moderation → published.
*/
app.post("/v1/recipes", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "communityPublish", "Egna recept");
const input = parse(createUserRecipeInputSchema, req.body);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
let structured: {
titleSv: string;
descriptionSv: string;
ingredients: Array<{
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
steps: Array<{
instructionSv: string;
timerSeconds?: number | null;
temperatureC?: number | null;
}>;
prepMin: number;
cookMin: number;
portions: number;
cuisine: string;
mealTypes: string[];
tags: string[];
methods: string[];
equipment: string[];
difficulty: string;
spiceLevel: number;
};
if (input.mode === "free_text") {
const result = await app.aamos.runTask(
"STRUCTURE_RECIPE_TEXT",
{ text: input.text, marketLocale: "sv-SE" },
{
correlationId: req.correlationId,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status !== "ok" || !result.output) {
throw errors.badRequest(
"Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.",
);
}
const out = result.output;
const ingredients = out.ingredients
.filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId!,
displayNameSv: i.displayNameSv,
quantity: i.quantity!,
unit: i.unit!,
optional: i.optional,
}));
if (ingredients.length === 0) {
throw errors.badRequest(
"Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.",
);
}
structured = {
titleSv: out.titleSv ?? "Mitt recept",
descriptionSv: out.descriptionSv ?? "",
ingredients,
steps: out.steps,
prepMin: out.prepTimeMinutes ?? 15,
cookMin: out.cookTimeMinutes ?? 20,
portions: out.portions ?? 4,
cuisine: out.suggestedCuisine ?? "international",
mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
spiceLevel: 0,
};
} else {
structured = {
titleSv: input.titleSv,
descriptionSv: input.descriptionSv,
ingredients: input.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
steps: input.steps.map((s) => ({
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
prepMin: input.prepTimeMinutes,
cookMin: input.cookTimeMinutes,
portions: input.portions,
cuisine: input.cuisine,
mealTypes: input.mealTypes,
tags: input.tags,
methods: input.methods,
equipment: input.equipment,
difficulty: input.difficulty,
spiceLevel: input.spiceLevel,
};
}
// Deterministisk näring + allergener (spec §61.12)
const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId);
const dbIngredients = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const sourceMap = new Map(
dbIngredients.map((i) => [
i.id,
{
nutritionPer100: i.nutritionPer100,
densityGPerMl: i.densityGPerMl,
gramsPerPiece: i.gramsPerPiece,
},
]),
);
const missing = ingredientIds.filter((id) => !sourceMap.has(id));
if (missing.length > 0) {
throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing });
}
const nutrition = computeRecipeNutrition(
structured.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
})),
structured.portions,
sourceMap,
);
const safetyInfo = await ingredientSafetyMap(app, ingredientIds);
const allergens = deriveRecipeAllergens(
structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId),
safetyInfo,
);
const slug = `${structured.titleSv
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/ö/g, "o")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)}-${Date.now().toString(36)}`;
const [recipe] = await app.db
.insert(schema.recipes)
.values({
slug,
titleSv: structured.titleSv,
descriptionSv: structured.descriptionSv,
cuisine: structured.cuisine as never,
mealTypes: structured.mealTypes as never,
tags: structured.tags,
methods: structured.methods,
equipment: structured.equipment,
difficulty: structured.difficulty as never,
prepTimeMinutes: structured.prepMin,
cookTimeMinutes: structured.cookMin,
totalTimeMinutes: structured.prepMin + structured.cookMin,
portions: structured.portions,
nutritionPerPortion: nutrition.perPortion,
allergens,
spiceLevel: structured.spiceLevel,
dna: {
cuisine: structured.cuisine as never,
vegetables: [],
flavorProfile: [],
spiceLevel: structured.spiceLevel,
method: (structured.methods[0] ?? "stovetop") as never,
timeMinutes: structured.prepMin + structured.cookMin,
calories: nutrition.perPortion.kcal,
proteinGrams: Math.round(nutrition.perPortion.proteinG),
},
status: "submitted",
verificationStatus: "unverified",
sourceType: "user_generated",
creatorUserId: req.userId,
creatorDisplayName: user?.displayName ?? "Okänd",
})
.returning();
await app.db.insert(schema.recipeIngredients).values(
structured.ingredients.map((i, idx) => ({
recipeId: recipe!.id,
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
sortOrder: idx,
})),
);
await app.db.insert(schema.recipeSteps).values(
structured.steps.map((s, idx) => ({
recipeId: recipe!.id,
stepNumber: idx + 1,
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
);
// AI-kontroll + moderering sker asynkront i workern (spec §35 steg 24).
await app.jobQueue.add("MODERATE_RECIPE", {
jobType: "MODERATE_RECIPE",
recipeId: recipe!.id,
correlationId: req.correlationId,
});
await emitEvent(app.db, {
type: "RECIPE_CREATED",
payload: { recipeId: recipe!.id, sourceType: "user_generated" },
userId: req.userId,
correlationId: req.correlationId,
});
return reply.status(201).send({
recipe,
uncomputableIngredients: nutrition.uncomputableIngredientIds,
message: "Receptet är inskickat och granskas innan publicering.",
});
});
}
async function loadFullRecipe(app: FastifyInstance, id: string) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
const ingredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id))
.orderBy(schema.recipeIngredients.sortOrder);
const steps = await app.db
.select()
.from(schema.recipeSteps)
.where(eq(schema.recipeSteps.recipeId, id))
.orderBy(schema.recipeSteps.stepNumber);
return { ...recipe, ingredients, steps };
}
async function ingredientSafetyMap(
app: FastifyInstance,
ids: string[],
): Promise<Map<string, IngredientSafetyInfo>> {
if (ids.length === 0) return new Map();
const rows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ids));
return new Map(
rows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
}
+353
View File
@@ -0,0 +1,353 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { whatToEatQuerySchema } from "@app/validation";
import {
computeCoverage,
checkRecipeSafety,
isRecipeSafe,
type IngredientSafetyInfo,
type PantryItem,
} from "@app/recipe-engine";
import {
isEventActive,
parseCraving,
rankAll,
seasonForDate,
summarizeContext,
type RecommendationCandidate,
type RecommendationContext,
} from "@app/recommendation-engine";
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
import { parse } from "../lib/errors.js";
import { requireActiveHousehold, todayIso } from "../lib/helpers.js";
/**
* "Vad ska vi äta?" (spec §18) appens viktigaste endpoint.
*
* Pipeline:
* 1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext.
* 2. Deterministisk säkerhetsfiltrering (spec §61.2) blockers försvinner.
* 3. Täckningsberäkning mot lagret + poängsättning med förklaringar.
* 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24).
* 5. (Bakom flagga) AAMOS får omranka topplistan aldrig lägga till recept.
*/
export async function recommendationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recommendations/what-to-eat", auth, async (req) => {
const q = parse(whatToEatQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const today = new Date();
// --- 1. Kontext: lager ---
const stockRows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
density: schema.canonicalIngredients.densityGPerMl,
gramsPerPiece: schema.canonicalIngredients.gramsPerPiece,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const pantry: PantryItem[] = stockRows.map((r) => ({
id: r.item.id,
canonicalIngredientId: r.item.canonicalIngredientId,
quantity: r.item.quantity,
unit: r.item.unit,
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}));
const unitInfo = new Map(
stockRows
.filter((r) => r.item.canonicalIngredientId)
.map((r) => [
r.item.canonicalIngredientId!,
{ densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece },
]),
);
// --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) ---
const members = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
const memberIds = members.map((m) => m.userId);
const allPrefs = await app.db
.select()
.from(schema.userPreferences)
.where(inArray(schema.userPreferences.userId, memberIds));
const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))];
const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))];
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
const myPrefs = allPrefs.find((p) => p.userId === req.userId);
// --- 3. Kandidater: publicerade recept för måltidstypen ---
const candidates = await app.db
.select()
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
),
)
.limit(200);
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(
inArray(
schema.recipeIngredients.recipeId,
candidates.map((c) => c.id),
),
);
const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))];
const safetyRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const safetyMap = new Map<string, IngredientSafetyInfo>(
safetyRows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
for (const r of safetyRows) {
if (!unitInfo.has(r.id)) {
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
}
}
// --- 4. Näringskontext: vad återstår av dagen? ---
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const targets =
profile?.weightKg && profile.heightCm && profile.birthYear
? computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: today.getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: myPrefs?.primaryGoal ?? undefined,
}).targets
: DEFAULT_TARGETS;
const todaysMeals = await app.db
.select({ nutrition: schema.meals.nutrition })
.from(schema.meals)
.where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso())));
const daySummary = summarizeDay(
todaysMeals.map((m) => m.nutrition),
targets,
);
// --- 5. Säsong & högtid (spec §28) ---
const events = await app.db
.select()
.from(schema.seasonEvents)
.where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, "SE")));
const activeHolidayTags = events
.filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today))
.map((e) => e.slug);
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
last: sql<string>`max(${schema.recipeCooks.cookedAt})`,
})
.from(schema.recipeCooks)
.where(eq(schema.recipeCooks.householdId, householdId))
.groupBy(schema.recipeCooks.recipeId);
const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last]));
const householdRatings = await app.db
.select({
recipeId: schema.recipeRatings.recipeId,
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
})
.from(schema.recipeRatings)
.where(inArray(schema.recipeRatings.userId, memberIds))
.groupBy(schema.recipeRatings.recipeId);
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
// --- 7. Tolka "jag är sugen på" (spec §19) ---
const craving = q.craving ? parseCraving(q.craving) : null;
const ctx: RecommendationContext = {
mealType: q.mealType,
persons: q.persons ?? members.length,
maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined,
maxCostMinorPerPortion: q.maxCostMinorPerPortion,
remainingProteinG: Math.max(0, daySummary.remaining.proteinG),
remainingKcal: Math.max(0, daySummary.remaining.kcal),
currentSeason: seasonForDate(today),
activeHolidayTags,
isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4,
favoriteCuisines: myPrefs?.favoriteCuisines ?? [],
cravingTags: craving?.tags,
cravingCuisine: craving?.cuisine,
cravingMaxKcal: craving?.maxKcal,
};
// --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
const scoredCandidates: RecommendationCandidate[] = [];
for (const recipe of candidates) {
const recipeIngredients = allIngredients
.filter((i) => i.recipeId === recipe.id)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
}));
const violations = checkRecipeSafety(
{
ingredients: recipeIngredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: combinedAllergens,
dietPattern: myPrefs?.dietPattern,
religiousRule: myPrefs?.religiousRule,
avoidIngredientIds: combinedAvoid,
spiceLevelMax: strictestSpice,
},
safetyMap,
);
if (!isRecipeSafe(violations)) continue;
const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today);
const lastDate = lastCooked.get(recipe.id);
scoredCandidates.push({
recipeId: recipe.id,
titleSv: recipe.titleSv,
cuisine: recipe.cuisine,
tags: recipe.tags as never,
totalTimeMinutes: recipe.totalTimeMinutes,
nutritionPerPortion: recipe.nutritionPerPortion,
estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion,
ratingAverage: recipe.ratingAverage,
ratingCount: recipe.ratingCount,
peakSeasons: recipe.peakSeasons,
holidayTags: recipe.holidayTags,
spiceLevel: recipe.spiceLevel,
coverage,
daysSinceLastCooked: lastDate
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
: null,
householdRating: householdRatingMap.get(recipe.id) ?? null,
});
}
let recommendations = rankAll(scoredCandidates, ctx, undefined, q.limit);
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
if (await app.flags.isEnabled("ai_rerank", req.userId)) {
const result = await app.aamos.runTask(
"RANK_RECIPES",
{
candidateIds: recommendations.map((r) => r.recipeId),
deterministicScores: Object.fromEntries(
recommendations.map((r) => [r.recipeId, r.score]),
),
contextSummary: summarizeContext(ctx),
},
{
correlationId: req.correlationId,
subjectRef: null,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status === "ok" && result.output) {
const order = new Map(result.output.rankedIds.map((id, i) => [id, i]));
recommendations = [...recommendations].sort(
(a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99),
);
}
}
// --- 10. Matlådor först när rimligt (spec §24) ---
const mealBoxes = q.includeLeftovers
? await app.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy)
.limit(5)
: [];
const mealBoxSuggestions = mealBoxes.map((box) => ({
mealBoxId: box.id,
titleSv: box.titleSv,
portionsRemaining: box.portionsRemaining,
recommendedUseBy: box.recommendedUseBy,
whySv:
Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000
? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.`
: "Färdig mat som väntar snabbaste middagen i huset.",
}));
return {
mealType: q.mealType,
context: {
persons: ctx.persons,
season: ctx.currentSeason,
activeHolidays: activeHolidayTags,
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
},
mealBoxSuggestions,
recommendations,
};
});
}
+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,
});
}
+406
View File
@@ -0,0 +1,406 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { StoreSection } from "@app/shared-types";
import {
addShoppingItemInputSchema,
completeShoppingInputSchema,
createShoppingListInputSchema,
idParamSchema,
updateShoppingItemInputSchema,
} from "@app/validation";
import { convert } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
/**
* Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per
* butiksavdelning, dela i hushållet, uppdatera lagret efter köp.
*/
const CATEGORY_TO_SECTION: Record<string, StoreSection> = {
mejeri: "mejeri",
kott_fagel: "kott_fagel",
fisk: "fisk",
gronsaker: "frukt_gront",
frukt: "frukt_gront",
spannmal: "skafferi",
baljvaxter: "skafferi",
skafferi: "skafferi",
konserver: "konserver",
kryddor: "kryddor_bak",
brod: "brod",
};
export async function shoppingRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/shopping-lists", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const lists = await app.db
.select()
.from(schema.shoppingLists)
.where(
and(
eq(schema.shoppingLists.householdId, householdId),
eq(schema.shoppingLists.status, "active"),
),
);
return { lists };
});
app.post("/v1/shopping-lists", auth, async (req, reply) => {
const input = parse(createShoppingListInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [list] = await app.db
.insert(schema.shoppingLists)
.values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null })
.returning();
// Generera från veckoplan: receptbehov befintligt lager (spec §27)
if (input.generateFromPlan && input.weekPlanId) {
await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId);
}
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, list!.id));
return reply.status(201).send({ list, items });
});
app.get("/v1/shopping-lists/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, id))
.orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder);
const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0);
// Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8).
return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" };
});
app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
await getOwnedList(app, id, req.userId);
const input = parse(addShoppingItemInputSchema, req.body);
let section = input.storeSection;
let estimatedPrice = input.estimatedPriceMinor;
if (input.canonicalIngredientId) {
const [ing] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId))
.limit(1);
if (ing) {
section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt";
if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) {
const grams = convert(input.quantity, input.unit, "GRAM", {
densityGPerMl: ing.densityGPerMl,
gramsPerPiece: ing.gramsPerPiece,
});
if (grams != null)
estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10;
}
}
}
// Slå ihop med befintlig rad för samma ingrediens (spec §27)
if (input.canonicalIngredientId) {
const [existing] = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId),
eq(schema.shoppingListItems.unit, input.unit),
eq(schema.shoppingListItems.checked, false),
),
)
.limit(1);
if (existing) {
const [merged] = await app.db
.update(schema.shoppingListItems)
.set({
quantity: existing.quantity + input.quantity,
estimatedPriceMinor:
existing.estimatedPriceMinor != null && estimatedPrice != null
? existing.estimatedPriceMinor + estimatedPrice
: (existing.estimatedPriceMinor ?? estimatedPrice ?? null),
})
.where(eq(schema.shoppingListItems.id, existing.id))
.returning();
return reply.send({ item: merged, merged: true });
}
}
const [item] = await app.db
.insert(schema.shoppingListItems)
.values({
shoppingListId: id,
canonicalIngredientId: input.canonicalIngredientId ?? null,
displayName: input.displayName,
quantity: input.quantity,
unit: input.unit,
storeSection: section ?? "hygien_ovrigt",
estimatedPriceMinor: estimatedPrice ?? null,
addedByUserId: req.userId,
origin: "manual",
})
.returning();
return reply.status(201).send({ item, merged: false });
});
app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
const input = parse(updateShoppingItemInputSchema, req.body);
const [item] = await app.db
.update(schema.shoppingListItems)
.set(input)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
)
.returning();
if (!item) throw errors.notFound();
return item;
});
app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
await app.db
.delete(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
);
return { ok: true };
});
/** Avsluta köprundan: bockade varor in i lagret (spec §27). */
app.post("/v1/shopping-lists/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const input = parse(completeShoppingInputSchema, req.body);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.checked, true),
),
);
let added = 0;
if (input.addToInventory && items.length > 0) {
const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s]));
const fallback =
input.defaultStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, list.householdId),
eq(schema.storageLocations.type, "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet.");
for (const item of items) {
const override = overrides.get(item.id);
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
householdId: list.householdId,
canonicalIngredientId: item.canonicalIngredientId,
displayName: item.displayName,
quantity: item.quantity,
unit: item.unit,
storageLocationId: override?.storageLocationId ?? fallback,
purchasedAt: todayIso(),
bestBeforeDate: override?.bestBeforeDate ?? null,
priceMinor: override?.priceMinor ?? item.estimatedPriceMinor,
source: "manual_search",
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: new Date(),
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId: list.householdId,
inventoryItemId: inv!.id,
type: "purchase",
quantityDelta: item.quantity,
unit: item.unit,
refType: "shopping",
refId: id,
actorUserId: req.userId,
valueMinor: override?.priceMinor ?? item.estimatedPriceMinor,
});
added += 1;
}
}
await app.db
.update(schema.shoppingLists)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.shoppingLists.id, id));
await emitEvent(app.db, {
type: "SHOPPING_COMPLETED",
payload: { shoppingListId: id, itemsAdded: added },
userId: req.userId,
householdId: list.householdId,
correlationId: req.correlationId,
});
return { ok: true, itemsAddedToInventory: added };
});
}
async function getOwnedList(app: FastifyInstance, listId: string, userId: string) {
const [list] = await app.db
.select()
.from(schema.shoppingLists)
.where(eq(schema.shoppingLists.id, listId))
.limit(1);
if (!list) throw errors.notFound("Listan finns inte.");
await requireMembership(app.db, list.householdId, userId);
return list;
}
/** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */
async function generateItemsFromPlan(
app: FastifyInstance,
listId: string,
weekPlanId: string,
householdId: string,
userId: string,
) {
const entries = await app.db
.select()
.from(schema.weekPlanEntries)
.where(
and(
eq(schema.weekPlanEntries.weekPlanId, weekPlanId),
eq(schema.weekPlanEntries.status, "planned"),
),
);
const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))];
if (recipeIds.length === 0) return;
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
const recipes = await app.db
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
.from(schema.recipes)
.where(inArray(schema.recipes.id, recipeIds));
const portionsMap = new Map(recipes.map((r) => [r.id, r.portions]));
// Aggregera behov per ingrediens (i gram där möjligt)
const needs = new Map<string, { name: string; grams: number }>();
const infoRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(
inArray(schema.canonicalIngredients.id, [
...new Set(allIngredients.map((i) => i.canonicalIngredientId)),
]),
);
const infoMap = new Map(infoRows.map((r) => [r.id, r]));
for (const entry of entries) {
if (!entry.recipeId) continue;
const basePortions = portionsMap.get(entry.recipeId) ?? 4;
const factor = entry.portions / basePortions;
for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) {
const info = infoMap.get(ing.canonicalIngredientId);
const grams = convert(ing.quantity * factor, ing.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams == null) continue;
const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 };
current.grams += grams;
needs.set(ing.canonicalIngredientId, current);
}
}
// Dra av lager
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]),
),
);
for (const item of stock) {
if (!item.canonicalIngredientId) continue;
const need = needs.get(item.canonicalIngredientId);
if (!need) continue;
const info = infoMap.get(item.canonicalIngredientId);
const grams = convert(item.quantity, item.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams != null) need.grams = Math.max(0, need.grams - grams);
}
// Skapa rader för det som saknas
let sortOrder = 0;
for (const [ingredientId, need] of needs) {
if (need.grams < 5) continue;
const info = infoMap.get(ingredientId);
const section = info
? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt")
: "hygien_ovrigt";
// Konvertera tillbaka till naturlig enhet
const targetUnit = info?.defaultUnit ?? "GRAM";
const qty =
convert(need.grams, "GRAM", targetUnit, {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
}) ?? need.grams;
const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10;
const estimatedPrice =
info?.defaultPriceMinorPerKg != null
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10
: null;
await app.db.insert(schema.shoppingListItems).values({
shoppingListId: listId,
canonicalIngredientId: ingredientId,
displayName: need.name,
quantity: rounded,
unit: targetUnit,
storeSection: section,
estimatedPriceMinor: estimatedPrice,
addedByUserId: userId,
origin: "plan",
sortOrder: sortOrder++,
});
}
}
+146
View File
@@ -0,0 +1,146 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import { verifyPurchaseInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
import { loadEntitlementsWithToken } from "../lib/entitlements.js";
/**
* Prenumerationer (spec §4547, Del 13).
* Backend verifierar ALLTID mot butiken och är source of truth (spec §61.14).
* Webhooks tas emot råa och processas asynkront av workern.
*/
export async function subscriptionRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/subscriptions/verify", auth, async (req) => {
const input = parse(verifyPurchaseInputSchema, req.body);
const result =
input.provider === "apple"
? await app.storeVerifier.verifyApple(input.signedTransaction)
: await app.storeVerifier.verifyGoogle(
input.packageName,
input.productId,
input.purchaseToken,
);
if (!result.ok) throw errors.badRequest(`Kunde inte verifiera köpet: ${result.error}`);
const purchase = result.purchase;
const householdId = await getActiveHouseholdId(app.db, req.userId);
// Idempotent på originalTransactionId
const [existing] = await app.db
.select()
.from(schema.subscriptions)
.where(eq(schema.subscriptions.originalTransactionId, purchase.originalTransactionId))
.limit(1);
let subscriptionId: string;
if (existing) {
if (existing.userId !== req.userId) {
throw errors.conflict(
"Det här köpet är kopplat till ett annat konto. Använd Återställ köp på rätt konto.",
);
}
const [updated] = await app.db
.update(schema.subscriptions)
.set({
status: purchase.status,
expiresAt: purchase.expiresAt,
plan: purchase.plan,
productId: purchase.productId,
lastVerifiedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.subscriptions.id, existing.id))
.returning();
subscriptionId = updated!.id;
} else {
const [created] = await app.db
.insert(schema.subscriptions)
.values({
userId: req.userId,
householdId,
provider: purchase.provider,
productId: purchase.productId,
plan: purchase.plan,
originalTransactionId: purchase.originalTransactionId,
status: purchase.status,
purchasedAt: purchase.purchasedAt,
expiresAt: purchase.expiresAt,
lastVerifiedAt: new Date(),
})
.returning();
subscriptionId = created!.id;
await emitEvent(app.db, {
type: "SUBSCRIPTION_STARTED",
payload: { subscriptionId, plan: purchase.plan, provider: purchase.provider },
userId: req.userId,
householdId: householdId ?? undefined,
correlationId: req.correlationId,
});
}
await app.db.insert(schema.subscriptionEvents).values({
subscriptionId,
userId: req.userId,
eventType: existing ? "verified" : "purchased",
payload: { productId: purchase.productId },
});
await audit(app.db, {
actorUserId: req.userId,
action: "subscription.verified",
targetType: "subscription",
targetId: subscriptionId,
});
return { ok: true, entitlements: await loadEntitlementsWithToken(app, req.userId) };
});
app.post("/v1/subscriptions/restore", auth, async (req) => {
// Restore = samma flöde som verify; klienten skickar aktuellt kvitto/token.
return {
ok: true,
message: "Skicka aktuellt kvitto till /v1/subscriptions/verify så återställs köpet.",
};
});
/**
* App Store Server Notifications V2 (spec §47).
* Signaturverifiering av JWS sker i workern (PROCESS_STORE_NOTIFICATION).
*/
app.post(
"/v1/subscriptions/webhooks/apple",
{ config: { rateLimit: false } },
async (req, reply) => {
const [row] = await app.db
.insert(schema.storeNotifications)
.values({ provider: "apple", rawPayload: (req.body ?? {}) as Record<string, unknown> })
.returning();
await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", {
jobType: "PROCESS_STORE_NOTIFICATION",
notificationId: row!.id,
});
return reply.status(200).send({ ok: true });
},
);
/** Google Play Real-time Developer Notifications (via Pub/Sub push). */
app.post(
"/v1/subscriptions/webhooks/google",
{ config: { rateLimit: false } },
async (req, reply) => {
const [row] = await app.db
.insert(schema.storeNotifications)
.values({ provider: "google", rawPayload: (req.body ?? {}) as Record<string, unknown> })
.returning();
await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", {
jobType: "PROCESS_STORE_NOTIFICATION",
notificationId: row!.id,
});
return reply.status(200).send({ ok: true });
},
);
}