Initial commit (unpacked platform)
This commit is contained in:
@@ -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 §57–58) ---
|
||||
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 §33–34) ---
|
||||
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 §13–14, 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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 §35–38).
|
||||
* 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.");
|
||||
});
|
||||
}
|
||||
@@ -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 <accessToken></code>.
|
||||
Fullständig referens: <code>docs/api-referens.md</code> i repot.</p>
|
||||
<table>${rows}</table></body></html>`;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 Mifflin–St 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.",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 §13–14): 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 1–24.");
|
||||
}
|
||||
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.1–2)
|
||||
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 2–4).
|
||||
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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import type { JobType, ScanType } from "@app/shared-types";
|
||||
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
|
||||
import { errors, parse } from "../lib/errors.js";
|
||||
import { emitEvent, requireActiveHousehold } from "../lib/helpers.js";
|
||||
import { consumeAiScan } from "../lib/entitlements.js";
|
||||
|
||||
/**
|
||||
* Skanningsflödet (spec §50):
|
||||
* App → POST /v1/scans (kvotkontroll + presignade upload-URL:er)
|
||||
* → PUT bild(er) till storage
|
||||
* → POST /v1/scans/:id/start (läggs på kö → worker → AAMOS)
|
||||
* → GET /v1/scans/:id (poll: status + resultat)
|
||||
* → POST /v1/scans/:id/confirm (användaren godkänner → lagret uppdateras)
|
||||
*
|
||||
* Användarbekräftelse är obligatorisk innan något skrivs till Food Twin
|
||||
* (spec §10, §61.5). Korrigeringar sparas som ai_corrections (spec §33).
|
||||
*/
|
||||
|
||||
const SCAN_TO_JOB: Record<ScanType, JobType> = {
|
||||
fridge: "ANALYZE_FRIDGE_IMAGE",
|
||||
freezer: "ANALYZE_FRIDGE_IMAGE",
|
||||
pantry: "ANALYZE_PANTRY_IMAGE",
|
||||
ingredients: "ANALYZE_PANTRY_IMAGE",
|
||||
plate: "ANALYZE_MEAL_IMAGE",
|
||||
receipt: "READ_RECEIPT",
|
||||
barcode: "NORMALIZE_PRODUCTS",
|
||||
expiry_date: "READ_EXPIRY_DATE",
|
||||
nutrition_label: "READ_NUTRITION_LABEL",
|
||||
product_package: "READ_NUTRITION_LABEL",
|
||||
};
|
||||
|
||||
const S3_PREFIX: Partial<Record<ScanType, string>> = {
|
||||
fridge: "fridge-scans",
|
||||
freezer: "fridge-scans",
|
||||
pantry: "pantry-scans",
|
||||
ingredients: "pantry-scans",
|
||||
plate: "meal-scans",
|
||||
receipt: "receipts",
|
||||
expiry_date: "product-images",
|
||||
nutrition_label: "product-images",
|
||||
product_package: "product-images",
|
||||
};
|
||||
|
||||
export async function scanRoutes(app: FastifyInstance) {
|
||||
const auth = { preHandler: [app.authenticate] };
|
||||
|
||||
app.post("/v1/scans", auth, async (req, reply) => {
|
||||
const input = parse(createScanInputSchema, req.body);
|
||||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||||
|
||||
// Streckkod är gratis uppslag utan AI – hanteras direkt (spec §11: lokalt + databas).
|
||||
if (input.scanType === "barcode") {
|
||||
if (!input.barcode) throw errors.badRequest("barcode krävs för streckkodsskanning.");
|
||||
const product = await lookupBarcode(app, input.barcode);
|
||||
const [job] = await app.db
|
||||
.insert(schema.scanJobs)
|
||||
.values({
|
||||
userId: req.userId,
|
||||
householdId,
|
||||
scanType: "barcode",
|
||||
jobType: "NORMALIZE_PRODUCTS",
|
||||
status: product ? "completed" : "failed",
|
||||
result: product ? { product } : null,
|
||||
error: product
|
||||
? null
|
||||
: "Produkten hittades inte. Fota framsida + näringsdeklaration så lägger vi till den.",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
return reply.status(201).send({ scan: job, product });
|
||||
}
|
||||
|
||||
// AI-skanning: kvotkontroll (fair use, spec §45–46) och presignade URL:er.
|
||||
await consumeAiScan(app.db, req.userId);
|
||||
|
||||
const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`;
|
||||
const uploads = [];
|
||||
for (let i = 0; i < Math.max(1, input.imageCount); i++) {
|
||||
uploads.push(await app.storage.presignUpload(prefix, input.contentType));
|
||||
}
|
||||
|
||||
const [job] = await app.db
|
||||
.insert(schema.scanJobs)
|
||||
.values({
|
||||
userId: req.userId,
|
||||
householdId,
|
||||
scanType: input.scanType,
|
||||
jobType: SCAN_TO_JOB[input.scanType],
|
||||
status: "queued",
|
||||
s3Keys: uploads.map((u) => u.key),
|
||||
context: input.context ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return reply.status(201).send({ scan: job, uploads });
|
||||
});
|
||||
|
||||
app.post("/v1/scans/:id/start", auth, async (req) => {
|
||||
const { id } = parse(idParamSchema, req.params);
|
||||
const job = await getOwnedScan(app, id, req.userId);
|
||||
if (job.status !== "queued") throw errors.conflict(`Jobbet är redan ${job.status}.`);
|
||||
|
||||
await app.jobQueue.add(job.jobType, {
|
||||
scanJobId: job.id,
|
||||
jobType: job.jobType,
|
||||
correlationId: req.correlationId,
|
||||
});
|
||||
return { ok: true, status: "queued" };
|
||||
});
|
||||
|
||||
app.get("/v1/scans/:id", auth, async (req) => {
|
||||
const { id } = parse(idParamSchema, req.params);
|
||||
return getOwnedScan(app, id, req.userId);
|
||||
});
|
||||
|
||||
app.post("/v1/scans/:id/confirm", auth, async (req) => {
|
||||
const { id } = parse(idParamSchema, req.params);
|
||||
const input = parse(confirmScanInputSchema, req.body);
|
||||
const job = await getOwnedScan(app, id, req.userId);
|
||||
if (job.status !== "awaiting_confirmation" && job.status !== "completed") {
|
||||
throw errors.conflict("Jobbet har inget resultat att bekräfta ännu.");
|
||||
}
|
||||
const householdId = job.householdId ?? (await requireActiveHousehold(app.db, req.userId));
|
||||
|
||||
const fallbackLocation =
|
||||
input.storageLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||||
|
||||
const created: string[] = [];
|
||||
for (const item of input.items) {
|
||||
if (item.action === "reject") {
|
||||
await recordCorrection(app, job, item.tempId ?? null, { action: "reject" });
|
||||
continue;
|
||||
}
|
||||
if (item.action === "edit" || item.action === "add") {
|
||||
await recordCorrection(app, job, item.tempId ?? null, {
|
||||
action: item.action,
|
||||
corrected: { name: item.displayName, quantity: item.quantity, unit: item.unit },
|
||||
});
|
||||
}
|
||||
const locationId = item.storageLocationId ?? fallbackLocation;
|
||||
if (!locationId)
|
||||
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
|
||||
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
.values({
|
||||
householdId,
|
||||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||||
displayName: item.displayName,
|
||||
brand: item.brand ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
storageLocationId: locationId,
|
||||
sublocation: item.sublocation ?? null,
|
||||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||||
useByDate: item.useByDate ?? null,
|
||||
priceMinor: item.priceMinor ?? null,
|
||||
purchasedAt: new Date().toISOString().slice(0, 10),
|
||||
source: scanSource(job.scanType),
|
||||
confidence: item.action === "accept" ? 0.9 : 1,
|
||||
verifiedByUser: true,
|
||||
lastVerifiedAt: new Date(),
|
||||
modelVersion: job.modelVersion,
|
||||
promptVersion: job.promptVersion,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await app.db.insert(schema.inventoryTransactions).values({
|
||||
householdId,
|
||||
inventoryItemId: inv!.id,
|
||||
type: "purchase",
|
||||
quantityDelta: item.quantity,
|
||||
unit: item.unit,
|
||||
refType: "scan",
|
||||
refId: job.id,
|
||||
actorUserId: req.userId,
|
||||
valueMinor: item.priceMinor ?? null,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "PRODUCT_ADDED",
|
||||
payload: {
|
||||
inventoryItemId: inv!.id,
|
||||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
source: scanSource(job.scanType),
|
||||
},
|
||||
userId: req.userId,
|
||||
householdId,
|
||||
correlationId: req.correlationId,
|
||||
});
|
||||
created.push(inv!.id);
|
||||
}
|
||||
|
||||
await app.db
|
||||
.update(schema.scanJobs)
|
||||
.set({ status: "completed", updatedAt: new Date() })
|
||||
.where(eq(schema.scanJobs.id, id));
|
||||
|
||||
return { ok: true, createdItemIds: created };
|
||||
});
|
||||
|
||||
app.get("/v1/scans", auth, async (req) => {
|
||||
const jobs = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.userId, req.userId))
|
||||
.orderBy((await import("drizzle-orm")).desc(schema.scanJobs.createdAt))
|
||||
.limit(30);
|
||||
return { scans: jobs };
|
||||
});
|
||||
}
|
||||
|
||||
async function getOwnedScan(app: FastifyInstance, id: string, userId: string) {
|
||||
const [job] = await app.db
|
||||
.select()
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, id))
|
||||
.limit(1);
|
||||
if (!job || job.userId !== userId) throw errors.notFound("Skanningen finns inte.");
|
||||
return job;
|
||||
}
|
||||
|
||||
async function lookupBarcode(app: FastifyInstance, gtin: string) {
|
||||
// 1. Egen produktdatabas (aktuell version)
|
||||
const [own] = await app.db
|
||||
.select()
|
||||
.from(schema.products)
|
||||
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
|
||||
.limit(1);
|
||||
if (own) return own;
|
||||
|
||||
// 2. Open Food Facts (laglig öppen källa, spec §11)
|
||||
const off = app.connectors.get("open-food-facts");
|
||||
if (off && "lookupBarcode" in off) {
|
||||
try {
|
||||
const result = await (off as { lookupBarcode(g: string): Promise<unknown> }).lookupBarcode(
|
||||
gtin,
|
||||
);
|
||||
if (result && typeof result === "object") {
|
||||
const p = result as {
|
||||
gtin: string;
|
||||
name?: string;
|
||||
brand?: string;
|
||||
ingredientsText?: string;
|
||||
nutrimentsPer100g: Record<string, number | undefined>;
|
||||
imageUrl?: string;
|
||||
};
|
||||
if (!p.name) return null;
|
||||
const n = p.nutrimentsPer100g;
|
||||
const [saved] = await app.db
|
||||
.insert(schema.products)
|
||||
.values({
|
||||
gtin: p.gtin,
|
||||
name: p.name,
|
||||
brand: p.brand ?? null,
|
||||
ingredientsText: p.ingredientsText ?? null,
|
||||
nutrition:
|
||||
n.kcal != null
|
||||
? {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: n.kcal ?? 0,
|
||||
proteinG: n.proteinG ?? 0,
|
||||
carbsG: n.carbsG ?? 0,
|
||||
fatG: n.fatG ?? 0,
|
||||
saturatedFatG: n.saturatedFatG ?? 0,
|
||||
fiberG: n.fiberG ?? 0,
|
||||
sugarG: n.sugarG ?? 0,
|
||||
saltG: n.saltG ?? 0,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
imageUrls: p.imageUrl ? [p.imageUrl] : [],
|
||||
dataSource: "open_food_facts",
|
||||
verificationStatus: "unverified",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return saved ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err, gtin }, "OFF-uppslag misslyckades");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanSource(scanType: ScanType) {
|
||||
switch (scanType) {
|
||||
case "fridge":
|
||||
return "fridge_photo" as const;
|
||||
case "freezer":
|
||||
return "freezer_photo" as const;
|
||||
case "pantry":
|
||||
return "pantry_photo" as const;
|
||||
case "ingredients":
|
||||
return "ingredient_photo" as const;
|
||||
case "receipt":
|
||||
return "receipt" as const;
|
||||
case "barcode":
|
||||
return "barcode" as const;
|
||||
default:
|
||||
return "label_photo" as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: ScanType) {
|
||||
const wanted =
|
||||
scanType === "freezer"
|
||||
? "freezer"
|
||||
: scanType === "pantry" || scanType === "ingredients"
|
||||
? "pantry"
|
||||
: "fridge";
|
||||
const [loc] = await app.db
|
||||
.select({ id: schema.storageLocations.id })
|
||||
.from(schema.storageLocations)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.storageLocations.householdId, householdId),
|
||||
eq(schema.storageLocations.type, wanted),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return loc?.id ?? null;
|
||||
}
|
||||
|
||||
async function recordCorrection(
|
||||
app: FastifyInstance,
|
||||
job: {
|
||||
id: string;
|
||||
userId: string;
|
||||
jobType: string;
|
||||
result: unknown;
|
||||
modelVersion: string | null;
|
||||
promptVersion: string | null;
|
||||
},
|
||||
tempId: string | null,
|
||||
correction: Record<string, unknown>,
|
||||
) {
|
||||
const consents = await app.db
|
||||
.select()
|
||||
.from(schema.userConsents)
|
||||
.where(eq(schema.userConsents.userId, job.userId));
|
||||
const snapshot = Object.fromEntries(consents.map((c) => [c.kind, c.status]));
|
||||
await app.db.insert(schema.aiCorrections).values({
|
||||
scanJobId: job.id,
|
||||
userId: job.userId,
|
||||
taskType: job.jobType,
|
||||
aiOutput: { tempId, raw: job.result },
|
||||
userCorrection: correction,
|
||||
modelVersion: job.modelVersion,
|
||||
promptVersion: job.promptVersion,
|
||||
consentSnapshot: snapshot,
|
||||
});
|
||||
await emitEvent(app.db, {
|
||||
type: "AI_CORRECTED",
|
||||
payload: {
|
||||
scanJobId: job.id,
|
||||
taskType: job.jobType,
|
||||
field: String(correction.action ?? "unknown"),
|
||||
},
|
||||
userId: job.userId,
|
||||
});
|
||||
}
|
||||
@@ -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++,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 §45–47, 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user