Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@app/api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Food API enda ingången för mobilappen (spec §31, §50)",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@app/ai-contracts": "workspace:*",
"@app/connectors": "workspace:*",
"@app/database": "workspace:*",
"@app/events": "workspace:*",
"@app/feature-flags": "workspace:*",
"@app/inventory-engine": "workspace:*",
"@app/memory-client": "workspace:*",
"@app/nutrition-engine": "workspace:*",
"@app/recipe-engine": "workspace:*",
"@app/recommendation-engine": "workspace:*",
"@app/shared-types": "workspace:*",
"@app/subscriptions": "workspace:*",
"@app/validation": "workspace:*",
"@aws-sdk/client-s3": "^3.1102.0",
"@aws-sdk/s3-request-presigner": "^3.1102.0",
"@fastify/cors": "^11.0.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.0.0",
"bullmq": "^6.0.0",
"dotenv": "^16.4.0",
"drizzle-orm": "^0.45.0",
"fastify": "^5.6.0",
"fastify-plugin": "^5.0.0",
"ioredis": "^6.0.0",
"nodemailer": "^9.0.3",
"pg": "^8.13.0",
"zod": "^4.4.0"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.11.0",
"tsup": "^8.3.0"
},
"tsup": {
"noExternal": [
"@app/ai-contracts",
"@app/connectors",
"@app/database",
"@app/events",
"@app/feature-flags",
"@app/inventory-engine",
"@app/memory-client",
"@app/nutrition-engine",
"@app/recipe-engine",
"@app/recommendation-engine",
"@app/shared-types",
"@app/subscriptions",
"@app/validation"
]
}
}
+94
View File
@@ -0,0 +1,94 @@
import { config as loadDotenv } from "dotenv";
import { existsSync } from "node:fs";
import path from "node:path";
// Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet).
for (const candidate of [".env", "../.env", "../../.env"]) {
const p = path.resolve(process.cwd(), candidate);
if (existsSync(p)) {
loadDotenv({ path: p });
break;
}
}
import { z } from "zod";
import { BRAND } from "@app/shared-types";
/** All konfiguration valideras vid start tydliga fel i stället för mystiska krascher. */
const configSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
API_PORT: z.coerce.number().int().default(4000),
API_BASE_URL: z.string().default("http://localhost:4000"),
CORS_ORIGINS: z.string().default("http://localhost:5173"),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
DATABASE_URL: z.string().default("postgres://app_user:app_dev_password@localhost:5432/app"),
REDIS_URL: z.string().default("redis://localhost:6379"),
JWT_ACCESS_SECRET: z.string().min(10).default("dev-only-change-me"),
JWT_REFRESH_SECRET: z.string().min(10).default("dev-only-change-me-too"),
JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().default(900),
JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().default(2_592_000),
ENTITLEMENT_SIGNING_SECRET: z.string().min(10).default("dev-only-change-me-three"),
ENTITLEMENT_TTL_HOURS: z.coerce.number().int().default(24),
ENTITLEMENT_GRACE_DAYS: z.coerce.number().int().default(7),
S3_MODE: z.enum(["mock", "aws"]).default("mock"),
S3_BUCKET: z.string().default(""),
S3_REGION: z.string().default("eu-north-1"),
/** Tom = AWS S3; sätt för S3-kompatibel lagring (MinIO/R2/Hetzner). */
S3_ENDPOINT: z.string().default(""),
/** Tomma = IAM-roll/instansprofil används (rekommenderat i AWS). */
S3_ACCESS_KEY_ID: z.string().default(""),
S3_SECRET_ACCESS_KEY: z.string().default(""),
AAMOS_MODE: z.enum(["http", "mock"]).default("http"),
AAMOS_API_URL: z.string().optional(),
AAMOS_API_KEY: z.string().optional(),
AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000),
APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"),
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
SMTP_HOST: z.string().default(""),
SMTP_PORT: z.coerce.number().int().default(587),
SMTP_SECURE: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
SMTP_USER: z.string().default(""),
SMTP_PASS: z.string().default(""),
MAIL_FROM: z.string().default(""),
APPLE_BUNDLE_ID: z.string().default(""),
GOOGLE_PACKAGE_NAME: z.string().default(""),
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = configSchema.safeParse(env);
if (!parsed.success) {
console.error("Ogiltig konfiguration:", parsed.error.flatten().fieldErrors);
process.exit(1);
}
const cfg = parsed.data;
// Varumärkesberoende defaults ur brand.config.json (i18n-spec §28)
if (!cfg.S3_BUCKET) cfg.S3_BUCKET = `${BRAND.slug}-production`;
if (!cfg.APPLE_BUNDLE_ID) cfg.APPLE_BUNDLE_ID = BRAND.iosBundleId;
if (!cfg.GOOGLE_PACKAGE_NAME) cfg.GOOGLE_PACKAGE_NAME = BRAND.androidPackage;
if (cfg.NODE_ENV === "production") {
const insecure = [
cfg.JWT_ACCESS_SECRET,
cfg.JWT_REFRESH_SECRET,
cfg.ENTITLEMENT_SIGNING_SECRET,
].some((s) => s.startsWith("dev-only"));
if (insecure) {
console.error("SÄKERHETSSTOPP: dev-hemligheter i produktion (spec §56).");
process.exit(1);
}
if (cfg.AAMOS_MODE === "mock") {
console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion.");
process.exit(1);
}
}
return cfg;
}
+21
View File
@@ -0,0 +1,21 @@
import { loadConfig } from "./config.js";
import { buildServer } from "./server.js";
const config = loadConfig();
const app = await buildServer(config);
const shutdown = async (signal: string) => {
app.log.info(`${signal} mottagen stänger ner API:t.`);
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
try {
await app.listen({ port: config.API_PORT, host: "0.0.0.0" });
app.log.info(`API igång på :${config.API_PORT} (${config.NODE_ENV}, AAMOS=${config.AAMOS_MODE})`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
+105
View File
@@ -0,0 +1,105 @@
import { and, eq, inArray } from "drizzle-orm";
import { schema, type Database } from "@app/database";
import { loadLocalePreferences } from "./localeContext.js";
/**
* Innehållsspråk (i18n-spec §1114): svensk källtext är sanningen, publicerade
* översättningar är vyer. Upplösning: exakt tagg ("en-US") → primärt språk
* ("en") → svensk källa. Endast status=published serveras till användare.
*/
/** "en-US" -> ["en-US", "en"], "sv" -> ["sv"]. */
export function languageCandidates(languageTag: string): string[] {
const primary = languageTag.split("-")[0] ?? languageTag;
return primary === languageTag ? [languageTag] : [languageTag, primary];
}
export async function userLanguageTag(db: Database, userId: string): Promise<string> {
const prefs = await loadLocalePreferences(db, userId);
return prefs.languageTag;
}
export interface ResolvedRecipeText {
language: string;
title: string;
description: string | null;
storageGuidance: string | null;
/** stepNumber -> { instruction, tip } */
steps: Map<number, { instruction: string; tip: string | null }>;
}
/**
* Hämtar publicerad receptöversättning för användarens språk, eller null om
* svensk källa ska användas. En DB-runda för texterna + en för stegen.
*/
export async function resolveRecipeTranslation(
db: Database,
recipeId: string,
languageTag: string,
): Promise<ResolvedRecipeText | null> {
const candidates = languageCandidates(languageTag);
if (candidates.includes("sv")) return null;
const rows = await db
.select()
.from(schema.recipeTranslations)
.where(
and(
eq(schema.recipeTranslations.recipeId, recipeId),
inArray(schema.recipeTranslations.languageTag, candidates),
eq(schema.recipeTranslations.status, "published"),
),
);
if (rows.length === 0) return null;
const best =
candidates.map((c) => rows.find((r) => r.languageTag === c)).find(Boolean) ?? rows[0]!;
const stepRows = await db
.select()
.from(schema.recipeStepTranslations)
.where(
and(
eq(schema.recipeStepTranslations.recipeId, recipeId),
eq(schema.recipeStepTranslations.languageTag, best.languageTag),
),
);
return {
language: best.languageTag,
title: best.title,
description: best.description,
storageGuidance: best.storageGuidance,
steps: new Map(stepRows.map((s) => [s.stepNumber, { instruction: s.instruction, tip: s.tip }])),
};
}
/**
* Ingrediensnamn för användarens språk: Map ingredientId -> namn.
* Saknade id:n behåller svenskt namn (fallback sker hos anroparen).
*/
export async function resolveIngredientNames(
db: Database,
ingredientIds: string[],
languageTag: string,
): Promise<Map<string, string>> {
const candidates = languageCandidates(languageTag);
if (candidates.includes("sv") || ingredientIds.length === 0) return new Map();
const rows = await db
.select({
ingredientId: schema.ingredientTranslations.ingredientId,
languageTag: schema.ingredientTranslations.languageTag,
name: schema.ingredientTranslations.name,
})
.from(schema.ingredientTranslations)
.where(
and(
inArray(schema.ingredientTranslations.ingredientId, ingredientIds),
inArray(schema.ingredientTranslations.languageTag, candidates),
eq(schema.ingredientTranslations.status, "published"),
),
);
const out = new Map<string, string>();
for (const candidate of [...candidates].reverse()) {
for (const r of rows) if (r.languageTag === candidate) out.set(r.ingredientId, r.name);
}
return out;
}
+101
View File
@@ -0,0 +1,101 @@
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { schema, type Database } from "@app/database";
import type { Entitlements } from "@app/shared-types";
import {
computeEntitlements,
signEntitlementToken,
type SubscriptionSnapshot,
type TrialSnapshot,
} from "@app/subscriptions";
import { currentMonth } from "./helpers.js";
import { errors } from "./errors.js";
/** Ladda och beräkna entitlements för en användare. Backend = source of truth (spec §61.14). */
export async function loadEntitlements(db: Database, userId: string): Promise<Entitlements> {
const [subRow] = await db
.select()
.from(schema.subscriptions)
.where(eq(schema.subscriptions.userId, userId))
.orderBy(desc(schema.subscriptions.updatedAt))
.limit(1);
const [trialRow] = await db
.select()
.from(schema.trials)
.where(eq(schema.trials.userId, userId))
.limit(1);
const [usageRow] = await db
.select()
.from(schema.aiUsageCounters)
.where(
and(
eq(schema.aiUsageCounters.userId, userId),
eq(schema.aiUsageCounters.month, currentMonth()),
),
)
.limit(1);
const subscription: SubscriptionSnapshot | null = subRow
? {
plan: subRow.plan,
status: subRow.status,
expiresAt: subRow.expiresAt,
gracePeriodExpiresAt: subRow.gracePeriodExpiresAt,
}
: null;
const trial: TrialSnapshot | null = trialRow
? { startedAt: trialRow.startedAt, endsAt: trialRow.endsAt }
: null;
return computeEntitlements(subscription, trial, {
aiScansUsedThisMonth: usageRow?.aiScans ?? 0,
});
}
/** Entitlements + signerad offline-token (spec §44). */
export async function loadEntitlementsWithToken(
app: FastifyInstance,
userId: string,
): Promise<Entitlements> {
const ent = await loadEntitlements(app.db, userId);
ent.signedToken = signEntitlementToken(userId, ent, app.config.ENTITLEMENT_SIGNING_SECRET, {
ttlHours: app.config.ENTITLEMENT_TTL_HOURS,
graceDays: app.config.ENTITLEMENT_GRACE_DAYS,
});
return ent;
}
/** Kräv AI-kvot och räkna upp användningen atomiskt. */
export async function consumeAiScan(db: Database, userId: string): Promise<void> {
const ent = await loadEntitlements(db, userId);
if (ent.aiScansUsedThisMonth >= ent.aiScansPerMonth) {
throw errors.quotaExceeded(
`Månadens AI-skanningar är slut (${ent.aiScansPerMonth} st). Uppgradera för fler.`,
);
}
const month = currentMonth();
await db
.insert(schema.aiUsageCounters)
.values({ userId, month, aiScans: 1 })
.onConflictDoUpdate({
target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month],
set: { aiScans: (await import("drizzle-orm")).sql`${schema.aiUsageCounters.aiScans} + 1` },
});
}
/** Kräv en premiumfunktion (t.ex. veckoplanering). */
export async function requireFeature(
db: Database,
userId: string,
feature: "weekPlanning" | "advancedNutrition" | "communityPublish",
labelSv: string,
): Promise<void> {
const ent = await loadEntitlements(db, userId);
if (!ent[feature]) {
throw errors.paymentRequired(
`${labelSv} ingår i Premium. Starta din provperiod eller uppgradera.`,
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { ZodType } from "zod";
/** Applikationsfel med HTTP-status och maskinläsbar kod. */
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
export const errors = {
badRequest: (msg: string, details?: unknown) => new ApiError(400, "BAD_REQUEST", msg, details),
unauthorized: (msg = "Ogiltig eller saknad autentisering") =>
new ApiError(401, "UNAUTHORIZED", msg),
forbidden: (msg = "Åtkomst nekad") => new ApiError(403, "FORBIDDEN", msg),
notFound: (msg = "Resursen finns inte") => new ApiError(404, "NOT_FOUND", msg),
conflict: (msg: string) => new ApiError(409, "CONFLICT", msg),
quotaExceeded: (msg: string) => new ApiError(429, "QUOTA_EXCEEDED", msg),
paymentRequired: (msg: string) => new ApiError(402, "PREMIUM_REQUIRED", msg),
internal: (msg = "Internt fel") => new ApiError(500, "INTERNAL", msg),
};
/**
* Validera indata mot ett Zod-schema. Kastar 400 med detaljer vid fel.
* Medvetet vald i stället för type-provider: färre rörliga delar i
* major-versionsgränser, samma säkerhet (se docs/beslutslogg.md D-011).
*/
export function parse<T>(schema: ZodType<T>, data: unknown, what = "indata"): T {
const result = schema.safeParse(data);
if (!result.success) {
throw new ApiError(400, "VALIDATION_ERROR", `Ogiltig ${what}`, result.error.issues);
}
return result.data;
}
+112
View File
@@ -0,0 +1,112 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { Database } from "@app/database";
import { schema } from "@app/database";
import type { EventType } from "@app/shared-types";
import type { NewDomainEvent } from "@app/events";
import { errors } from "./errors.js";
/** Slumpad, läsbar inbjudningskod utan lättförväxlade tecken. */
export function generateInviteCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = randomBytes(8);
let code = "";
for (const b of bytes) code += alphabet[b % alphabet.length];
return code;
}
export function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function currentMonth(): string {
return new Date().toISOString().slice(0, 7);
}
/** Verifiera hushållsmedlemskap enda vägen till hushållsdata (spec §56). */
export async function requireMembership(
db: Database,
householdId: string,
userId: string,
): Promise<{ role: string; portionFactor: number }> {
const [member] = await db
.select()
.from(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, householdId),
eq(schema.householdMembers.userId, userId),
),
)
.limit(1);
if (!member) throw errors.forbidden("Du är inte medlem i det här hushållet.");
return { role: member.role, portionFactor: member.portionFactor };
}
/** Hämta användarens aktiva hushåll (första medlemskapet) eller null. */
export async function getActiveHouseholdId(db: Database, userId: string): Promise<string | null> {
const [member] = await db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, userId))
.orderBy(schema.householdMembers.joinedAt)
.limit(1);
return member?.householdId ?? null;
}
/** Som ovan men kastar 404 om användaren saknar hushåll. */
export async function requireActiveHousehold(db: Database, userId: string): Promise<string> {
const id = await getActiveHouseholdId(db, userId);
if (!id) {
throw errors.notFound("Du har inget hushåll ännu. Skapa ett under Hemma → Hushåll.");
}
return id;
}
/** Skriv domänhändelse till outboxen (spec §55) i samma transaktion när tx skickas in. */
export async function emitEvent<T extends EventType>(
db: Database,
event: NewDomainEvent<T>,
): Promise<void> {
await db.insert(schema.domainEvents).values({
type: event.type,
userId: event.userId ?? null,
householdId: event.householdId ?? null,
payload: event.payload as Record<string, unknown>,
correlationId: event.correlationId ?? null,
});
}
/** Audit-logg (spec §56). */
export async function audit(
db: Database,
entry: {
actorUserId?: string | undefined;
actorType?: "user" | "admin" | "system" | "worker";
action: string;
targetType?: string;
targetId?: string;
metadata?: Record<string, unknown>;
ip?: string | undefined;
correlationId?: string | undefined;
},
): Promise<void> {
await db.insert(schema.auditLogs).values({
actorUserId: entry.actorUserId ?? null,
actorType: entry.actorType ?? "user",
action: entry.action,
targetType: entry.targetType ?? null,
targetId: entry.targetId ?? null,
metadata: entry.metadata ?? null,
ip: entry.ip ?? null,
correlationId: entry.correlationId ?? null,
});
}
export function newCorrelationId(): string {
return randomUUID();
}
+36
View File
@@ -0,0 +1,36 @@
import { eq } from "drizzle-orm";
import { schema, type Database } from "@app/database";
import {
DEFAULT_LOCALE_PREFERENCES,
toLocaleContext,
type LocaleContext,
type UserLocalePreferences,
} from "@app/shared-types";
/** Läs användarens locale-preferenser med SE-defaults som fallback (i18n-spec §6). */
export async function loadLocalePreferences(
db: Database,
userId: string,
): Promise<UserLocalePreferences> {
const [row] = await db
.select()
.from(schema.userLocalePreferences)
.where(eq(schema.userLocalePreferences.userId, userId))
.limit(1);
if (!row) return { ...DEFAULT_LOCALE_PREFERENCES };
return {
languageTag: row.languageTag,
regionCode: row.regionCode,
timeZone: row.timeZone,
measurementSystem: row.measurementSystem,
temperatureUnit: row.temperatureUnit,
currencyCode: row.currencyCode,
firstDayOfWeek: row.firstDayOfWeek,
use24HourTime: row.use24HourTime,
};
}
/** LocaleContext till varje AAMOS-anrop (i18n-spec §22). */
export async function getLocaleContext(db: Database, userId: string): Promise<LocaleContext> {
return toLocaleContext(await loadLocalePreferences(db, userId));
}
+310
View File
@@ -0,0 +1,310 @@
import nodemailer, { type Transporter } from "nodemailer";
import { BRAND } from "@app/shared-types";
/**
* E-postutskick med utbytbar transport.
*
* - EMAIL_MODE=log (dev/test): skriver mejlet till loggen i stället för att skicka.
* - EMAIL_MODE=smtp (produktion): kopplas till vald leverantör (SES/Postmark/Resend)
* i lanseringsfasen transporten är en funktion, leverantörsbytet är en fil.
*
* Innehållet byggs som mallnyckel + variabler per språk (i18n-spec §26)
* aldrig inbakad text i anropskoden.
*/
export interface MailMessage {
to: string;
subject: string;
text: string;
}
export interface Mailer {
send(message: MailMessage): Promise<void>;
}
class LogMailer implements Mailer {
async send(message: MailMessage): Promise<void> {
console.log(
`[mailer:log] till=${message.to} ämne="${message.subject}"\n${message.text
.split("\n")
.map((l) => ` | ${l}`)
.join("\n")}`,
);
}
}
export interface SmtpSettings {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
/**
* Riktig SMTP via nodemailer. Leverantörsoberoende: fungerar med SES,
* Postmark, Resend, Brevo, egen mailserver allt som talar standard-SMTP.
* Verifierad med riktig SMTP-rundtur (HELO → MAIL FROM → DATA) mot lokal server.
*/
class SmtpMailer implements Mailer {
private readonly transporter: Transporter;
private readonly from: string;
constructor(settings: SmtpSettings) {
if (!settings.host) {
// Högljutt fel i stället för tyst tappade mejl.
throw new Error("EMAIL_MODE=smtp kräver SMTP_HOST (samt SMTP_PORT/USER/PASS/MAIL_FROM).");
}
if (!settings.from) {
throw new Error("EMAIL_MODE=smtp kräver MAIL_FROM (avsändaradress).");
}
this.from = settings.from;
this.transporter = nodemailer.createTransport({
host: settings.host,
port: settings.port,
secure: settings.secure, // true = SMTPS (465); false = STARTTLS/klartext (587/25)
...(settings.user ? { auth: { user: settings.user, pass: settings.pass } } : {}),
});
}
/** Verifiera anslutning + inloggning vid uppstart. */
async verify(): Promise<void> {
await this.transporter.verify();
}
async send(message: MailMessage): Promise<void> {
await this.transporter.sendMail({
from: this.from,
to: message.to,
subject: message.subject,
text: message.text,
});
}
}
export function createMailer(mode: string, smtp?: SmtpSettings): Mailer {
if (mode === "smtp") {
if (!smtp) throw new Error("EMAIL_MODE=smtp kräver SMTP-inställningar.");
return new SmtpMailer(smtp);
}
return new LogMailer();
}
// ---------------------------------------------------------------------------
// Mallar (nyckel + variabler, per språk samma princip som notismallarna)
// ---------------------------------------------------------------------------
type MailTemplate = (vars: Record<string, string>) => { subject: string; text: string };
const TEMPLATES: Record<string, Record<string, MailTemplate>> = {
sv: {
"auth.verify_email": (v) => ({
subject: `Bekräfta din e-postadress ${BRAND.name}`,
text:
`Välkommen till ${BRAND.name}!\n\n` +
`Bekräfta att ${v.email} är din adress genom att öppna länken i appen:\n${v.link}\n\n` +
`Länken gäller i 24 timmar. Appen fungerar även utan bekräftelse, men vissa ` +
`funktioner (t.ex. lösenordsåterställning) blir säkrare med verifierad adress.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Återställ ditt lösenord ${BRAND.name}`,
text:
`Hej!\n\nDu (eller någon annan) har begärt att återställa lösenordet för ${v.email}.\n\n` +
`Öppna länken i appen inom 30 minuter:\n${v.link}\n\n` +
`Om du inte begärde detta kan du ignorera mejlet lösenordet ändras inte.\n\n${BRAND.name}`,
}),
},
en: {
"auth.verify_email": (v) => ({
subject: `Confirm your email ${BRAND.name}`,
text:
`Welcome to ${BRAND.name}!\n\n` +
`Confirm that ${v.email} is your address by opening this link in the app:\n${v.link}\n\n` +
`The link is valid for 24 hours. The app works without confirmation, but some ` +
`features (like password reset) are safer with a verified address.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Reset your password ${BRAND.name}`,
text:
`Hi!\n\nA password reset was requested for ${v.email}.\n\n` +
`Open this link in the app within 30 minutes:\n${v.link}\n\n` +
`If you didn't request this, you can ignore this email your password stays unchanged.\n\n${BRAND.name}`,
}),
},
es: {
"auth.verify_email": (v) => ({
subject: `Confirma tu correo ${BRAND.name}`,
text:
`¡Bienvenido a ${BRAND.name}!\n\n` +
`Confirma que ${v.email} es tu dirección abriendo este enlace en la app:\n${v.link}\n\n` +
`El enlace es válido durante 24 horas. La app funciona sin confirmación, pero algunas ` +
`funciones (como restablecer la contraseña) son más seguras con la dirección verificada.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Restablece tu contraseña ${BRAND.name}`,
text:
`¡Hola!\n\nSe ha solicitado restablecer la contraseña de ${v.email}.\n\n` +
`Abre este enlace en la app en un plazo de 30 minutos:\n${v.link}\n\n` +
`Si no lo solicitaste, ignora este correo: tu contraseña no cambiará.\n\n${BRAND.name}`,
}),
},
it: {
"auth.verify_email": (v) => ({
subject: `Conferma la tua email ${BRAND.name}`,
text:
`Benvenuto su ${BRAND.name}!\n\n` +
`Conferma che ${v.email} è il tuo indirizzo aprendo questo link nell'app:\n${v.link}\n\n` +
`Il link è valido per 24 ore. L'app funziona anche senza conferma, ma alcune ` +
`funzioni (come il ripristino della password) sono più sicure con l'indirizzo verificato.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Reimposta la tua password ${BRAND.name}`,
text:
`Ciao!\n\nÈ stato richiesto il ripristino della password per ${v.email}.\n\n` +
`Apri questo link nell'app entro 30 minuti:\n${v.link}\n\n` +
`Se non l'hai richiesto, ignora questa email: la password non cambierà.\n\n${BRAND.name}`,
}),
},
de: {
"auth.verify_email": (v) => ({
subject: `Bestätige deine E-Mail-Adresse ${BRAND.name}`,
text:
`Willkommen bei ${BRAND.name}!\n\n` +
`Bestätige, dass ${v.email} deine Adresse ist, indem du diesen Link in der App öffnest:\n${v.link}\n\n` +
`Der Link ist 24 Stunden gültig. Die App funktioniert auch ohne Bestätigung, aber manche ` +
`Funktionen (z. B. Passwort-Zurücksetzen) sind mit verifizierter Adresse sicherer.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Setze dein Passwort zurück ${BRAND.name}`,
text:
`Hallo!\n\nFür ${v.email} wurde ein Passwort-Reset angefordert.\n\n` +
`Öffne diesen Link innerhalb von 30 Minuten in der App:\n${v.link}\n\n` +
`Falls du das nicht warst, ignoriere diese E-Mail dein Passwort bleibt unverändert.\n\n${BRAND.name}`,
}),
},
fr: {
"auth.verify_email": (v) => ({
subject: `Confirmez votre e-mail ${BRAND.name}`,
text:
`Bienvenue sur ${BRAND.name} !\n\n` +
`Confirmez que ${v.email} est bien votre adresse en ouvrant ce lien dans l'app :\n${v.link}\n\n` +
`Le lien est valable 24 heures. L'app fonctionne sans confirmation, mais certaines ` +
`fonctions (comme la réinitialisation du mot de passe) sont plus sûres avec une adresse vérifiée.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Réinitialisez votre mot de passe ${BRAND.name}`,
text:
`Bonjour !\n\nUne réinitialisation du mot de passe a été demandée pour ${v.email}.\n\n` +
`Ouvrez ce lien dans l'app sous 30 minutes :\n${v.link}\n\n` +
`Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail : le mot de passe ne changera pas.\n\n${BRAND.name}`,
}),
},
da: {
"auth.verify_email": (v) => ({
subject: `Bekræft din e-mailadresse ${BRAND.name}`,
text:
`Velkommen til ${BRAND.name}!\n\n` +
`Bekræft, at ${v.email} er din adresse ved at åbne linket i appen:\n${v.link}\n\n` +
`Linket gælder i 24 timer. Appen fungerer også uden bekræftelse, men nogle funktioner (fx nulstilling af adgangskode) er sikrere med bekræftet adresse.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Nulstil din adgangskode ${BRAND.name}`,
text:
`Hej!\n\nDer er anmodet om nulstilling af adgangskoden for ${v.email}.\n\n` +
`Åbn linket i appen inden for 30 minutter:\n${v.link}\n\n` +
`Hvis du ikke har anmodet om dette, kan du ignorere denne mail adgangskoden ændres ikke.\n\n${BRAND.name}`,
}),
},
nb: {
"auth.verify_email": (v) => ({
subject: `Bekreft e-postadressen din ${BRAND.name}`,
text:
`Velkommen til ${BRAND.name}!\n\n` +
`Bekreft at ${v.email} er adressen din ved å åpne lenken i appen:\n${v.link}\n\n` +
`Lenken gjelder i 24 timer. Appen fungerer også uten bekreftelse, men noen funksjoner (f.eks. tilbakestilling av passord) er tryggere med bekreftet adresse.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Tilbakestill passordet ditt ${BRAND.name}`,
text:
`Hei!\n\nDet er bedt om tilbakestilling av passordet for ${v.email}.\n\n` +
`Åpne lenken i appen innen 30 minutter:\n${v.link}\n\n` +
`Hvis du ikke ba om dette, kan du ignorere denne e-posten passordet endres ikke.\n\n${BRAND.name}`,
}),
},
fi: {
"auth.verify_email": (v) => ({
subject: `Vahvista sähköpostiosoitteesi ${BRAND.name}`,
text:
`Tervetuloa palveluun ${BRAND.name}!\n\n` +
`Vahvista, että ${v.email} on osoitteesi avaamalla linkki sovelluksessa:\n${v.link}\n\n` +
`Linkki on voimassa 24 tuntia. Sovellus toimii ilman vahvistustakin, mutta jotkin toiminnot (kuten salasanan nollaus) ovat turvallisempia vahvistetulla osoitteella.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Nollaa salasanasi ${BRAND.name}`,
text:
`Hei!\n\nOsoitteelle ${v.email} on pyydetty salasanan nollausta.\n\n` +
`Avaa linkki sovelluksessa 30 minuutin kuluessa:\n${v.link}\n\n` +
`Jos et pyytänyt tätä, voit ohittaa viestin salasana ei muutu.\n\n${BRAND.name}`,
}),
},
nl: {
"auth.verify_email": (v) => ({
subject: `Bevestig je e-mailadres ${BRAND.name}`,
text:
`Welkom bij ${BRAND.name}!\n\n` +
`Bevestig dat ${v.email} jouw adres is door deze link in de app te openen:\n${v.link}\n\n` +
`De link is 24 uur geldig. De app werkt ook zonder bevestiging, maar sommige functies (zoals wachtwoordherstel) zijn veiliger met een bevestigd adres.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Stel je wachtwoord opnieuw in ${BRAND.name}`,
text:
`Hoi!\n\nEr is een wachtwoordherstel aangevraagd voor ${v.email}.\n\n` +
`Open deze link binnen 30 minuten in de app:\n${v.link}\n\n` +
`Als jij dit niet was, kun je deze e-mail negeren het wachtwoord verandert niet.\n\n${BRAND.name}`,
}),
},
pl: {
"auth.verify_email": (v) => ({
subject: `Potwierdź swój adres e-mail ${BRAND.name}`,
text:
`Witamy w ${BRAND.name}!\n\n` +
`Potwierdź, że ${v.email} to Twój adres, otwierając link w aplikacji:\n${v.link}\n\n` +
`Link jest ważny 24 godziny. Aplikacja działa bez potwierdzenia, ale niektóre funkcje (np. resetowanie hasła) są bezpieczniejsze ze zweryfikowanym adresem.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Zresetuj swoje hasło ${BRAND.name}`,
text:
`Cześć!\n\nPoproszono o zresetowanie hasła dla ${v.email}.\n\n` +
`Otwórz link w aplikacji w ciągu 30 minut:\n${v.link}\n\n` +
`Jeśli to nie Ty, zignoruj tę wiadomość hasło się nie zmieni.\n\n${BRAND.name}`,
}),
},
pt: {
"auth.verify_email": (v) => ({
subject: `Confirme o seu e-mail ${BRAND.name}`,
text:
`Bem-vindo ao ${BRAND.name}!\n\n` +
`Confirme que ${v.email} é o seu endereço abrindo este link na app:\n${v.link}\n\n` +
`O link é válido por 24 horas. A app funciona sem confirmação, mas algumas funções (como repor a palavra-passe) são mais seguras com o endereço verificado.\n\n${BRAND.name}`,
}),
"auth.password_reset": (v) => ({
subject: `Reponha a sua palavra-passe ${BRAND.name}`,
text:
`Olá!\n\nFoi pedida a reposição da palavra-passe de ${v.email}.\n\n` +
`Abra este link na app no prazo de 30 minutos:\n${v.link}\n\n` +
`Se não fez este pedido, ignore este e-mail a palavra-passe não será alterada.\n\n${BRAND.name}`,
}),
},
};
export function renderMail(
templateKey: string,
languageTag: string,
vars: Record<string, string>,
): { subject: string; text: string } {
const lang = languageTag.split("-")[0] ?? "sv";
const template = TEMPLATES[lang]?.[templateKey] ?? TEMPLATES.sv?.[templateKey];
if (!template) throw new Error(`Okänd mejlmall: ${templateKey}`);
return template(vars);
}
+50
View File
@@ -0,0 +1,50 @@
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
/**
* Lösenordshantering med scrypt ur node:crypto ingen native-dependency,
* OWASP-rekommenderade parametrar (N=2^15, r=8, p=1, 32 byte).
* Format: scrypt$N$r$p$salt_b64$hash_b64 (självbeskrivande → parametrar kan höjas).
*/
const N = 32_768;
const R = 8;
const P = 1;
const KEYLEN = 32;
function scryptAsync(
password: string,
salt: Buffer,
n: number,
r: number,
p: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 128 * n * r * 2 }, (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, N, R, P);
return `scrypt$${N}$${R}$${P}$${salt.toString("base64")}$${hash.toString("base64")}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const n = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
const salt = Buffer.from(parts[4]!, "base64");
const expected = Buffer.from(parts[5]!, "base64");
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
try {
const actual = await scryptAsync(password, salt, n, r, p);
return actual.length === expected.length && timingSafeEqual(actual, expected);
} catch {
return false;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* TOTP (RFC 6238) utan externa beroenden HMAC-SHA1, 6 siffror, 30 s steg.
* Används för admin-2FA. Inga tredjepartspaket i autentiseringskedjan
* (samma princip som scrypt-valet för lösenord).
*/
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = "";
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
const clean = s.toUpperCase().replace(/=+$/, "").replace(/\s/g, "");
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = BASE32_ALPHABET.indexOf(ch);
if (idx === -1) throw new Error("Ogiltig base32");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
/** Nytt slumpat TOTP-secret (160 bitar enligt RFC 4226-rekommendation). */
export function generateTotpSecret(): string {
return base32Encode(randomBytes(20));
}
function hotp(secretBase32: string, counter: number): string {
const key = base32Decode(secretBase32);
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", key).update(msg).digest();
const offset = digest[digest.length - 1]! & 0x0f;
const code =
((digest[offset]! & 0x7f) << 24) |
(digest[offset + 1]! << 16) |
(digest[offset + 2]! << 8) |
digest[offset + 3]!;
return String(code % 1_000_000).padStart(6, "0");
}
export function totpCode(secretBase32: string, atMs = Date.now(), stepSeconds = 30): string {
return hotp(secretBase32, Math.floor(atMs / 1000 / stepSeconds));
}
/**
* Verifiera med ±1 stegs fönster (klockdrift). Konstanttidsjämförelse.
* Returnerar det matchade steget (för engångsskydd) eller null.
*/
export function verifyTotp(
secretBase32: string,
code: string,
atMs = Date.now(),
stepSeconds = 30,
): number | null {
const normalized = code.replace(/\s/g, "");
if (!/^\d{6}$/.test(normalized)) return null;
const step = Math.floor(atMs / 1000 / stepSeconds);
for (const candidate of [step, step - 1, step + 1]) {
const expected = hotp(secretBase32, candidate);
if (timingSafeEqual(Buffer.from(expected), Buffer.from(normalized))) return candidate;
}
return null;
}
/** otpauth-URL för autentiseringsappar (Google Authenticator, 1Password …). */
export function otpauthUrl(secretBase32: string, accountName: string, issuer: string): string {
const label = encodeURIComponent(`${issuer}:${accountName}`);
return `otpauth://totp/${label}?secret=${secretBase32}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
+77
View File
@@ -0,0 +1,77 @@
import fp from "fastify-plugin";
import fastifyJwt from "@fastify/jwt";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import type { UserRole } from "@app/shared-types";
import { errors } from "../lib/errors.js";
export interface AccessTokenPayload {
sub: string;
role: UserRole;
type: "access";
/** true endast när inloggningen gick via TOTP-steget (admin-2FA). */
mfa?: boolean;
}
declare module "fastify" {
interface FastifyInstance {
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
userId: string;
userRole: UserRole;
tokenMfa: boolean;
}
}
export const authPlugin = fp(async (app: FastifyInstance) => {
await app.register(fastifyJwt, {
secret: app.config.JWT_ACCESS_SECRET,
sign: { expiresIn: app.config.JWT_ACCESS_TTL_SECONDS },
});
app.decorateRequest("userId", "");
app.decorateRequest("userRole", "user" as UserRole);
app.decorateRequest("tokenMfa", false);
app.decorate("authenticate", async (req: FastifyRequest) => {
let payload: AccessTokenPayload;
try {
payload = await req.jwtVerify<AccessTokenPayload>();
} catch {
throw errors.unauthorized();
}
if (payload.type !== "access" || !payload.sub) throw errors.unauthorized();
req.userId = payload.sub;
req.userRole = payload.role;
req.tokenMfa = payload.mfa === true;
});
app.decorate("requireAdmin", async (req: FastifyRequest) => {
await app.authenticate(req, undefined as unknown as FastifyReply);
if (req.userRole !== "admin") {
// Dubbelkolla mot databasen rollen i en gammal token räcker inte.
const [user] = await app.db
.select({ role: schema.users.role })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (user?.role !== "admin") throw errors.forbidden("Kräver admin.");
req.userRole = "admin";
}
// 2FA-tvång: har kontot TOTP aktiverat måste token vara MFA-utfärdad
// (via /v1/auth/totp-verify). En lösenords-token räcker inte för admin-API.
if (!req.tokenMfa) {
const [totp] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (totp?.enabledAt) {
throw errors.forbidden("Tvåfaktorsautentisering krävs. Logga in igen med engångskod.");
}
}
});
});
+201
View File
@@ -0,0 +1,201 @@
import fp from "fastify-plugin";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomUUID } from "node:crypto";
import { Queue } from "bullmq";
import IORedis from "ioredis";
import { createDatabase, type Database } from "@app/database";
import { schema } from "@app/database";
import { createAamosClient, type AamosClient } from "@app/ai-contracts";
import { FeatureFlagService } from "@app/feature-flags";
import { StoreVerifier } from "@app/subscriptions";
import { createMailer, type Mailer } from "../lib/mailer.js";
import { createDefaultRegistry, type ConnectorRegistry } from "@app/connectors";
import type { AppConfig } from "../config.js";
import { ApiError } from "../lib/errors.js";
declare module "fastify" {
interface FastifyInstance {
config: AppConfig;
db: Database;
dbPool: { end(): Promise<void> };
jobQueue: Queue;
redis: IORedis;
aamos: AamosClient;
flags: FeatureFlagService;
storeVerifier: StoreVerifier;
mailer: Mailer;
connectors: ConnectorRegistry;
}
interface FastifyRequest {
correlationId: string;
}
}
import { JOB_QUEUE_NAME } from "@app/shared-types";
export { JOB_QUEUE_NAME };
/**
* Kärnplugin: db, redis/kö, AAMOS-klient, flaggor, correlation-id,
* säkerhetsheaders och enhetlig felhantering.
*/
export const corePlugin = fp(async (app: FastifyInstance, opts: { config: AppConfig }) => {
const { config } = opts;
app.decorate("config", config);
// --- Databas ---
const { db, pool } = createDatabase(config.DATABASE_URL);
app.decorate("db", db);
app.decorate("dbPool", pool);
app.addHook("onClose", async () => {
await pool.end();
});
// --- Redis + jobbkö ---
const redis = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null, lazyConnect: true });
redis.on("error", (err) => app.log.warn({ err }, "Redis-fel"));
app.decorate("redis", redis);
const jobQueue = new Queue(JOB_QUEUE_NAME, {
connection: redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2_000 },
removeOnComplete: 1_000,
removeOnFail: 5_000,
},
});
app.decorate("jobQueue", jobQueue);
app.addHook("onClose", async () => {
await jobQueue.close();
redis.disconnect();
});
// --- AAMOS (befintlig plattform; mock endast i dev/test) ---
app.decorate(
"aamos",
createAamosClient({
AAMOS_MODE: config.AAMOS_MODE,
AAMOS_API_URL: config.AAMOS_API_URL,
AAMOS_API_KEY: config.AAMOS_API_KEY,
AAMOS_TIMEOUT_MS: String(config.AAMOS_TIMEOUT_MS),
}),
);
// --- Feature flags ---
app.decorate(
"flags",
new FeatureFlagService({
loadAll: async () => {
const rows = await db.select().from(schema.featureFlags);
return rows.map((r) => ({
key: r.key,
enabled: r.enabled,
rolloutPercent: r.rolloutPercent,
}));
},
}),
);
// --- Butiksverifiering ---
app.decorate(
"storeVerifier",
new StoreVerifier({
mode: config.APP_STORE_MODE,
appleBundleId: config.APPLE_BUNDLE_ID,
googlePackageName: config.GOOGLE_PACKAGE_NAME,
}),
);
// --- E-post (log-läge i dev; leverantör kopplas i fas 7) ---
app.decorate(
"mailer",
createMailer(config.EMAIL_MODE, {
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_SECURE,
user: config.SMTP_USER,
pass: config.SMTP_PASS,
from: config.MAIL_FROM,
}),
);
// --- Connectors ---
app.decorate("connectors", createDefaultRegistry());
// --- Correlation ID (spec §58) ---
app.decorateRequest("correlationId", "");
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
const incoming = req.headers["x-correlation-id"];
req.correlationId =
typeof incoming === "string" && incoming.length <= 100 ? incoming : randomUUID();
reply.header("x-correlation-id", req.correlationId);
});
// --- Säkerhetsheaders ---
app.addHook("onSend", async (_req, reply) => {
reply.header("x-content-type-options", "nosniff");
reply.header("x-frame-options", "DENY");
reply.header("referrer-policy", "no-referrer");
reply.header("cache-control", "no-store");
});
// --- Enhetlig felhantering ---
const alertWebhook = async (text: string): Promise<void> => {
const url = process.env.ERROR_WEBHOOK_URL;
if (!url) return;
try {
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(5000),
});
} catch {
// Larmet får aldrig påverka svaret till klienten.
}
};
app.setErrorHandler((err, req, reply) => {
if (err instanceof ApiError) {
reply.status(err.statusCode).send({
error: {
code: err.code,
message: err.message,
details: err.details,
correlationId: req.correlationId,
},
});
return;
}
// Fastify-inbyggda (t.ex. rate limit, felaktig JSON)
const known = err as { statusCode?: unknown; message?: unknown };
const status = typeof known.statusCode === "number" ? known.statusCode : 500;
if (status >= 500) {
req.log.error({ err, correlationId: req.correlationId }, "Ohanterat fel");
void alertWebhook(
`API 5xx: ${req.method} ${req.url} ${String(known.message ?? "okänt fel")} (correlationId: ${req.correlationId})`,
);
}
reply.status(status).send({
error: {
code: status === 429 ? "RATE_LIMITED" : status >= 500 ? "INTERNAL" : "REQUEST_ERROR",
message:
status >= 500
? "Internt fel försök igen."
: typeof known.message === "string"
? known.message
: "Ogiltig förfrågan.",
correlationId: req.correlationId,
},
});
});
app.setNotFoundHandler((req, reply) => {
reply.status(404).send({
error: {
code: "NOT_FOUND",
message: "Endpointen finns inte.",
correlationId: req.correlationId,
},
});
});
});
+246
View File
@@ -0,0 +1,246 @@
import fp from "fastify-plugin";
import type { FastifyInstance } from "fastify";
import { createHmac, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Lagringsabstraktion (spec §53). Två lägen:
*
* - mock (dev/test): "presignade" URL:er pekar på API:ts egna PUT/GET-endpoints
* och filer lagras under .data/s3/. Hela flödet (app → signed upload →
* worker läser) fungerar utan AWS.
* - aws (staging/produktion): RIKTIGA S3 presigned URLs via @aws-sdk/client-s3
* + @aws-sdk/s3-request-presigner. Fungerar mot AWS S3 och alla
* S3-kompatibla lagringar (MinIO, Cloudflare R2, Hetzner …) via S3_ENDPOINT.
* Verifierad med riktig rundtur (presign → PUT → presign → GET) mot MinIO.
*
* Nyckelstruktur enligt spec §53: users/, fridge-scans/, pantry-scans/,
* meal-scans/, receipts/, product-images/, recipe-images/, temporary/.
*/
import {
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export interface PresignedUpload {
key: string;
uploadUrl: string;
method: "PUT";
headers: Record<string, string>;
expiresAt: string;
}
export interface StorageService {
presignUpload(prefix: string, contentType: string): Promise<PresignedUpload>;
/** URL som workern/AAMOS kan läsa bilden från. */
getReadUrl(key: string): Promise<string>;
putObject(key: string, data: Buffer, contentType: string): Promise<void>;
getObject(key: string): Promise<Buffer | null>;
}
declare module "fastify" {
interface FastifyInstance {
storage: StorageService;
}
}
const MOCK_ROOT = path.resolve(process.cwd(), ".data/s3");
class MockStorage implements StorageService {
constructor(
private readonly baseUrl: string,
private readonly signingSecret: string,
) {}
private sign(key: string): string {
return createHmac("sha256", this.signingSecret).update(key).digest("hex").slice(0, 32);
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const sig = this.sign(key);
return {
key,
uploadUrl: `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${this.sign(key)}`;
}
async putObject(key: string, data: Buffer, _contentType?: string): Promise<void> {
const filePath = path.join(MOCK_ROOT, key);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, data);
}
async getObject(key: string): Promise<Buffer | null> {
try {
return await readFile(path.join(MOCK_ROOT, key));
} catch {
return null;
}
}
verifySignature(key: string, sig: string): boolean {
return this.sign(key) === sig;
}
}
/** Riktig S3 (AWS eller S3-kompatibel via S3_ENDPOINT). */
export class AwsStorage implements StorageService {
private readonly client: S3Client;
constructor(
private readonly bucket: string,
region: string,
endpoint: string | undefined,
accessKeyId: string | undefined,
secretAccessKey: string | undefined,
) {
this.client = new S3Client({
region,
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}), // annars IAM-roll/instansprofil (rekommenderat i AWS)
});
}
/** Snabb verifiering vid uppstart hellre högljutt fel än trasiga uppladdningar. */
async healthCheck(): Promise<void> {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
}
async presignUpload(prefix: string, contentType: string): Promise<PresignedUpload> {
const ext = contentType.split("/")[1] ?? "bin";
const key = `${prefix}/${randomUUID()}.${ext}`;
const uploadUrl = await getSignedUrl(
this.client,
new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }),
{ expiresIn: 15 * 60 },
);
return {
key,
uploadUrl,
method: "PUT",
headers: { "content-type": contentType },
expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
};
}
async getReadUrl(key: string): Promise<string> {
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
expiresIn: 60 * 60,
});
}
async putObject(key: string, data: Buffer, contentType: string): Promise<void> {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }),
);
}
async getObject(key: string): Promise<Buffer | null> {
try {
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
const bytes = await res.Body?.transformToByteArray();
return bytes ? Buffer.from(bytes) : null;
} catch {
return null;
}
}
}
export const storagePlugin = fp(async (app: FastifyInstance) => {
let storage: StorageService;
if (app.config.S3_MODE === "aws") {
const aws = new AwsStorage(
app.config.S3_BUCKET,
app.config.S3_REGION,
app.config.S3_ENDPOINT || undefined,
app.config.S3_ACCESS_KEY_ID || undefined,
app.config.S3_SECRET_ACCESS_KEY || undefined,
);
// Verifiera bucket-åtkomst vid uppstart produktion ska aldrig starta halvtrasig.
try {
await aws.healthCheck();
app.log.info(`S3: riktig lagring aktiv (bucket: ${app.config.S3_BUCKET})`);
} catch (err) {
if (app.config.NODE_ENV === "production") {
throw new Error(
`S3-bucketen "${app.config.S3_BUCKET}" är inte nåbar: ${(err as Error).message}`,
);
}
app.log.error(
`S3-bucketen "${app.config.S3_BUCKET}" svarar inte (${(err as Error).message}) kontrollera S3_*-värdena.`,
);
}
storage = aws;
app.decorate("storage", storage);
return; // riktiga presignade URL:er inga lokala S3-endpoints behövs
}
const mockStorage = new MockStorage(
app.config.API_BASE_URL,
app.config.ENTITLEMENT_SIGNING_SECRET,
);
storage = mockStorage;
app.decorate("storage", storage);
// Mock-S3-endpoints (endast i mock-läge)
app.addContentTypeParser(
["image/jpeg", "image/png", "image/webp", "image/heic", "application/octet-stream"],
{ parseAs: "buffer", bodyLimit: 15 * 1024 * 1024 },
(_req, body, done) => done(null, body),
);
app.put<{ Params: { key: string }; Querystring: { sig?: string } }>(
"/v1/mock-s3/:key",
{ config: { rateLimit: false } },
async (req, reply) => {
const key = decodeURIComponent(req.params.key);
if (!mockStorage.verifySignature(key, req.query.sig ?? "")) {
return reply
.status(403)
.send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } });
}
const body = req.body as Buffer | undefined;
if (!body || body.length === 0) {
return reply.status(400).send({ error: { code: "EMPTY_BODY", message: "Tom fil" } });
}
await mockStorage.putObject(
key,
body,
req.headers["content-type"] ?? "application/octet-stream",
);
return reply.status(200).send({ ok: true, key });
},
);
app.get<{ Params: { key: string }; Querystring: { sig?: string } }>(
"/v1/mock-s3/:key",
{ config: { rateLimit: false } },
async (req, reply) => {
const key = decodeURIComponent(req.params.key);
if (!mockStorage.verifySignature(key, req.query.sig ?? "")) {
return reply
.status(403)
.send({ error: { code: "BAD_SIGNATURE", message: "Ogiltig signatur" } });
}
const data = await mockStorage.getObject(key);
if (!data)
return reply
.status(404)
.send({ error: { code: "NOT_FOUND", message: "Filen finns inte" } });
return reply.header("content-type", "application/octet-stream").send(data);
},
);
});
+427
View File
@@ -0,0 +1,427 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { z } from "zod";
import { adminGrantInputSchema, totpCodeInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit } from "../lib/helpers.js";
import { generateTotpSecret, otpauthUrl, verifyTotp } from "../lib/totp.js";
import { BRAND } from "@app/shared-types";
/** Adminpanelens API (spec §57). Alla anrop kräver admin-roll och auditloggas. */
export async function adminRoutes(app: FastifyInstance) {
const admin = { preHandler: [app.requireAdmin] };
// --- Users ---
app.get("/admin/v1/users", admin, async (req) => {
const q = z
.object({
search: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const conditions = q.search ? ilike(schema.users.email, `%${q.search}%`) : undefined;
const users = await app.db
.select({
id: schema.users.id,
email: schema.users.email,
displayName: schema.users.displayName,
role: schema.users.role,
onboardingCompleted: schema.users.onboardingCompleted,
createdAt: schema.users.createdAt,
deletedAt: schema.users.deletedAt,
})
.from(schema.users)
.where(conditions)
.orderBy(desc(schema.users.createdAt))
.limit(q.limit)
.offset(q.offset);
return { users };
});
// --- Receptmoderering (spec §35 steg 4) ---
app.get("/admin/v1/moderation/recipes", admin, async () => {
const pending = await app.db
.select()
.from(schema.recipes)
.where(sql`${schema.recipes.status} IN ('submitted', 'ai_checked', 'in_moderation')`)
.orderBy(schema.recipes.createdAt)
.limit(50);
return { recipes: pending };
});
app.post("/admin/v1/moderation/recipes/:id", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z
.object({
action: z.enum(["approve", "reject", "request_changes"]),
note: z.string().max(1000).optional(),
})
.parse(req.body);
const status =
body.action === "approve" ? "published" : body.action === "reject" ? "rejected" : "draft";
const [recipe] = await app.db
.update(schema.recipes)
.set({ status, moderationNote: body.note ?? null, updatedAt: new Date() })
.where(eq(schema.recipes.id, params.id))
.returning();
if (!recipe) throw errors.notFound();
if (body.action === "approve" && recipe.creatorUserId) {
await app.db
.insert(schema.creatorStats)
.values({ userId: recipe.creatorUserId, publishedRecipes: 1 })
.onConflictDoUpdate({
target: schema.creatorStats.userId,
set: {
publishedRecipes: sql`${schema.creatorStats.publishedRecipes} + 1`,
updatedAt: new Date(),
},
});
}
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: `moderation.recipe_${body.action}`,
targetType: "recipe",
targetId: params.id,
metadata: { note: body.note },
});
return recipe;
});
// --- Feature flags (spec §57) ---
app.get("/admin/v1/flags", admin, async () => {
return {
flags: await app.db.select().from(schema.featureFlags).orderBy(schema.featureFlags.key),
};
});
app.put("/admin/v1/flags/:key", admin, async (req) => {
const params = z.object({ key: z.string().max(60) }).parse(req.params);
const body = z
.object({
enabled: z.boolean(),
rolloutPercent: z.number().int().min(0).max(100).default(100),
descriptionSv: z.string().max(300).optional(),
})
.parse(req.body);
const [flag] = await app.db
.insert(schema.featureFlags)
.values({ key: params.key, ...body })
.onConflictDoUpdate({
target: schema.featureFlags.key,
set: { ...body, updatedAt: new Date() },
})
.returning();
app.flags.invalidate();
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "flags.updated",
targetType: "feature_flag",
targetId: params.key,
metadata: body,
});
return flag;
});
// --- Subscriptions ---
app.get("/admin/v1/subscriptions", admin, async () => {
const subs = await app.db
.select()
.from(schema.subscriptions)
.orderBy(desc(schema.subscriptions.updatedAt))
.limit(100);
return { subscriptions: subs };
});
app.post("/admin/v1/subscriptions/grant", admin, async (req) => {
const input = parse(adminGrantInputSchema, req.body);
if (input.plan === "free")
throw errors.badRequest("Använd delete i stället för att sätta free.");
const [sub] = await app.db
.insert(schema.subscriptions)
.values({
userId: input.userId,
provider: "promo",
productId: `promo_${input.plan}`,
plan: input.plan,
status: "active",
purchasedAt: new Date(),
expiresAt: new Date(Date.now() + input.days * 86_400_000),
lastVerifiedAt: new Date(),
})
.returning();
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "subscription.granted",
targetType: "user",
targetId: input.userId,
metadata: { plan: input.plan, days: input.days, reason: input.reason },
});
return sub;
});
// --- Jobb & systemhälsa (spec §5758) ---
app.get("/admin/v1/jobs/overview", admin, async () => {
const scanStats = await app.db
.select({ status: schema.scanJobs.status, count: sql<number>`count(*)` })
.from(schema.scanJobs)
.groupBy(schema.scanJobs.status);
const queueCounts = await app.jobQueue.getJobCounts();
const unpublishedEvents = await app.db
.select({ count: sql<number>`count(*)` })
.from(schema.domainEvents)
.where(sql`${schema.domainEvents.publishedAt} IS NULL`);
return {
scanJobs: scanStats,
queue: queueCounts,
outboxPending: Number(unpublishedEvents[0]?.count ?? 0),
};
});
app.get("/admin/v1/system/health", admin, async () => {
const checks: Record<string, { ok: boolean; detail?: string }> = {};
try {
await app.db.execute(sql`SELECT 1`);
checks.database = { ok: true };
} catch (err) {
checks.database = { ok: false, detail: String(err) };
}
try {
const pong = await app.redis.ping();
checks.redis = { ok: pong === "PONG" };
} catch (err) {
checks.redis = { ok: false, detail: String(err) };
}
checks.aamos = await app.aamos.healthCheck();
checks.connectors = { ok: true, detail: `${app.connectors.list().length} registrerade` };
return { checks, timestamp: new Date().toISOString() };
});
// --- Audit logs ---
app.get("/admin/v1/audit-logs", admin, async (req) => {
const q = z
.object({
action: z.string().max(60).optional(),
limit: z.coerce.number().int().min(1).max(200).default(100),
})
.parse(req.query);
const conditions = q.action ? ilike(schema.auditLogs.action, `%${q.action}%`) : undefined;
const logs = await app.db
.select()
.from(schema.auditLogs)
.where(conditions)
.orderBy(desc(schema.auditLogs.createdAt))
.limit(q.limit);
return { logs };
});
// --- AI-korrigeringar & evals (spec §3334) ---
app.get("/admin/v1/ai/corrections", admin, async () => {
const corrections = await app.db
.select()
.from(schema.aiCorrections)
.orderBy(desc(schema.aiCorrections.createdAt))
.limit(100);
return { corrections };
});
app.get("/admin/v1/ai/eval-runs", admin, async () => {
const runs = await app.db
.select()
.from(schema.aiEvalRuns)
.orderBy(desc(schema.aiEvalRuns.createdAt))
.limit(50);
return { runs };
});
// --- Admin-2FA (TOTP, RFC 6238) ---
/**
* Starta 2FA-setup: nytt secret + otpauth-URL för autentiseringsappen.
* OBS: kräver befintlig admin-session; kan köras om tills enable bekräftats.
*/
app.post("/admin/v1/2fa/setup", admin, async (req) => {
const [existing] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (existing?.enabledAt) {
throw errors.badRequest("2FA är redan aktiverat. Inaktivera först för att byta secret.");
}
const secret = generateTotpSecret();
const [user] = await app.db
.select({ email: schema.users.email })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
await app.db
.insert(schema.adminTotp)
.values({ userId: req.userId, secretBase32: secret })
.onConflictDoUpdate({
target: schema.adminTotp.userId,
set: { secretBase32: secret, enabledAt: null, lastUsedStep: null },
});
return {
secret,
otpauthUrl: otpauthUrl(secret, user?.email ?? "admin", `${BRAND.name} Admin`),
};
});
/** Bekräfta setup med första koden först nu börjar tvånget gälla. */
app.post("/admin/v1/2fa/enable", admin, async (req) => {
const body = parse(totpCodeInputSchema, req.body);
const [row] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (!row) throw errors.badRequest("Kör setup först.");
if (row.enabledAt) return { enabled: true };
const step = verifyTotp(row.secretBase32, body.code);
if (step == null)
throw errors.unauthorized("Fel engångskod kontrollera appen och försök igen.");
await app.db
.update(schema.adminTotp)
.set({ enabledAt: new Date(), lastUsedStep: step })
.where(eq(schema.adminTotp.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "admin.2fa_enabled",
});
return { enabled: true };
});
/** Inaktivera 2FA (kräver giltig kod aldrig bara en session). */
app.post("/admin/v1/2fa/disable", admin, async (req) => {
const body = parse(totpCodeInputSchema, req.body);
const [row] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
if (!row?.enabledAt) return { enabled: false };
if (verifyTotp(row.secretBase32, body.code) == null) {
throw errors.unauthorized("Fel engångskod.");
}
await app.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "admin.2fa_disabled",
});
return { enabled: false };
});
/** Status för inloggad admin (visas i adminpanelen). */
app.get("/admin/v1/2fa/status", admin, async (req) => {
const [row] = await app.db
.select({ enabledAt: schema.adminTotp.enabledAt })
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, req.userId))
.limit(1);
return { enabled: Boolean(row?.enabledAt) };
});
// --- Receptöversättningar (i18n-spec §1314, M3) ---
/** Beställ AI-utkast för ett recept och språk. Idempotent (upsert i worker). */
app.post("/admin/v1/recipes/:id/translate", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const body = z.object({ languageTag: z.string().min(2).max(35) }).parse(req.body);
const [recipe] = await app.db
.select({ id: schema.recipes.id })
.from(schema.recipes)
.where(eq(schema.recipes.id, params.id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
await app.jobQueue.add("TRANSLATE_RECIPE", {
jobType: "TRANSLATE_RECIPE",
recipeId: params.id,
targetLanguageTag: body.languageTag,
});
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "recipe.translate.requested",
targetType: "recipe",
targetId: params.id,
metadata: { languageTag: body.languageTag },
});
return { queued: true, recipeId: params.id, languageTag: body.languageTag };
});
/** Lista översättningar (med verifieringsresultat) för granskning. */
app.get("/admin/v1/recipes/:id/translations", admin, async (req) => {
const params = z.object({ id: z.uuid() }).parse(req.params);
const translations = await app.db
.select()
.from(schema.recipeTranslations)
.where(eq(schema.recipeTranslations.recipeId, params.id));
const steps = await app.db
.select()
.from(schema.recipeStepTranslations)
.where(eq(schema.recipeStepTranslations.recipeId, params.id))
.orderBy(schema.recipeStepTranslations.stepNumber);
return {
translations: translations.map((t) => ({
...t,
steps: steps.filter((s) => s.languageTag === t.languageTag),
})),
};
});
/** Publicera/underkänn en översättning efter mänsklig granskning. */
app.post("/admin/v1/recipes/:id/translations/:languageTag", admin, async (req) => {
const params = z
.object({ id: z.uuid(), languageTag: z.string().min(2).max(35) })
.parse(req.params);
const body = z
.object({
action: z.enum(["publish", "back_to_draft", "in_review"]),
/** Redaktören kan rätta texten i samma steg. */
title: z.string().min(1).optional(),
description: z.string().nullable().optional(),
})
.parse(req.body);
const status =
body.action === "publish"
? ("published" as const)
: body.action === "in_review"
? ("in_review" as const)
: ("draft_ai" as const);
const [row] = await app.db
.update(schema.recipeTranslations)
.set({
status,
...(body.title ? { title: body.title, source: "human" as const } : {}),
...(body.description !== undefined ? { description: body.description } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(schema.recipeTranslations.recipeId, params.id),
eq(schema.recipeTranslations.languageTag, params.languageTag),
),
)
.returning();
if (!row) throw errors.notFound("Översättningen finns inte.");
await audit(app.db, {
actorUserId: req.userId,
actorType: "admin",
action: "recipe.translation.moderated",
targetType: "recipe",
targetId: params.id,
metadata: { languageTag: params.languageTag, action: body.action },
});
return row;
});
}
+482
View File
@@ -0,0 +1,482 @@
import type { FastifyInstance } from "fastify";
import { randomBytes, randomUUID } from "node:crypto";
import { and, eq, isNull } from "drizzle-orm";
import { schema } from "@app/database";
import {
loginInputSchema,
refreshInputSchema,
registerInputSchema,
changePasswordInputSchema,
forgotPasswordInputSchema,
resetPasswordInputSchema,
verifyEmailInputSchema,
totpVerifyInputSchema,
} from "@app/validation";
import { trialEndsAt } from "@app/subscriptions";
import { BRAND, DEFAULT_LOCALE_PREFERENCES, localeDefaultsForRegion } from "@app/shared-types";
import { renderMail } from "../lib/mailer.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import { errors, parse } from "../lib/errors.js";
import { hashPassword, verifyPassword } from "../lib/passwords.js";
import { verifyTotp } from "../lib/totp.js";
import { audit, sha256 } from "../lib/helpers.js";
/**
* Auth: registrering, inloggning, roterande refresh-tokens, utloggning.
* Refresh-tokens lagras hashade; återanvändning av roterad token
* ogiltigförklarar hela familjen (token theft detection).
*/
export async function authRoutes(app: FastifyInstance) {
const strictLimit = { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } };
app.post("/v1/auth/register", strictLimit, async (req, reply) => {
const input = parse(registerInputSchema, req.body);
const [existing] = await app.db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.email, input.email))
.limit(1);
if (existing) throw errors.conflict("E-postadressen är redan registrerad.");
const [user] = await app.db
.insert(schema.users)
.values({ email: input.email, displayName: input.displayName, locale: input.locale })
.returning();
if (!user) throw errors.internal();
await app.db
.insert(schema.userCredentials)
.values({ userId: user.id, passwordHash: await hashPassword(input.password) });
await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing();
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: user.id })
.onConflictDoNothing();
// 7 dagars trial utan kort startar direkt (spec §46)
const now = new Date();
await app.db
.insert(schema.trials)
.values({ userId: user.id, startedAt: now, endsAt: trialEndsAt(now) });
await audit(app.db, {
actorUserId: user.id,
action: "auth.register",
ip: req.ip,
correlationId: req.correlationId,
});
// D-031: enhetens språk följer med registreringen → locale-preferenser skapas
// FÖRE välkomstmejlet, så att spanjorens första mejl kommer på spanska.
const regionFromLocale = input.locale.split("-")[1]?.toUpperCase();
const localeDefaults = regionFromLocale
? localeDefaultsForRegion(regionFromLocale)
: DEFAULT_LOCALE_PREFERENCES;
await app.db
.insert(schema.userLocalePreferences)
.values({
userId: user.id,
languageTag: input.locale,
regionCode: localeDefaults.regionCode,
timeZone: localeDefaults.timeZone,
measurementSystem: localeDefaults.measurementSystem,
temperatureUnit: localeDefaults.temperatureUnit,
currencyCode: localeDefaults.currencyCode,
})
.onConflictDoNothing();
// E-postverifiering (icke-blockerande): mejlet skickas, kontot fungerar direkt.
await sendVerificationMail(app, user.id, user.email);
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
return reply.status(201).send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
const input = parse(loginInputSchema, req.body);
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.email, input.email), isNull(schema.users.deletedAt)))
.limit(1);
const [creds] = user
? await app.db
.select()
.from(schema.userCredentials)
.where(eq(schema.userCredentials.userId, user.id))
.limit(1)
: [];
const ok = user && creds ? await verifyPassword(input.password, creds.passwordHash) : false;
if (!ok || !user) {
// Konstant svar oavsett om kontot finns ingen user enumeration.
throw errors.unauthorized("Fel e-post eller lösenord.");
}
// Admin-2FA (step-up): rätt lösenord räcker inte om TOTP är aktiverat.
const [totp] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, user.id))
.limit(1);
if (totp?.enabledAt) {
const preAuthToken = app.jwt.sign(
{ sub: user.id, type: "preauth" },
{ expiresIn: 300 }, // 5 minuter att ange koden
);
await audit(app.db, {
actorUserId: user.id,
action: "auth.login_totp_required",
ip: req.ip,
correlationId: req.correlationId,
});
return reply.send({ totpRequired: true, preAuthToken });
}
await audit(app.db, {
actorUserId: user.id,
action: "auth.login",
ip: req.ip,
correlationId: req.correlationId,
});
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
return reply.send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
/** Steg 2 av admin-inloggning: preauth-token + TOTP-kod → riktiga tokens. */
app.post("/v1/auth/totp-verify", strictLimit, async (req, reply) => {
const input = parse(totpVerifyInputSchema, req.body);
let payload: { sub?: string; type?: string };
try {
payload = app.jwt.verify(input.preAuthToken);
} catch {
throw errors.unauthorized("Ogiltig eller utgången inloggning. Börja om.");
}
if (payload.type !== "preauth" || !payload.sub) throw errors.unauthorized();
const [totp] = await app.db
.select()
.from(schema.adminTotp)
.where(eq(schema.adminTotp.userId, payload.sub))
.limit(1);
if (!totp?.enabledAt) throw errors.unauthorized();
const step = verifyTotp(totp.secretBase32, input.code);
if (step == null || (totp.lastUsedStep != null && step <= totp.lastUsedStep)) {
await audit(app.db, {
actorUserId: payload.sub,
action: "auth.totp_failed",
ip: req.ip,
});
throw errors.unauthorized("Fel engångskod.");
}
await app.db
.update(schema.adminTotp)
.set({ lastUsedStep: step })
.where(eq(schema.adminTotp.userId, payload.sub));
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.id, payload.sub), isNull(schema.users.deletedAt)))
.limit(1);
if (!user) throw errors.unauthorized();
await audit(app.db, {
actorUserId: user.id,
action: "auth.login",
metadata: { mfa: true },
ip: req.ip,
correlationId: req.correlationId,
});
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip, {
mfa: true,
});
return reply.send({
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
...tokens,
});
});
/** Bekräfta e-postadress (länken i välkomstmejlet). Engångs, 24 h TTL. */
app.post("/v1/auth/verify-email", strictLimit, async (req, reply) => {
const input = parse(verifyEmailInputSchema, req.body);
const [stored] = await app.db
.select()
.from(schema.emailVerificationTokens)
.where(eq(schema.emailVerificationTokens.tokenHash, sha256(input.token)))
.limit(1);
if (!stored || stored.usedAt || stored.expiresAt < new Date()) {
throw errors.unauthorized("Ogiltig eller utgången verifieringslänk. Begär en ny.");
}
await app.db
.update(schema.emailVerificationTokens)
.set({ usedAt: new Date() })
.where(eq(schema.emailVerificationTokens.id, stored.id));
await app.db
.update(schema.users)
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.users.id, stored.userId));
await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip });
return reply.send({ ok: true });
});
/** Skicka nytt verifieringsmejl (inloggad, ej redan verifierad). */
app.post(
"/v1/auth/resend-verification",
{ preHandler: [app.authenticate], ...strictLimit },
async (req, reply) => {
const [user] = await app.db
.select({ email: schema.users.email, verifiedAt: schema.users.emailVerifiedAt })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (!user) throw errors.unauthorized();
if (!user.verifiedAt) await sendVerificationMail(app, req.userId, user.email);
return reply.send({ ok: true });
},
);
app.post("/v1/auth/refresh", strictLimit, async (req, reply) => {
const input = parse(refreshInputSchema, req.body);
const tokenHash = sha256(input.refreshToken);
const [stored] = await app.db
.select()
.from(schema.refreshTokens)
.where(eq(schema.refreshTokens.tokenHash, tokenHash))
.limit(1);
if (!stored) throw errors.unauthorized("Ogiltig refresh-token.");
if (stored.revokedAt) {
// Token-återanvändning → hela familjen ogiltigförklaras.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(eq(schema.refreshTokens.familyId, stored.familyId));
await audit(app.db, {
actorUserId: stored.userId,
action: "auth.refresh_reuse_detected",
metadata: { familyId: stored.familyId },
ip: req.ip,
});
throw errors.unauthorized("Sessionen har återkallats. Logga in igen.");
}
if (stored.expiresAt < new Date()) throw errors.unauthorized("Sessionen har gått ut.");
const [user] = await app.db
.select()
.from(schema.users)
.where(and(eq(schema.users.id, stored.userId), isNull(schema.users.deletedAt)))
.limit(1);
if (!user) throw errors.unauthorized();
// Rotera: revokera gamla, utfärda ny i samma familj.
const next = await issueTokens(
app,
user.id,
user.role,
req.headers["user-agent"],
req.ip,
stored.familyId,
);
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date(), replacedByTokenId: next.refreshTokenId })
.where(eq(schema.refreshTokens.id, stored.id));
return reply.send({
accessToken: next.accessToken,
refreshToken: next.refreshToken,
accessTokenExpiresIn: next.accessTokenExpiresIn,
});
});
app.post("/v1/auth/logout", { preHandler: [app.authenticate] }, async (req, reply) => {
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, { actorUserId: req.userId, action: "auth.logout", ip: req.ip });
return reply.send({ ok: true });
});
/**
* Lösenordsåterställning steg 1 (spec §56: säker kontohantering).
* Svarar ALLTID { ok: true } avslöjar aldrig om kontot finns
* (anti-enumeration). Token: 32 slumpbytes, lagras sha256-hashad,
* 30 min TTL, engångsbruk; tidigare oanvända tokens ogiltigförklaras.
*/
app.post("/v1/auth/forgot-password", strictLimit, async (req, reply) => {
const input = parse(forgotPasswordInputSchema, req.body);
const [user] = await app.db
.select({ id: schema.users.id, email: schema.users.email })
.from(schema.users)
.where(eq(schema.users.email, input.email))
.limit(1);
if (user) {
// Ogiltigförklara tidigare oanvända tokens.
await app.db
.update(schema.passwordResetTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(schema.passwordResetTokens.userId, user.id),
isNull(schema.passwordResetTokens.usedAt),
),
);
const token = randomBytes(32).toString("hex");
await app.db.insert(schema.passwordResetTokens).values({
userId: user.id,
tokenHash: sha256(token),
expiresAt: new Date(Date.now() + 30 * 60_000),
});
const languageTag = (await loadLocalePreferences(app.db, user.id)).languageTag;
const mail = renderMail("auth.password_reset", languageTag, {
email: user.email,
link: `${BRAND.urlScheme}://reset-password?token=${token}`,
});
await app.mailer.send({ to: user.email, ...mail });
await audit(app.db, {
actorUserId: user.id,
action: "auth.password_reset_requested",
ip: req.ip,
});
}
// Samma svar oavsett och ingen tidsskillnad stor nog att mäta via rate limit.
return reply.send({ ok: true });
});
/** Lösenordsåterställning steg 2: token + nytt lösenord. Engångsbruk. */
app.post("/v1/auth/reset-password", strictLimit, async (req, reply) => {
const input = parse(resetPasswordInputSchema, req.body);
const [stored] = await app.db
.select()
.from(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.tokenHash, sha256(input.token)))
.limit(1);
if (!stored || stored.usedAt || stored.expiresAt < new Date()) {
throw errors.unauthorized("Ogiltig eller utgången återställningslänk. Begär en ny.");
}
await app.db
.update(schema.passwordResetTokens)
.set({ usedAt: new Date() })
.where(eq(schema.passwordResetTokens.id, stored.id));
await app.db
.update(schema.userCredentials)
.set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() })
.where(eq(schema.userCredentials.userId, stored.userId));
// Logga ut ALLA sessioner ett återställt konto börjar om från noll.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, stored.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, {
actorUserId: stored.userId,
action: "auth.password_reset_completed",
ip: req.ip,
});
return reply.send({ ok: true });
});
app.post(
"/v1/auth/change-password",
{ preHandler: [app.authenticate], ...strictLimit },
async (req, reply) => {
const input = parse(changePasswordInputSchema, req.body);
const [creds] = await app.db
.select()
.from(schema.userCredentials)
.where(eq(schema.userCredentials.userId, req.userId))
.limit(1);
if (!creds || !(await verifyPassword(input.currentPassword, creds.passwordHash))) {
throw errors.unauthorized("Fel nuvarande lösenord.");
}
await app.db
.update(schema.userCredentials)
.set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() })
.where(eq(schema.userCredentials.userId, req.userId));
// Logga ut alla andra sessioner.
await app.db
.update(schema.refreshTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)),
);
await audit(app.db, { actorUserId: req.userId, action: "auth.change_password", ip: req.ip });
return reply.send({ ok: true });
},
);
}
async function issueTokens(
app: FastifyInstance,
userId: string,
role: string,
userAgent: string | undefined,
ip: string | undefined,
familyIdOrOpts?: string | { mfa?: boolean },
opts?: { mfa?: boolean },
) {
const familyId = typeof familyIdOrOpts === "string" ? familyIdOrOpts : undefined;
const mfa = (typeof familyIdOrOpts === "object" ? familyIdOrOpts.mfa : opts?.mfa) ?? false;
const accessToken = app.jwt.sign({ sub: userId, role, type: "access", ...(mfa ? { mfa } : {}) });
const refreshToken = randomUUID() + "." + randomUUID();
const family = familyId ?? randomUUID();
const [row] = await app.db
.insert(schema.refreshTokens)
.values({
userId,
tokenHash: sha256(refreshToken),
familyId: family,
expiresAt: new Date(Date.now() + app.config.JWT_REFRESH_TTL_SECONDS * 1000),
userAgent: userAgent?.slice(0, 300) ?? null,
ip: ip ?? null,
})
.returning({ id: schema.refreshTokens.id });
return {
accessToken,
refreshToken,
refreshTokenId: row!.id,
accessTokenExpiresIn: app.config.JWT_ACCESS_TTL_SECONDS,
};
}
/** Skapa verifieringstoken + skicka mejl på användarens språk (24 h TTL). */
async function sendVerificationMail(app: FastifyInstance, userId: string, email: string) {
await app.db
.update(schema.emailVerificationTokens)
.set({ usedAt: new Date() })
.where(
and(
eq(schema.emailVerificationTokens.userId, userId),
isNull(schema.emailVerificationTokens.usedAt),
),
);
const token = randomBytes(32).toString("hex");
await app.db.insert(schema.emailVerificationTokens).values({
userId,
tokenHash: sha256(token),
expiresAt: new Date(Date.now() + 24 * 3600_000),
});
const languageTag = (await loadLocalePreferences(app.db, userId)).languageTag;
const mail = renderMail("auth.verify_email", languageTag, {
email,
link: `${BRAND.urlScheme}://verify-email?token=${token}`,
});
await app.mailer.send({ to: email, ...mail });
}
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { requireActiveHousehold } from "../lib/helpers.js";
/** Budget & matsvinn (spec §26): vecka/månad, kostnad per måltid, svinnvärde. */
export async function budgetRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/budget/summary", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const now = new Date();
// UTC-kalender (samma policy som motorerna): identiska summor oavsett serverns tidszon.
const mondayOffset = (now.getUTCDay() + 6) % 7; // 0 = måndag
const weekStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset),
);
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
// Inköpskostnad = summa purchase-transaktioner med värde
const [weekPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Matsvinnsvärde = summa discard-transaktioner (spec §12, §26)
const [weekWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Kostnad per hemlagad måltid: lagade recept denna månad med kostnadsdata
const cooks = await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
portions: schema.recipeCooks.portionsCooked,
})
.from(schema.recipeCooks)
.where(
and(
eq(schema.recipeCooks.householdId, householdId),
gte(schema.recipeCooks.cookedAt, monthStart),
),
);
let mealCostTotal = 0;
let mealPortions = 0;
if (cooks.length > 0) {
const recipes = await app.db
.select({ id: schema.recipes.id, cost: schema.recipes.estimatedCostMinorPerPortion })
.from(schema.recipes)
.where(inArray(schema.recipes.id, [...new Set(cooks.map((c) => c.recipeId))]));
const costMap = new Map(recipes.map((r) => [r.id, r.cost]));
for (const cook of cooks) {
const cost = costMap.get(cook.recipeId);
if (cost != null) {
mealCostTotal += cost * cook.portions;
mealPortions += cook.portions;
}
}
}
const [household] = await app.db
.select({
budget: schema.households.weeklyBudgetMinor,
currencyCode: schema.households.currencyCode,
})
.from(schema.households)
.where(eq(schema.households.id, householdId))
.limit(1);
// Alla belopp i minor units (heltal) i hushållets valuta (i18n-spec §20).
const currency = household?.currencyCode ?? "SEK";
return {
currency,
week: {
purchasedMinor: Math.round(Number(weekPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(weekWaste?.total ?? 0)),
wasteCount: Number(weekWaste?.count ?? 0),
budgetMinor: household?.budget ?? null,
},
month: {
purchasedMinor: Math.round(Number(monthPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(monthWaste?.total ?? 0)),
wasteCount: Number(monthWaste?.count ?? 0),
estimatedCostPerPortionMinor:
mealPortions > 0 ? Math.round(mealCostTotal / mealPortions) : null,
cookedPortions: mealPortions,
},
note: "Kostnader bygger på kvitton, angivna priser och schablonpriser uppskattningar, inte bokföring.",
};
});
}
+165
View File
@@ -0,0 +1,165 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
/**
* Community & creators (spec §3538).
* Rankinglistor bakom feature flag; ALDRIG ranking på vikt/kalorier (spec §38).
*/
export async function communityRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/creators/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [stats] = await app.db
.select()
.from(schema.creatorStats)
.where(eq(schema.creatorStats.userId, id))
.limit(1);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, id))
.limit(1);
if (!user) throw errors.notFound("Profilen finns inte.");
if (stats?.visibility === "private" && id !== req.userId) {
throw errors.forbidden("Profilen är privat.");
}
const recipes = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
ratingAverage: schema.recipes.ratingAverage,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
imageUrls: schema.recipes.imageUrls,
})
.from(schema.recipes)
.where(and(eq(schema.recipes.creatorUserId, id), eq(schema.recipes.status, "published")))
.orderBy(desc(schema.recipes.cookCount))
.limit(30);
return {
userId: id,
displayName: user.displayName,
level: stats?.level ?? "beginner",
followers: stats?.followers ?? 0,
publishedRecipes: stats?.publishedRecipes ?? recipes.length,
totalCooks: stats?.totalCooks ?? 0,
averageRating: stats?.averageRating ?? null,
badges: stats?.badges ?? [],
recipes,
};
});
app.post("/v1/creators/:id/follow", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
if (id === req.userId) throw errors.badRequest("Du kan inte följa dig själv.");
await app.db
.insert(schema.creatorFollows)
.values({ followerUserId: req.userId, creatorUserId: id })
.onConflictDoNothing();
await app.db
.insert(schema.creatorStats)
.values({ userId: id, followers: 1 })
.onConflictDoUpdate({
target: schema.creatorStats.userId,
set: { followers: sql`${schema.creatorStats.followers} + 1`, updatedAt: new Date() },
});
return { ok: true };
});
app.delete("/v1/creators/:id/follow", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.delete(schema.creatorFollows)
.where(
and(
eq(schema.creatorFollows.followerUserId, req.userId),
eq(schema.creatorFollows.creatorUserId, id),
),
);
await app.db
.update(schema.creatorStats)
.set({ followers: sql`GREATEST(${schema.creatorStats.followers} - 1, 0)` })
.where(eq(schema.creatorStats.userId, id));
return { ok: true };
});
/** Topplistor (spec §38) kräver flagga + minst 5 betyg för att synas. */
app.get("/v1/rankings/:kind", auth, async (req) => {
if (!(await app.flags.isEnabled("creator_rankings", req.userId))) {
return { enabled: false, entries: [] };
}
const kind = (req.params as { kind: string }).kind;
const MIN_RATINGS = 5;
if (kind === "most-cooked") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.cookCount,
})
.from(schema.recipes)
.where(eq(schema.recipes.status, "published"))
.orderBy(desc(schema.recipes.cookCount))
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "top-rated") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.ratingAverage,
})
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${schema.recipes.ratingCount} >= ${MIN_RATINGS}`,
),
)
.orderBy(desc(schema.recipes.ratingAverage))
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "budget") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: schema.recipes.estimatedCostMinorPerPortion,
})
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${schema.recipes.estimatedCostMinorPerPortion} IS NOT NULL`,
),
)
.orderBy(schema.recipes.estimatedCostMinorPerPortion)
.limit(20);
return { enabled: true, kind, entries: rows };
}
if (kind === "protein") {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
value: sql<number>`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`,
})
.from(schema.recipes)
.where(eq(schema.recipes.status, "published"))
.orderBy(desc(sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float`))
.limit(20);
return { enabled: true, kind, entries: rows };
}
// Spec §38: ingen ranking på vikt, viktnedgång, kalorier eller BMI.
throw errors.badRequest("Okänd rankingtyp.");
});
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance } from "fastify";
import { sql } from "drizzle-orm";
import { BRAND } from "@app/shared-types";
/** Liveness/readiness för lastbalanserare och Docker healthchecks (spec §58). */
export async function healthRoutes(app: FastifyInstance) {
app.get("/healthz", { config: { rateLimit: false } }, async () => ({
ok: true,
service: `${BRAND.slug}-api`,
timestamp: new Date().toISOString(),
}));
app.get("/readyz", { config: { rateLimit: false } }, async (_req, reply) => {
try {
await app.db.execute(sql`SELECT 1`);
return { ok: true };
} catch {
return reply.status(503).send({ ok: false, reason: "database" });
}
});
/** Enkel endpointöversikt i stället för tung OpenAPI-generering (se beslutslogg D-011). */
app.get("/docs", { config: { rateLimit: false } }, async (_req, reply) => {
const routes = app.routeCatalog
.filter((r) => !r.url.startsWith("/v1/mock-s3"))
.sort((a, b) => a.url.localeCompare(b.url) || a.method.localeCompare(b.method));
const rows = routes
.map((r) => `<tr><td><code>${r.method}</code></td><td><code>${r.url}</code></td></tr>`)
.join("\n");
reply.header("content-type", "text/html; charset=utf-8");
return `<!doctype html><html lang="sv"><head><meta charset="utf-8"><title>API</title>
<style>body{font-family:system-ui;margin:2rem auto;max-width:820px;color:#1a202c;padding:0 1rem}
table{border-collapse:collapse;width:100%}td{padding:.35rem .75rem;border-bottom:1px solid #e2e8f0}
code{background:#f7fafc;padding:.1rem .3rem;border-radius:4px}</style></head>
<body><h1>${BRAND.name} API v1</h1>
<p>${routes.length} endpoints. Auth: <code>Authorization: Bearer &lt;accessToken&gt;</code>.
Fullständig referens: <code>docs/api-referens.md</code> i repot.</p>
<table>${rows}</table></body></html>`;
});
}
+230
View File
@@ -0,0 +1,230 @@
import type { FastifyInstance } from "fastify";
import { and, eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
createHouseholdInputSchema,
createStorageLocationInputSchema,
idParamSchema,
joinHouseholdInputSchema,
memberParamSchema,
updateHouseholdInputSchema,
updateMemberInputSchema,
updateStorageLocationInputSchema,
} from "@app/validation";
import { loadEntitlements } from "../lib/entitlements.js";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, generateInviteCode, requireMembership } from "../lib/helpers.js";
/** Hushåll (spec §7): delat lager/plan/lista, individuella mål och roller. */
export async function householdRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/households", auth, async (req) => {
const rows = await app.db
.select({
household: schema.households,
role: schema.householdMembers.role,
portionFactor: schema.householdMembers.portionFactor,
})
.from(schema.householdMembers)
.innerJoin(schema.households, eq(schema.householdMembers.householdId, schema.households.id))
.where(eq(schema.householdMembers.userId, req.userId));
return rows.map((r) => ({ ...r.household, myRole: r.role, myPortionFactor: r.portionFactor }));
});
app.post("/v1/households", auth, async (req, reply) => {
const input = parse(createHouseholdInputSchema, req.body);
const [household] = await app.db
.insert(schema.households)
.values({
name: input.name,
inviteCode: generateInviteCode(),
weeklyBudgetMinor: input.weeklyBudgetMinor ?? null,
...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),
})
.returning();
await app.db
.insert(schema.householdMembers)
.values({ householdId: household!.id, userId: req.userId, role: "owner" });
await app.db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
return reply.status(201).send(household);
});
app.get("/v1/households/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await requireMembership(app.db, id, req.userId);
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.id, id))
.limit(1);
if (!household) throw errors.notFound();
const members = await app.db
.select({
userId: schema.householdMembers.userId,
role: schema.householdMembers.role,
portionFactor: schema.householdMembers.portionFactor,
joinedAt: schema.householdMembers.joinedAt,
displayName: schema.users.displayName,
})
.from(schema.householdMembers)
.innerJoin(schema.users, eq(schema.householdMembers.userId, schema.users.id))
.where(eq(schema.householdMembers.householdId, id));
const locations = await app.db
.select()
.from(schema.storageLocations)
.where(eq(schema.storageLocations.householdId, id))
.orderBy(schema.storageLocations.sortOrder);
// OBS: individuella hälsomål/allergier exponeras INTE här (spec §7, §56).
return { ...household, members, storageLocations: locations };
});
app.patch("/v1/households/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
if (membership.role !== "owner" && membership.role !== "adult") {
throw errors.forbidden("Endast vuxna medlemmar kan ändra hushållet.");
}
const input = parse(updateHouseholdInputSchema, req.body);
const [row] = await app.db
.update(schema.households)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.households.id, id))
.returning();
return row;
});
app.post("/v1/households/join", auth, async (req) => {
const input = parse(joinHouseholdInputSchema, req.body);
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.inviteCode, input.inviteCode.toUpperCase()))
.limit(1);
if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden.");
// Kontrollera plangräns: max medlemmar styrs av ägarens plan (spec §45).
const [owner] = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, household.id),
eq(schema.householdMembers.role, "owner"),
),
)
.limit(1);
const members = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, household.id));
if (owner) {
const ent = await loadEntitlements(app.db, owner.userId);
if (members.length >= ent.maxHouseholdMembers) {
throw errors.paymentRequired(
`Hushållet har nått maxantalet medlemmar (${ent.maxHouseholdMembers}) för sin plan.`,
);
}
}
await app.db
.insert(schema.householdMembers)
.values({ householdId: household.id, userId: req.userId, role: "adult" })
.onConflictDoNothing();
await emitEvent(app.db, {
type: "HOUSEHOLD_MEMBER_ADDED",
payload: { householdId: household.id, newUserId: req.userId, role: "adult" },
userId: req.userId,
householdId: household.id,
correlationId: req.correlationId,
});
return { ok: true, household: { id: household.id, name: household.name } };
});
app.patch("/v1/households/:id/members/:userId", auth, async (req) => {
const { id, userId } = parse(memberParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
const isSelf = userId === req.userId;
if (!isSelf && membership.role !== "owner" && membership.role !== "adult") {
throw errors.forbidden("Endast vuxna kan ändra andra medlemmar.");
}
const input = parse(updateMemberInputSchema, req.body);
if (input.role && membership.role !== "owner") {
throw errors.forbidden("Endast ägaren kan ändra roller.");
}
const [row] = await app.db
.update(schema.householdMembers)
.set(input)
.where(
and(
eq(schema.householdMembers.householdId, id),
eq(schema.householdMembers.userId, userId),
),
)
.returning();
if (!row) throw errors.notFound("Medlemmen finns inte.");
return row;
});
app.delete("/v1/households/:id/members/:userId", auth, async (req) => {
const { id, userId } = parse(memberParamSchema, req.params);
const membership = await requireMembership(app.db, id, req.userId);
const isSelf = userId === req.userId;
if (!isSelf && membership.role !== "owner") {
throw errors.forbidden("Endast ägaren kan ta bort andra medlemmar.");
}
await app.db
.delete(schema.householdMembers)
.where(
and(
eq(schema.householdMembers.householdId, id),
eq(schema.householdMembers.userId, userId),
),
);
await audit(app.db, {
actorUserId: req.userId,
action: "household.member_removed",
targetType: "user",
targetId: userId,
});
return { ok: true };
});
// --- Förvaringsplatser (spec §8) ---
app.post("/v1/households/:id/storage-locations", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
await requireMembership(app.db, id, req.userId);
const input = parse(createStorageLocationInputSchema, req.body);
const [row] = await app.db
.insert(schema.storageLocations)
.values({ householdId: id, ...input })
.returning();
return reply.status(201).send(row);
});
app.patch("/v1/households/:id/storage-locations/:locationId", auth, async (req) => {
const params = req.params as { id: string; locationId: string };
await requireMembership(app.db, params.id, req.userId);
const input = parse(updateStorageLocationInputSchema, req.body);
const [row] = await app.db
.update(schema.storageLocations)
.set(input)
.where(
and(
eq(schema.storageLocations.id, params.locationId),
eq(schema.storageLocations.householdId, params.id),
),
)
.returning();
if (!row) throw errors.notFound();
return row;
});
}
+406
View File
@@ -0,0 +1,406 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, isNull, or } from "drizzle-orm";
import { schema } from "@app/database";
import {
createInventoryItemInputSchema,
idParamSchema,
inventoryQuerySchema,
inventoryTransactionInputSchema,
updateInventoryItemInputSchema,
} from "@app/validation";
import { classifyExpiry, findDuplicateCandidates, normalizeDelta } from "@app/inventory-engine";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
/**
* Food Twin lagret (spec §8). Transaktionsbaserat: varje förändring skrivs
* som inventory_transaction och saldot uppdateras atomiskt.
*/
export async function inventoryRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/inventory", auth, async (req) => {
const query = parse(inventoryQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const conditions = [
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
];
if (query.storageLocationId) {
conditions.push(eq(schema.inventoryItems.storageLocationId, query.storageLocationId));
}
if (query.search) {
const pattern = `%${query.search}%`;
conditions.push(
or(
ilike(schema.inventoryItems.displayName, pattern),
ilike(schema.inventoryItems.brand, pattern),
)!,
);
}
const rows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
locationName: schema.storageLocations.name,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(and(...conditions))
.orderBy(desc(schema.inventoryItems.updatedAt))
.limit(query.limit)
.offset(query.offset);
const items = rows.map((r) => {
const expiry = classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
});
return {
...r.item,
locationName: r.locationName,
locationType: r.locationType,
expiry,
};
});
const filtered = query.expiryStatus
? items.filter((i) => i.expiry.status === query.expiryStatus)
: items;
return { items: filtered };
});
/** Varor som bör användas snart driver "använd först" (spec §4.4, §40). */
app.get("/v1/inventory/expiring", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const rows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const withExpiry = rows
.map((r) => ({
...r.item,
expiry: classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}),
}))
.filter(
(i) =>
i.expiry.status === "expiring" ||
i.expiry.status === "use_soon" ||
i.expiry.status === "expired",
)
.sort((a, b) => (a.expiry.daysLeft ?? 99) - (b.expiry.daysLeft ?? 99));
return { items: withExpiry };
});
app.post("/v1/inventory/items", auth, async (req, reply) => {
const input = parse(createInventoryItemInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [location] = await app.db
.select()
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.id, input.storageLocationId),
eq(schema.storageLocations.householdId, householdId),
),
)
.limit(1);
if (!location) throw errors.badRequest("Förvaringsplatsen tillhör inte ditt hushåll.");
// Dubblettkontroll (spec §9) varna, blockera inte.
const existing = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
)
.limit(200);
const duplicates = findDuplicateCandidates(
{
canonicalIngredientId: input.canonicalIngredientId ?? null,
displayName: input.displayName,
brand: input.brand ?? null,
quantity: input.quantity,
source: input.source,
createdAt: new Date().toISOString(),
},
existing.map((e) => ({
id: e.id,
canonicalIngredientId: e.canonicalIngredientId,
displayName: e.displayName,
brand: e.brand,
quantity: e.quantity,
source: e.source,
createdAt: e.createdAt.toISOString(),
})),
);
const [item] = await app.db
.insert(schema.inventoryItems)
.values({
householdId,
canonicalIngredientId: input.canonicalIngredientId ?? null,
productId: input.productId ?? null,
displayName: input.displayName,
brand: input.brand ?? null,
quantity: input.quantity,
unit: input.unit,
storageLocationId: input.storageLocationId,
sublocation: input.sublocation ?? null,
purchasedAt: input.purchasedAt ?? null,
openedAt: input.openedAt ?? null,
bestBeforeDate: input.bestBeforeDate ?? null,
useByDate: input.useByDate ?? null,
dateKind: input.dateKind ?? null,
frozenAt: input.frozenAt ?? null,
priceMinor: input.priceMinor ?? null,
source: input.source,
confidence: input.source === "manual_search" || input.source === "free_text" ? 1 : 0.9,
verifiedByUser: true,
lastVerifiedAt: new Date(),
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: item!.id,
type: "purchase",
quantityDelta: input.quantity,
unit: input.unit,
refType: "manual",
actorUserId: req.userId,
valueMinor: input.priceMinor ?? null,
});
await emitEvent(app.db, {
type: "PRODUCT_ADDED",
payload: {
inventoryItemId: item!.id,
canonicalIngredientId: input.canonicalIngredientId ?? null,
quantity: input.quantity,
unit: input.unit,
source: input.source,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return reply.status(201).send({ item, duplicateCandidates: duplicates });
});
app.patch("/v1/inventory/items/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(updateInventoryItemInputSchema, req.body);
const item = await getOwnedItem(app, id, req.userId);
const { verifiedByUser, quantity, ...fields } = input;
const updates: Record<string, unknown> = { ...fields, updatedAt: new Date() };
if (verifiedByUser) {
updates.verifiedByUser = true;
updates.lastVerifiedAt = new Date();
}
// Mängdändring går ALLTID via transaktion (spec §8).
if (quantity != null && quantity !== item.quantity) {
const delta = quantity - item.quantity;
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: "adjust",
quantityDelta: delta,
unit: item.unit,
refType: "manual",
actorUserId: req.userId,
note: "Manuell justering",
});
updates.quantity = quantity;
}
const [row] = await app.db
.update(schema.inventoryItems)
.set(updates)
.where(eq(schema.inventoryItems.id, id))
.returning();
await emitEvent(app.db, {
type: "PRODUCT_UPDATED",
payload: { inventoryItemId: id, changes: updates },
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
return row;
});
/** Konsumera/släng/justera kärnan i transaktionsmodellen. */
app.post("/v1/inventory/items/:id/transactions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(inventoryTransactionInputSchema, req.body);
const item = await getOwnedItem(app, id, req.userId);
const delta = normalizeDelta(input.type, input.quantityDelta);
const newQuantity = Math.max(0, Math.round((item.quantity + delta) * 1000) / 1000);
const actualDelta = newQuantity - item.quantity;
const valueMinor =
input.type === "discard" && item.priceMinor != null && item.quantity > 0
? Math.round(Math.abs(actualDelta / item.quantity) * item.priceMinor * 100) / 100
: null;
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: input.type,
quantityDelta: actualDelta,
unit: item.unit,
refType: "manual",
actorUserId: req.userId,
note: input.note ?? null,
valueMinor,
});
const [updated] = await app.db
.update(schema.inventoryItems)
.set({
quantity: newQuantity,
depletedAt: newQuantity <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, id))
.returning();
const eventType =
input.type === "discard"
? "PRODUCT_DISCARDED"
: input.type === "consume"
? "PRODUCT_CONSUMED"
: null;
if (eventType === "PRODUCT_DISCARDED") {
await emitEvent(app.db, {
type: "PRODUCT_DISCARDED",
payload: {
inventoryItemId: id,
quantity: Math.abs(actualDelta),
unit: item.unit,
valueMinor,
reason: input.note ?? null,
},
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
} else if (eventType === "PRODUCT_CONSUMED") {
await emitEvent(app.db, {
type: "PRODUCT_CONSUMED",
payload: {
inventoryItemId: id,
quantity: Math.abs(actualDelta),
unit: item.unit,
refType: "manual",
},
userId: req.userId,
householdId: item.householdId,
correlationId: req.correlationId,
});
}
return { item: updated };
});
app.get("/v1/inventory/items/:id/transactions", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await getOwnedItem(app, id, req.userId);
const txs = await app.db
.select()
.from(schema.inventoryTransactions)
.where(eq(schema.inventoryTransactions.inventoryItemId, id))
.orderBy(desc(schema.inventoryTransactions.createdAt))
.limit(100);
return { transactions: txs };
});
app.delete("/v1/inventory/items/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const item = await getOwnedItem(app, id, req.userId);
// Radering = correction till 0 + arkivering (historiken bevaras, spec §8).
if (item.quantity > 0) {
await app.db.insert(schema.inventoryTransactions).values({
householdId: item.householdId,
inventoryItemId: id,
type: "correction",
quantityDelta: -item.quantity,
unit: item.unit,
actorUserId: req.userId,
note: "Post borttagen av användare",
});
}
await app.db
.update(schema.inventoryItems)
.set({ quantity: 0, depletedAt: new Date(), updatedAt: new Date() })
.where(eq(schema.inventoryItems.id, id));
return { ok: true };
});
}
async function getOwnedItem(app: FastifyInstance, itemId: string, userId: string) {
const [item] = await app.db
.select()
.from(schema.inventoryItems)
.where(eq(schema.inventoryItems.id, itemId))
.limit(1);
if (!item) throw errors.notFound("Lagerposten finns inte.");
await requireMembership(app.db, item.householdId, userId);
return item;
}
+336
View File
@@ -0,0 +1,336 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
consentInputSchema,
onboardingInputSchema,
updateHealthProfileInputSchema,
updateMeInputSchema,
updatePreferencesInputSchema,
} from "@app/validation";
import { computeDailyTargets, DEFAULT_TARGETS } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { audit, generateInviteCode, getActiveHouseholdId } from "../lib/helpers.js";
import { loadEntitlementsWithToken } from "../lib/entitlements.js";
/** Profil, preferenser, samtycken, dagsmål, GDPR-export/-radering (spec §6, §56). */
export async function meRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/me", auth, async (req) => {
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
if (!user) throw errors.notFound();
const householdId = await getActiveHouseholdId(app.db, req.userId);
return {
id: user.id,
email: user.email,
emailVerified: user.emailVerifiedAt != null,
displayName: user.displayName,
role: user.role,
locale: user.locale,
precisionMode: user.precisionMode,
onboardingCompleted: user.onboardingCompleted,
activeHouseholdId: householdId,
};
});
app.patch("/v1/me", auth, async (req) => {
const input = parse(updateMeInputSchema, req.body);
const [user] = await app.db
.update(schema.users)
.set({ ...input, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId))
.returning();
return {
id: user!.id,
displayName: user!.displayName,
locale: user!.locale,
precisionMode: user!.precisionMode,
};
});
// --- Hälsoprofil (separerad domän, spec §56) ---
app.get("/v1/me/health-profile", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
return profile ?? null;
});
app.patch("/v1/me/health-profile", auth, async (req) => {
const input = parse(updateHealthProfileInputSchema, req.body);
const [row] = await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Preferenser ---
app.get("/v1/me/preferences", auth, async (req) => {
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
return prefs ?? null;
});
app.patch("/v1/me/preferences", auth, async (req) => {
const input = parse(updatePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- Onboarding i ett svep (spec §6) ---
app.post("/v1/me/onboarding", auth, async (req) => {
const input = parse(onboardingInputSchema, req.body);
if (input.healthProfile) {
await app.db
.insert(schema.userHealthProfiles)
.values({ userId: req.userId, ...input.healthProfile })
.onConflictDoUpdate({
target: schema.userHealthProfiles.userId,
set: { ...input.healthProfile, updatedAt: new Date() },
});
}
if (input.preferences) {
await app.db
.insert(schema.userPreferences)
.values({ userId: req.userId, ...input.preferences })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { ...input.preferences, updatedAt: new Date() },
});
}
let householdId: string | null = await getActiveHouseholdId(app.db, req.userId);
if (input.householdChoice.kind === "create" && !householdId) {
const [household] = await app.db
.insert(schema.households)
.values({ name: input.householdChoice.name, inviteCode: generateInviteCode() })
.returning();
await app.db.insert(schema.householdMembers).values({
householdId: household!.id,
userId: req.userId,
role: "owner",
});
// Standardplatser: kyl, frys, skafferi (spec §8)
await app.db.insert(schema.storageLocations).values([
{ householdId: household!.id, type: "fridge", name: "Kylen", sortOrder: 0 },
{ householdId: household!.id, type: "freezer", name: "Frysen", sortOrder: 1 },
{ householdId: household!.id, type: "pantry", name: "Skafferiet", sortOrder: 2 },
]);
householdId = household!.id;
} else if (input.householdChoice.kind === "join") {
const [household] = await app.db
.select()
.from(schema.households)
.where(eq(schema.households.inviteCode, input.householdChoice.inviteCode.toUpperCase()))
.limit(1);
if (!household) throw errors.notFound("Ingen hushållsinbjudan matchar koden.");
await app.db
.insert(schema.householdMembers)
.values({ householdId: household.id, userId: req.userId, role: "adult" })
.onConflictDoNothing();
householdId = household.id;
}
await app.db
.update(schema.users)
.set({ precisionMode: input.precisionMode, onboardingCompleted: true, updatedAt: new Date() })
.where(eq(schema.users.id, req.userId));
return { ok: true, householdId };
});
// --- Samtycken (spec §33: separata) ---
app.get("/v1/me/consents", auth, async (req) => {
return app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, req.userId));
});
app.put("/v1/me/consents", auth, async (req) => {
const input = parse(consentInputSchema, req.body);
const now = new Date();
const [row] = await app.db
.insert(schema.userConsents)
.values({
userId: req.userId,
kind: input.kind,
status: input.granted ? "granted" : "denied",
grantedAt: input.granted ? now : null,
revokedAt: input.granted ? null : now,
})
.onConflictDoUpdate({
target: [schema.userConsents.userId, schema.userConsents.kind],
set: {
status: input.granted ? "granted" : "revoked",
...(input.granted ? { grantedAt: now, revokedAt: null } : { revokedAt: now }),
updatedAt: now,
},
})
.returning();
await audit(app.db, {
actorUserId: req.userId,
action: `consent.${input.granted ? "granted" : "revoked"}`,
targetType: "consent",
targetId: input.kind,
});
return row;
});
// --- Dagsmål: beräknas deterministiskt, med transparent grund (spec §21) ---
app.get("/v1/me/daily-targets", auth, async (req) => {
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
if (!profile?.weightKg || !profile.heightCm || !profile.birthYear) {
return {
targets: DEFAULT_TARGETS,
basis: null,
note: "Schablonmål fyll i längd, vikt och födelseår för personliga mål.",
};
}
const result = computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: new Date().getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: prefs?.primaryGoal ?? undefined,
});
return {
...result,
note: "Uppskattning enligt MifflinSt Jeor. Appen är inte medicinsk rådgivning.",
};
});
// --- Entitlements (spec §47) ---
app.get("/v1/me/entitlements", auth, async (req) => {
return loadEntitlementsWithToken(app, req.userId);
});
// --- Locale-preferenser (i18n-spec §6): språk ≠ region ≠ enheter ---
app.get("/v1/me/locale-preferences", auth, async (req) => {
const { loadLocalePreferences } = await import("../lib/localeContext.js");
return loadLocalePreferences(app.db, req.userId);
});
app.patch("/v1/me/locale-preferences", auth, async (req) => {
const { updateLocalePreferencesInputSchema } = await import("@app/validation");
const input = parse(updateLocalePreferencesInputSchema, req.body);
const [row] = await app.db
.insert(schema.userLocalePreferences)
.values({ userId: req.userId, ...input })
.onConflictDoUpdate({
target: schema.userLocalePreferences.userId,
set: { ...input, updatedAt: new Date() },
})
.returning();
return row;
});
// --- GDPR: export (spec §56) ---
app.get("/v1/me/export", auth, async (req) => {
const userId = req.userId;
const [user] = await app.db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.limit(1);
const [health] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const consents = await app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const meals = await app.db.select().from(schema.meals).where(eq(schema.meals.userId, userId));
const memory = await app.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const ratings = await app.db
.select()
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.export", ip: req.ip });
return {
exportedAt: new Date().toISOString(),
user,
healthProfile: health ?? null,
preferences: prefs ?? null,
consents,
meals,
memory,
ratings,
};
});
// --- GDPR: radera konto (spec §56, §32) ---
app.delete("/v1/me", auth, async (req) => {
const userId = req.userId;
// Hård radering av persondata via FK-cascade; users-raden anonymiseras
// och soft-deletas för att bevara referensintegritet i aggregat.
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId));
await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId));
await app.db
.delete(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId));
await app.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await app.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, userId));
await app.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, userId));
await app.db
.update(schema.users)
.set({
email: `deleted-${userId}@anonymized.invalid`,
displayName: "Raderad användare",
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.users.id, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip });
return {
ok: true,
message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.",
};
});
}
+334
View File
@@ -0,0 +1,334 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm";
import { schema } from "@app/database";
import {
consumeMealBoxInputSchema,
createMealBoxInputSchema,
dayQuerySchema,
idParamSchema,
logMealInputSchema,
} from "@app/validation";
import {
computeItemNutrition,
DEFAULT_TARGETS,
computeDailyTargets,
scaleNutrition,
summarizeDay,
sumNutrition,
} from "@app/nutrition-engine";
import { EMPTY_NUTRITION, type NutritionValues } from "@app/shared-types";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
/**
* Måltidsloggning + "Min dag" (spec §4.3, §23) och matlådor (spec §24).
* Näringsvärden härleds deterministiskt aldrig av AI (spec §61.1).
*/
export async function mealRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/meals", auth, async (req, reply) => {
const input = parse(logMealInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId).catch(() => null);
let nutrition: NutritionValues;
let isEstimate = false;
let estimateMin: number | null = null;
let estimateMax: number | null = null;
if (input.nutritionOverride) {
// Användarens egen inmatning eller bekräftat foto-intervall.
nutrition = { ...EMPTY_NUTRITION, ...input.nutritionOverride };
isEstimate = input.source === "plate_photo";
} else if (input.recipeId) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, input.recipeId))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
nutrition = scaleNutrition(recipe.nutritionPerPortion, input.portionFraction);
} else if (input.items.length > 0) {
const parts: NutritionValues[] = [];
for (const item of input.items) {
if (item.nutrition) {
parts.push({ ...EMPTY_NUTRITION, ...item.nutrition });
continue;
}
if (item.canonicalIngredientId && item.quantity != null && item.unit) {
const [ing] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, item.canonicalIngredientId))
.limit(1);
if (ing) {
const computed = computeItemNutrition(item.quantity, item.unit, ing.nutritionPer100, {
densityGPerMl: ing.densityGPerMl,
gramsPerPiece: ing.gramsPerPiece,
});
if (computed) {
parts.push(computed);
continue;
}
}
}
throw errors.badRequest(
`"${item.displayName}" saknar näringsdata. Ange mängd + känd ingrediens, eller egna värden.`,
);
}
nutrition = sumNutrition(parts);
} else if (input.scanJobId) {
// Tallriksfoto: intervall från AAMOS som användaren bekräftar (spec §22).
const [job] = await app.db
.select()
.from(schema.scanJobs)
.where(and(eq(schema.scanJobs.id, input.scanJobId), eq(schema.scanJobs.userId, req.userId)))
.limit(1);
if (!job?.result) throw errors.badRequest("Skanningen har inget resultat.");
const result = job.result as { kcalRange?: { min: number; max: number; mostLikely: number } };
if (!result.kcalRange) throw errors.badRequest("Skanningen saknar kaloriuppskattning.");
nutrition = { ...EMPTY_NUTRITION, kcal: result.kcalRange.mostLikely };
isEstimate = true;
estimateMin = result.kcalRange.min;
estimateMax = result.kcalRange.max;
} else {
throw errors.badRequest("Ange recept, livsmedel, skanning eller egna näringsvärden.");
}
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: req.userId,
householdId,
date: input.date,
mealType: input.mealType,
source: input.source,
recipeId: input.recipeId ?? null,
titleSv: input.titleSv,
portionFraction: input.portionFraction,
nutrition,
nutritionIsEstimate: isEstimate,
estimateMinKcal: estimateMin,
estimateMaxKcal: estimateMax,
items: input.items.length > 0 ? input.items : null,
scanJobId: input.scanJobId ?? null,
})
.returning();
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: {
mealId: meal!.id,
mealType: input.mealType,
kcal: nutrition.kcal,
source: input.source,
},
userId: req.userId,
householdId: householdId ?? undefined,
correlationId: req.correlationId,
});
return reply.status(201).send(meal);
});
/** "Min dag" (spec §4.3): måltider + summering mot personliga mål. */
app.get("/v1/meals/day", auth, async (req) => {
const { date } = parse(dayQuerySchema, req.query);
const meals = await app.db
.select()
.from(schema.meals)
.where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, date)))
.orderBy(schema.meals.loggedAt);
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
const targets =
profile?.weightKg && profile.heightCm && profile.birthYear
? computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: new Date().getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: prefs?.primaryGoal ?? undefined,
}).targets
: DEFAULT_TARGETS;
const summary = summarizeDay(
meals.map((m) => m.nutrition),
targets,
);
const hasEstimates = meals.some((m) => m.nutritionIsEstimate);
return {
date,
meals,
summary,
note: hasEstimates
? "Dagen innehåller uppskattade värden från foto justera gärna vid behov."
: "Värdena är beräknade ur recept och livsmedelsdata och visas som uppskattningar.",
};
});
app.delete("/v1/meals/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [meal] = await app.db
.select()
.from(schema.meals)
.where(and(eq(schema.meals.id, id), eq(schema.meals.userId, req.userId)))
.limit(1);
if (!meal) throw errors.notFound();
await app.db.delete(schema.meals).where(eq(schema.meals.id, id));
return { ok: true };
});
// ---------------------------------------------------------------------
// Matlådor (spec §24)
// ---------------------------------------------------------------------
app.get("/v1/meal-boxes", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const boxes = await app.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy);
return { mealBoxes: boxes };
});
app.post("/v1/meal-boxes", auth, async (req, reply) => {
const input = parse(createMealBoxInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
let nutritionPerPortion = null;
if (input.recipeId) {
const [recipe] = await app.db
.select({ nutrition: schema.recipes.nutritionPerPortion })
.from(schema.recipes)
.where(eq(schema.recipes.id, input.recipeId))
.limit(1);
nutritionPerPortion = recipe?.nutrition ?? null;
}
const cookedAt = input.cookedAt ?? todayIso();
const useByDays = input.frozen ? 90 : 3;
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: input.recipeId ?? null,
titleSv: input.titleSv,
portions: input.portions,
portionsRemaining: input.portions,
nutritionPerPortion,
cookedAt,
storageLocationId: input.storageLocationId,
frozen: input.frozen,
recommendedUseBy: new Date(Date.parse(cookedAt) + useByDays * 86_400_000)
.toISOString()
.slice(0, 10),
reservedForUserId: input.reservedForUserId ?? null,
})
.returning();
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.portions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return reply.status(201).send(box);
});
app.post("/v1/meal-boxes/:id/consume", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(consumeMealBoxInputSchema, req.body);
const [box] = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.id, id))
.limit(1);
if (!box) throw errors.notFound("Matlådan finns inte.");
await requireMembership(app.db, box.householdId, req.userId);
if (box.portionsRemaining < input.portions) {
throw errors.conflict(`Bara ${box.portionsRemaining} portioner kvar.`);
}
const remaining = box.portionsRemaining - input.portions;
await app.db
.update(schema.mealBoxes)
.set({ portionsRemaining: remaining, status: remaining <= 0 ? "consumed" : box.status })
.where(eq(schema.mealBoxes.id, id));
let mealId: string | null = null;
if (input.logAsMeal && box.nutritionPerPortion) {
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: req.userId,
householdId: box.householdId,
date: input.date ?? todayIso(),
mealType: input.mealType,
source: "meal_box",
recipeId: box.recipeId,
titleSv: box.titleSv,
portionFraction: input.portions,
nutrition: scaleNutrition(box.nutritionPerPortion, input.portions),
nutritionIsEstimate: false,
})
.returning();
mealId = meal!.id;
}
await emitEvent(app.db, {
type: "MEAL_BOX_CONSUMED",
payload: { mealBoxId: id, portions: input.portions },
userId: req.userId,
householdId: box.householdId,
correlationId: req.correlationId,
});
return { ok: true, portionsRemaining: remaining, mealId };
});
app.post("/v1/meal-boxes/:id/discard", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [box] = await app.db
.select()
.from(schema.mealBoxes)
.where(eq(schema.mealBoxes.id, id))
.limit(1);
if (!box) throw errors.notFound();
await requireMembership(app.db, box.householdId, req.userId);
await app.db
.update(schema.mealBoxes)
.set({ status: "discarded", portionsRemaining: 0 })
.where(eq(schema.mealBoxes.id, id));
return { ok: true };
});
/** Måltidshistorik ("tidigare måltid" som loggkälla, spec §23). */
app.get("/v1/meals/recent", auth, async (req) => {
const meals = await app.db
.select()
.from(schema.meals)
.where(eq(schema.meals.userId, req.userId))
.orderBy(desc(schema.meals.loggedAt))
.limit(20);
return { meals };
});
}
+143
View File
@@ -0,0 +1,143 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, or } from "drizzle-orm";
import { schema } from "@app/database";
import { buildMemoryOverview } from "@app/memory-client";
import { userLanguageTag } from "../lib/contentLanguage.js";
import { idParamSchema, memoryQuerySchema, updateMemoryItemInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
/**
* "Vad plattformen vet om mig" (spec §32): full transparens.
* Användaren kan korrigera, pausa, radera varje post och radera allt.
*/
export async function memoryRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/me/memory", auth, async (req) => {
const q = parse(memoryQuerySchema, req.query);
const householdId = await getActiveHouseholdId(app.db, req.userId);
const scope = householdId
? or(
eq(schema.memoryItems.userId, req.userId),
eq(schema.memoryItems.householdId, householdId),
)!
: eq(schema.memoryItems.userId, req.userId);
const conditions = [scope];
if (q.kind) conditions.push(eq(schema.memoryItems.kind, q.kind));
if (!q.includePaused) conditions.push(eq(schema.memoryItems.paused, false));
const items = await app.db
.select()
.from(schema.memoryItems)
.where(and(...conditions))
.orderBy(desc(schema.memoryItems.updatedAt))
.limit(q.limit)
.offset(q.offset);
const overview = buildMemoryOverview(
items.map((i) => ({
id: i.id,
userId: i.userId ?? undefined,
householdId: i.householdId ?? undefined,
kind: i.kind,
key: i.key,
summarySv: i.summarySv,
value: i.value,
origin: i.origin,
confidence: i.confidence,
verifiedByUser: i.verifiedByUser,
paused: i.paused,
createdAt: i.createdAt.toISOString(),
updatedAt: i.updatedAt.toISOString(),
lastUsedAt: i.lastUsedAt?.toISOString(),
expiresAt: i.expiresAt?.toISOString(),
})),
await userLanguageTag(app.db, req.userId),
);
return overview;
});
app.patch("/v1/me/memory/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(updateMemoryItemInputSchema, req.body);
const item = await getOwnedMemory(app, id, req.userId);
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (input.summarySv != null) updates.summarySv = input.summarySv;
if (input.value !== undefined) updates.value = input.value;
if (input.paused != null) updates.paused = input.paused;
if (input.verified != null || input.summarySv != null || input.value !== undefined) {
// Användarkorrigering gör posten verifierad och användarägd (spec §30).
updates.verifiedByUser = true;
updates.origin = "user_stated";
updates.confidence = 1;
}
const [row] = await app.db
.update(schema.memoryItems)
.set(updates)
.where(eq(schema.memoryItems.id, item.id))
.returning();
await emitEvent(app.db, {
type: "MEMORY_UPDATED",
payload: { memoryItemId: id, kind: item.kind, origin: "user_stated" },
userId: req.userId,
correlationId: req.correlationId,
});
return row;
});
app.delete("/v1/me/memory/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const item = await getOwnedMemory(app, id, req.userId);
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.id, item.id));
await audit(app.db, {
actorUserId: req.userId,
action: "memory.deleted",
targetType: "memory_item",
targetId: id,
});
return { ok: true };
});
/** Radera ALLT personligt minne (spec §32: "radera"). */
app.delete("/v1/me/memory", auth, async (req) => {
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, req.userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, req.userId));
await audit(app.db, { actorUserId: req.userId, action: "memory.deleted_all" });
return { ok: true, message: "Allt personligt minne är raderat." };
});
/** Pausa allt minne (spec §32: "pausa"). */
app.post("/v1/me/memory/pause-all", auth, async (req) => {
const body = (req.body ?? {}) as { paused?: boolean };
const paused = body.paused ?? true;
await app.db
.update(schema.memoryItems)
.set({ paused, updatedAt: new Date() })
.where(eq(schema.memoryItems.userId, req.userId));
await audit(app.db, {
actorUserId: req.userId,
action: paused ? "memory.paused_all" : "memory.resumed_all",
});
return { ok: true, paused };
});
}
async function getOwnedMemory(app: FastifyInstance, id: string, userId: string) {
const [item] = await app.db
.select()
.from(schema.memoryItems)
.where(eq(schema.memoryItems.id, id))
.limit(1);
if (!item) throw errors.notFound("Minnesposten finns inte.");
if (item.userId && item.userId !== userId) throw errors.forbidden();
if (!item.userId && item.householdId) {
const { requireMembership } = await import("../lib/helpers.js");
await requireMembership(app.db, item.householdId, userId);
}
return item;
}
+165
View File
@@ -0,0 +1,165 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
generateWeekPlanInputSchema,
idParamSchema,
updatePlanEntryInputSchema,
weekPlanQuerySchema,
} from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership } from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
/**
* Veckoplanering (spec §25). Planen genereras asynkront av workern
* (GENERATE_WEEK_PLAN) som väger lager, utgångsdatum, matlådor, budget,
* variation och mål med deterministisk kärna och AAMOS som rådgivare.
*/
export async function planningRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/week-plans", auth, async (req) => {
const q = parse(weekPlanQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const conditions = [eq(schema.weekPlans.householdId, householdId)];
if (q.weekStartDate) conditions.push(eq(schema.weekPlans.weekStartDate, q.weekStartDate));
const plans = await app.db
.select()
.from(schema.weekPlans)
.where(and(...conditions))
.orderBy(desc(schema.weekPlans.weekStartDate))
.limit(8);
const result = [];
for (const plan of plans) {
const entries = await app.db
.select()
.from(schema.weekPlanEntries)
.where(eq(schema.weekPlanEntries.weekPlanId, plan.id))
.orderBy(schema.weekPlanEntries.date, schema.weekPlanEntries.sortOrder);
result.push({ ...plan, entries });
}
return { plans: result };
});
app.post("/v1/week-plans/generate", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "weekPlanning", "Veckoplanering");
const input = parse(generateWeekPlanInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [plan] = await app.db
.insert(schema.weekPlans)
.values({
householdId,
weekStartDate: input.weekStartDate,
status: "draft",
generatedBy: "engine",
notes: input.noteSv ?? null,
})
.returning();
await app.jobQueue.add("GENERATE_WEEK_PLAN", {
jobType: "GENERATE_WEEK_PLAN",
weekPlanId: plan!.id,
householdId,
userId: req.userId,
input,
correlationId: req.correlationId,
});
return reply.status(202).send({
plan,
message: "Planen genereras hämta den om en stund via GET /v1/week-plans.",
});
});
app.patch("/v1/week-plans/:id/entries/:entryId", auth, async (req) => {
const params = req.params as { id: string; entryId: string };
const [plan] = await app.db
.select()
.from(schema.weekPlans)
.where(eq(schema.weekPlans.id, params.id))
.limit(1);
if (!plan) throw errors.notFound("Planen finns inte.");
await requireMembership(app.db, plan.householdId, req.userId);
const input = parse(updatePlanEntryInputSchema, req.body);
const updates: Record<string, unknown> = { ...input };
// Dynamisk omplanering med förklaring (spec §25)
if (input.status === "skipped") {
const [entry] = await app.db
.select()
.from(schema.weekPlanEntries)
.where(eq(schema.weekPlanEntries.id, params.entryId))
.limit(1);
if (entry?.recipeId) {
// Flytta rätten till nästa lediga dag om råvaror bör användas.
const later = await app.db
.select()
.from(schema.weekPlanEntries)
.where(
and(
eq(schema.weekPlanEntries.weekPlanId, params.id),
gte(schema.weekPlanEntries.date, entry.date),
eq(schema.weekPlanEntries.status, "planned"),
sql`${schema.weekPlanEntries.id} <> ${params.entryId}`,
),
)
.orderBy(schema.weekPlanEntries.date)
.limit(1);
if (later[0]) {
await app.db
.update(schema.weekPlanEntries)
.set({
recipeId: entry.recipeId,
titleSv: entry.titleSv,
status: "moved",
rescheduleReasonSv: `${entry.titleSv} flyttades hit eftersom råvarorna bör användas först.`,
})
.where(eq(schema.weekPlanEntries.id, later[0].id));
}
}
}
const [row] = await app.db
.update(schema.weekPlanEntries)
.set(updates)
.where(
and(
eq(schema.weekPlanEntries.id, params.entryId),
eq(schema.weekPlanEntries.weekPlanId, params.id),
),
)
.returning();
if (!row) throw errors.notFound("Planposten finns inte.");
await emitEvent(app.db, {
type: "WEEK_PLAN_UPDATED",
payload: { weekPlanId: params.id, reason: input.status ?? null },
userId: req.userId,
householdId: plan.householdId,
correlationId: req.correlationId,
});
return row;
});
app.post("/v1/week-plans/:id/activate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const [plan] = await app.db
.select()
.from(schema.weekPlans)
.where(eq(schema.weekPlans.id, id))
.limit(1);
if (!plan) throw errors.notFound();
await requireMembership(app.db, plan.householdId, req.userId);
const [row] = await app.db
.update(schema.weekPlans)
.set({ status: "active", updatedAt: new Date() })
.where(eq(schema.weekPlans.id, id))
.returning();
return row;
});
}
+1031
View File
@@ -0,0 +1,1031 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
cookRecipeInputSchema,
createUserRecipeInputSchema,
idParamSchema,
rateRecipeInputSchema,
recipeQuerySchema,
substitutionQuerySchema,
} from "@app/validation";
import {
checkRecipeSafety,
deriveRecipeAllergens,
scaleIngredients,
type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import {
languageCandidates,
resolveIngredientNames,
resolveRecipeTranslation,
userLanguageTag,
} from "../lib/contentLanguage.js";
import {
emitEvent,
getActiveHouseholdId,
requireActiveHousehold,
todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recipes", auth, async (req) => {
const q = parse(recipeQuerySchema, req.query);
const conditions = [eq(schema.recipes.status, "published")];
if (q.search) {
conditions.push(
or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
)!,
);
}
if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine));
if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`);
if (q.tags)
for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`);
if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`);
if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes));
if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty));
if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId));
if (q.maxKcalPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`,
);
}
if (q.minProteinPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`,
);
}
if (q.maxCostMinorPerPortion) {
conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion));
}
// Deterministisk allergifiltrering på databasnivå (spec §61.2)
if (q.excludeAllergens) {
for (const allergen of q.excludeAllergens) {
conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`);
}
}
const orderBy =
q.sort === "rating"
? desc(schema.recipes.ratingAverage)
: q.sort === "cooked"
? desc(schema.recipes.cookCount)
: q.sort === "newest"
? desc(schema.recipes.createdAt)
: q.sort === "time"
? schema.recipes.totalTimeMinutes
: q.sort === "cost"
? schema.recipes.estimatedCostMinorPerPortion
: desc(schema.recipes.cookCount);
const rows = await app.db
.select({
id: schema.recipes.id,
slug: schema.recipes.slug,
titleSv: schema.recipes.titleSv,
descriptionSv: schema.recipes.descriptionSv,
cuisine: schema.recipes.cuisine,
mealTypes: schema.recipes.mealTypes,
tags: schema.recipes.tags,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
portions: schema.recipes.portions,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
allergens: schema.recipes.allergens,
spiceLevel: schema.recipes.spiceLevel,
estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion,
costCurrency: sql<string>`'SEK'`,
difficulty: schema.recipes.difficulty,
imageUrls: schema.recipes.imageUrls,
ratingAverage: schema.recipes.ratingAverage,
ratingCount: schema.recipes.ratingCount,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
creatorDisplayName: schema.recipes.creatorDisplayName,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(and(...conditions))
.orderBy(orderBy)
.limit(q.limit)
.offset(q.offset);
// Titlar på användarens språk där publicerad översättning finns (i18n M3).
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
let titleMap = new Map<string, string>();
if (!candidates.includes("sv") && rows.length > 0) {
const translations = await app.db
.select({
recipeId: schema.recipeTranslations.recipeId,
languageTag: schema.recipeTranslations.languageTag,
title: schema.recipeTranslations.title,
})
.from(schema.recipeTranslations)
.where(
and(
inArray(
schema.recipeTranslations.recipeId,
rows.map((r) => r.id),
),
inArray(schema.recipeTranslations.languageTag, candidates),
eq(schema.recipeTranslations.status, "published"),
),
);
for (const candidate of [...candidates].reverse()) {
for (const tr of translations)
if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title);
}
}
return {
recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
app.get("/v1/recipes/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const recipe = await loadFullRecipe(app, id);
// Personlig säkerhetskontroll deterministisk (spec §61.2).
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] };
if (prefs) {
const info = await ingredientSafetyMap(
app,
recipe.ingredients.map((i) => i.canonicalIngredientId),
);
const violations = checkRecipeSafety(
{
ingredients: recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: prefs.allergens,
dietPattern: prefs.dietPattern,
religiousRule: prefs.religiousRule,
avoidIngredientIds: prefs.avoidIngredientIds,
spiceLevelMax: prefs.spiceLevelMax,
},
info,
);
safety = { safe: !violations.some((v) => v.severity === "blocker"), violations };
}
// Varianter (spec §16)
const variants = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(
and(
or(
eq(schema.recipes.variantOfRecipeId, id),
recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`,
),
eq(schema.recipes.status, "published"),
),
);
const [myRating] = await app.db
.select()
.from(schema.recipeRatings)
.where(
and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)),
)
.limit(1);
const [favorite] = await app.db
.select()
.from(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
)
.limit(1);
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
app.db,
recipe.ingredients.map((i) => i.canonicalIngredientId),
languageTag,
);
return {
...recipe,
language: translation?.language ?? "sv",
title: translation?.title ?? recipe.titleSv,
description: translation ? translation.description : recipe.descriptionSv,
storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv,
ingredients: recipe.ingredients.map((i) => ({
...i,
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
})),
steps: recipe.steps.map((s) => {
const ts = translation?.steps.get(s.stepNumber);
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
}),
safety,
variants,
myRating: myRating ?? null,
isFavorite: Boolean(favorite),
};
});
/** Skala recept (Cooking Mode: "skala till sex personer"). */
app.get("/v1/recipes/:id/scaled", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const portions = Number((req.query as { portions?: string }).portions ?? 4);
if (!Number.isInteger(portions) || portions < 1 || portions > 24) {
throw errors.badRequest("portions måste vara 124.");
}
const recipe = await loadFullRecipe(app, id);
const scaled = scaleIngredients(
recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
recipe.portions,
portions,
);
return {
recipeId: id,
portions,
ingredients: scaled,
nutritionPerPortion: recipe.nutritionPerPortion,
};
});
app.post("/v1/recipes/:id/rate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(rateRecipeInputSchema, req.body);
await loadFullRecipe(app, id);
await app.db
.insert(schema.recipeRatings)
.values({
recipeId: id,
userId: req.userId,
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
})
.onConflictDoUpdate({
target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId],
set: {
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
updatedAt: new Date(),
},
});
// Uppdatera aggregat
const [agg] = await app.db
.select({
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
count: sql<number>`count(*)`,
})
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.recipeId, id));
await app.db
.update(schema.recipes)
.set({
ratingAverage: agg ? Number(agg.avg) : null,
ratingCount: agg ? Number(agg.count) : 0,
})
.where(eq(schema.recipes.id, id));
// Smaksignaler ur feedback (spec §30) explicit användarsignal.
const tagToAxis: Record<
string,
{ axis: "spice" | "salt" | "acid" | "creaminess"; dir: number }
> = {
too_spicy: { axis: "spice", dir: -1 },
too_mild: { axis: "spice", dir: 1 },
too_salty: { axis: "salt", dir: -1 },
too_sour: { axis: "acid", dir: -1 },
too_little_sauce: { axis: "creaminess", dir: 1 },
};
for (const tag of input.feedbackTags) {
const mapping = tagToAxis[tag];
if (mapping) {
await app.db.insert(schema.tasteSignals).values({
userId: req.userId,
axis: mapping.axis,
direction: mapping.dir,
strength: 0.7,
origin: "user_stated",
refRecipeId: id,
});
}
}
await emitEvent(app.db, {
type: "RECIPE_RATED",
payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags },
userId: req.userId,
correlationId: req.correlationId,
});
return { ok: true };
});
app.post("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.insert(schema.recipeFavorites)
.values({ recipeId: id, userId: req.userId })
.onConflictDoNothing();
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.delete("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.delete(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
);
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.get("/v1/recipes/favorites/mine", auth, async (req) => {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
imageUrls: schema.recipes.imageUrls,
})
.from(schema.recipeFavorites)
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
.where(eq(schema.recipeFavorites.userId, req.userId))
.orderBy(desc(schema.recipeFavorites.createdAt));
return { recipes: rows };
});
/**
* "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO),
* måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen.
*/
app.post("/v1/recipes/:id/cook", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookRecipeInputSchema, req.body);
const recipe = await loadFullRecipe(app, id);
const householdId = await requireActiveHousehold(app.db, req.userId);
const date = input.date ?? todayIso();
// 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum)
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory) {
const factor = input.portionsCooked / recipe.portions;
const overrides = new Map(input.inventoryOverrides.map((o) => [o.canonicalIngredientId, o]));
for (const ing of recipe.ingredients) {
if (ing.optional) continue;
const override = overrides.get(ing.canonicalIngredientId);
const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
const requiredUnit = override ? override.unit : ing.unit;
if (requiredQty <= 0) continue;
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
if (stock.length === 0) continue;
const [info] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
.limit(1);
const allocation = allocateFefo(
requiredQty,
requiredUnit,
stock.map((s) => ({
id: s.id,
canonicalIngredientId: s.canonicalIngredientId,
quantity: s.quantity,
unit: s.unit,
bestBeforeDate: s.bestBeforeDate,
useByDate: s.useByDate,
openedAt: s.openedAt,
frozenAt: s.frozenAt,
thawedAt: s.thawedAt,
purchasedAt: s.purchasedAt,
})),
{ densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
);
for (const alloc of allocation.allocations) {
const item = stock.find((s) => s.id === alloc.itemId)!;
const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: alloc.itemId,
type: "cook_use",
quantityDelta: -(item.quantity - newQty),
unit: item.unit,
refType: "recipe_cook",
refId: id,
actorUserId: req.userId,
});
await app.db
.update(schema.inventoryItems)
.set({
quantity: newQty,
depletedAt: newQty <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, alloc.itemId));
deductions.push({
itemId: alloc.itemId,
quantity: alloc.quantity,
unit: alloc.unit,
name: item.displayName,
});
}
}
}
// 2. Logga måltid per ätare med portionsandel (spec §7: individuellt)
const eaters =
input.eaters.length > 0 ? input.eaters : [{ userId: req.userId, portionFraction: 1 }];
const mealIds: string[] = [];
for (const eater of eaters) {
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: eater.userId,
householdId,
date,
mealType: input.mealType,
source: "cooked_recipe",
recipeId: id,
titleSv: recipe.titleSv,
portionFraction: eater.portionFraction,
nutrition,
nutritionIsEstimate: false,
})
.returning();
mealIds.push(meal!.id);
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: {
mealId: meal!.id,
mealType: input.mealType,
kcal: nutrition.kcal,
source: "cooked_recipe",
},
userId: eater.userId,
householdId,
correlationId: req.correlationId,
});
}
// 3. Matlådor (spec §24)
let mealBoxId: string | null = null;
if (input.mealBoxPortions > 0) {
const locationId =
input.mealBoxStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, householdId),
eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
const useByDays = input.mealBoxFrozen ? 90 : 3;
const recommendedUseBy = new Date(Date.now() + useByDays * 86_400_000)
.toISOString()
.slice(0, 10);
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: id,
titleSv: recipe.titleSv,
portions: input.mealBoxPortions,
portionsRemaining: input.mealBoxPortions,
nutritionPerPortion: recipe.nutritionPerPortion,
cookedAt: date,
storageLocationId: locationId,
frozen: input.mealBoxFrozen,
recommendedUseBy,
})
.returning();
mealBoxId = box!.id;
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.mealBoxPortions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
}
// 4. Statistik + event
await app.db.insert(schema.recipeCooks).values({
recipeId: id,
userId: req.userId,
householdId,
portionsCooked: input.portionsCooked,
});
await app.db
.update(schema.recipes)
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
.where(eq(schema.recipes.id, id));
await emitEvent(app.db, {
type: "RECIPE_COOKED",
payload: {
recipeId: id,
portions: input.portionsCooked,
mealBoxPortions: input.mealBoxPortions,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
});
/** Substitutionsförslag för en ingrediens (spec §20). */
app.get("/v1/substitutions", auth, async (req) => {
const q = parse(substitutionQuerySchema, req.query);
const subs = await app.db
.select({
sub: schema.substitutions,
toName: schema.canonicalIngredients.nameSv,
})
.from(schema.substitutions)
.innerJoin(
schema.canonicalIngredients,
eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id),
)
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
.orderBy(desc(schema.substitutions.priority));
return {
substitutions: subs.map((s) => ({
...s.sub,
toNameSv: s.toName,
contextWarning:
q.context && s.sub.notRecommendedFor.includes(q.context)
? `Rekommenderas inte för ${q.context}.`
: null,
})),
};
});
/**
* Ingrediens-sök (manuell registrering, spec §9; i18n M2).
* Söker i svenska namn/alias OCH i publicerade översättningar för
* användarens språk; svaret bär namn upplöst till användarens språk.
*/
app.get("/v1/ingredients", auth, async (req) => {
const search = String((req.query as { search?: string }).search ?? "").trim();
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
// i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm).
// "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar".
const pattern = `%${search}%`;
const translationMatch = sql`EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = ${schema.canonicalIngredients.id}
AND it.status = 'published'
AND it.language_tag IN ${candidates}
AND (unaccent(it.name) ILIKE unaccent(${pattern})
OR similarity(it.name, ${search}) > 0.35
OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern})))
)`;
const conditions = search
? or(
sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`,
sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`,
sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`,
...(candidates.includes("sv") ? [] : [translationMatch]),
)
: undefined;
const rows = await app.db
.select({
id: schema.canonicalIngredients.id,
nameSv: schema.canonicalIngredients.nameSv,
category: schema.canonicalIngredients.category,
defaultUnit: schema.canonicalIngredients.defaultUnit,
allergens: schema.canonicalIngredients.allergens,
})
.from(schema.canonicalIngredients)
.where(conditions)
.orderBy(
search
? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}`
: schema.canonicalIngredients.nameSv,
)
.limit(30);
const names = await resolveIngredientNames(
app.db,
rows.map((r) => r.id),
languageTag,
);
return {
ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
/**
* Marknadsprofil för näringsvisning + allergenframhävning (i18n M6).
* Fallback: EU. Styr endast VISNING säkerhetsfiltrering per användare
* (spec §61.2) påverkas aldrig av marknadsprofilen.
*/
app.get("/v1/i18n/nutrition-profile", auth, async (req) => {
const requested = String((req.query as { region?: string }).region ?? "").toUpperCase();
const region =
requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase();
const [profile] =
(await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, region))
.limit(1)) ?? [];
const [fallback] = profile
? [profile]
: await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, "EU"))
.limit(1);
const effective = fallback!;
const allergens = await app.db
.select({ allergen: schema.allergenMarketRules.allergen })
.from(schema.allergenMarketRules)
.where(eq(schema.allergenMarketRules.regionCode, effective.regionCode));
return {
regionCode: effective.regionCode,
requestedRegion: region,
energyDisplay: effective.energyDisplay,
saltDisplay: effective.saltDisplay,
energyLabelKey: effective.energyLabelKey,
highlightAllergens: allergens.map((a) => a.allergen).sort(),
};
});
/** Enhetsetiketter per språk (i18n M2) för klienter som inte vill hårdkoda. */
app.get("/v1/i18n/units", auth, async (req) => {
const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv");
const candidates = languageCandidates(languageTag);
const rows = await app.db
.select()
.from(schema.unitTranslations)
.where(inArray(schema.unitTranslations.languageTag, candidates));
const byUnit = new Map<string, (typeof rows)[number]>();
for (const candidate of [...candidates].reverse())
for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r);
return {
languageTag,
units: [...byUnit.values()].map((r) => ({
unitCode: r.unitCode,
abbreviation: r.abbreviation,
name: r.name,
})),
};
});
/**
* Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS
* strukturerar. Näring/allergener beräknas ALLTID deterministiskt här.
* Publiceringsflöde: submitted → AI-kontroll → moderation → published.
*/
app.post("/v1/recipes", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "communityPublish", "Egna recept");
const input = parse(createUserRecipeInputSchema, req.body);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
let structured: {
titleSv: string;
descriptionSv: string;
ingredients: Array<{
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
steps: Array<{
instructionSv: string;
timerSeconds?: number | null;
temperatureC?: number | null;
}>;
prepMin: number;
cookMin: number;
portions: number;
cuisine: string;
mealTypes: string[];
tags: string[];
methods: string[];
equipment: string[];
difficulty: string;
spiceLevel: number;
};
if (input.mode === "free_text") {
const result = await app.aamos.runTask(
"STRUCTURE_RECIPE_TEXT",
{ text: input.text, marketLocale: "sv-SE" },
{
correlationId: req.correlationId,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status !== "ok" || !result.output) {
throw errors.badRequest(
"Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.",
);
}
const out = result.output;
const ingredients = out.ingredients
.filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId!,
displayNameSv: i.displayNameSv,
quantity: i.quantity!,
unit: i.unit!,
optional: i.optional,
}));
if (ingredients.length === 0) {
throw errors.badRequest(
"Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.",
);
}
structured = {
titleSv: out.titleSv ?? "Mitt recept",
descriptionSv: out.descriptionSv ?? "",
ingredients,
steps: out.steps,
prepMin: out.prepTimeMinutes ?? 15,
cookMin: out.cookTimeMinutes ?? 20,
portions: out.portions ?? 4,
cuisine: out.suggestedCuisine ?? "international",
mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
spiceLevel: 0,
};
} else {
structured = {
titleSv: input.titleSv,
descriptionSv: input.descriptionSv,
ingredients: input.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
steps: input.steps.map((s) => ({
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
prepMin: input.prepTimeMinutes,
cookMin: input.cookTimeMinutes,
portions: input.portions,
cuisine: input.cuisine,
mealTypes: input.mealTypes,
tags: input.tags,
methods: input.methods,
equipment: input.equipment,
difficulty: input.difficulty,
spiceLevel: input.spiceLevel,
};
}
// Deterministisk näring + allergener (spec §61.12)
const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId);
const dbIngredients = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const sourceMap = new Map(
dbIngredients.map((i) => [
i.id,
{
nutritionPer100: i.nutritionPer100,
densityGPerMl: i.densityGPerMl,
gramsPerPiece: i.gramsPerPiece,
},
]),
);
const missing = ingredientIds.filter((id) => !sourceMap.has(id));
if (missing.length > 0) {
throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing });
}
const nutrition = computeRecipeNutrition(
structured.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
})),
structured.portions,
sourceMap,
);
const safetyInfo = await ingredientSafetyMap(app, ingredientIds);
const allergens = deriveRecipeAllergens(
structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId),
safetyInfo,
);
const slug = `${structured.titleSv
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/ö/g, "o")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)}-${Date.now().toString(36)}`;
const [recipe] = await app.db
.insert(schema.recipes)
.values({
slug,
titleSv: structured.titleSv,
descriptionSv: structured.descriptionSv,
cuisine: structured.cuisine as never,
mealTypes: structured.mealTypes as never,
tags: structured.tags,
methods: structured.methods,
equipment: structured.equipment,
difficulty: structured.difficulty as never,
prepTimeMinutes: structured.prepMin,
cookTimeMinutes: structured.cookMin,
totalTimeMinutes: structured.prepMin + structured.cookMin,
portions: structured.portions,
nutritionPerPortion: nutrition.perPortion,
allergens,
spiceLevel: structured.spiceLevel,
dna: {
cuisine: structured.cuisine as never,
vegetables: [],
flavorProfile: [],
spiceLevel: structured.spiceLevel,
method: (structured.methods[0] ?? "stovetop") as never,
timeMinutes: structured.prepMin + structured.cookMin,
calories: nutrition.perPortion.kcal,
proteinGrams: Math.round(nutrition.perPortion.proteinG),
},
status: "submitted",
verificationStatus: "unverified",
sourceType: "user_generated",
creatorUserId: req.userId,
creatorDisplayName: user?.displayName ?? "Okänd",
})
.returning();
await app.db.insert(schema.recipeIngredients).values(
structured.ingredients.map((i, idx) => ({
recipeId: recipe!.id,
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
sortOrder: idx,
})),
);
await app.db.insert(schema.recipeSteps).values(
structured.steps.map((s, idx) => ({
recipeId: recipe!.id,
stepNumber: idx + 1,
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
);
// AI-kontroll + moderering sker asynkront i workern (spec §35 steg 24).
await app.jobQueue.add("MODERATE_RECIPE", {
jobType: "MODERATE_RECIPE",
recipeId: recipe!.id,
correlationId: req.correlationId,
});
await emitEvent(app.db, {
type: "RECIPE_CREATED",
payload: { recipeId: recipe!.id, sourceType: "user_generated" },
userId: req.userId,
correlationId: req.correlationId,
});
return reply.status(201).send({
recipe,
uncomputableIngredients: nutrition.uncomputableIngredientIds,
message: "Receptet är inskickat och granskas innan publicering.",
});
});
}
async function loadFullRecipe(app: FastifyInstance, id: string) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
const ingredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id))
.orderBy(schema.recipeIngredients.sortOrder);
const steps = await app.db
.select()
.from(schema.recipeSteps)
.where(eq(schema.recipeSteps.recipeId, id))
.orderBy(schema.recipeSteps.stepNumber);
return { ...recipe, ingredients, steps };
}
async function ingredientSafetyMap(
app: FastifyInstance,
ids: string[],
): Promise<Map<string, IngredientSafetyInfo>> {
if (ids.length === 0) return new Map();
const rows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ids));
return new Map(
rows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
}
+353
View File
@@ -0,0 +1,353 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { whatToEatQuerySchema } from "@app/validation";
import {
computeCoverage,
checkRecipeSafety,
isRecipeSafe,
type IngredientSafetyInfo,
type PantryItem,
} from "@app/recipe-engine";
import {
isEventActive,
parseCraving,
rankAll,
seasonForDate,
summarizeContext,
type RecommendationCandidate,
type RecommendationContext,
} from "@app/recommendation-engine";
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
import { parse } from "../lib/errors.js";
import { requireActiveHousehold, todayIso } from "../lib/helpers.js";
/**
* "Vad ska vi äta?" (spec §18) appens viktigaste endpoint.
*
* Pipeline:
* 1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext.
* 2. Deterministisk säkerhetsfiltrering (spec §61.2) blockers försvinner.
* 3. Täckningsberäkning mot lagret + poängsättning med förklaringar.
* 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24).
* 5. (Bakom flagga) AAMOS får omranka topplistan aldrig lägga till recept.
*/
export async function recommendationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recommendations/what-to-eat", auth, async (req) => {
const q = parse(whatToEatQuerySchema, req.query);
const householdId = await requireActiveHousehold(app.db, req.userId);
const today = new Date();
// --- 1. Kontext: lager ---
const stockRows = await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
density: schema.canonicalIngredients.densityGPerMl,
gramsPerPiece: schema.canonicalIngredients.gramsPerPiece,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const pantry: PantryItem[] = stockRows.map((r) => ({
id: r.item.id,
canonicalIngredientId: r.item.canonicalIngredientId,
quantity: r.item.quantity,
unit: r.item.unit,
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}));
const unitInfo = new Map(
stockRows
.filter((r) => r.item.canonicalIngredientId)
.map((r) => [
r.item.canonicalIngredientId!,
{ densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece },
]),
);
// --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) ---
const members = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
const memberIds = members.map((m) => m.userId);
const allPrefs = await app.db
.select()
.from(schema.userPreferences)
.where(inArray(schema.userPreferences.userId, memberIds));
const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))];
const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))];
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
const myPrefs = allPrefs.find((p) => p.userId === req.userId);
// --- 3. Kandidater: publicerade recept för måltidstypen ---
const candidates = await app.db
.select()
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
),
)
.limit(200);
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(
inArray(
schema.recipeIngredients.recipeId,
candidates.map((c) => c.id),
),
);
const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))];
const safetyRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const safetyMap = new Map<string, IngredientSafetyInfo>(
safetyRows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
for (const r of safetyRows) {
if (!unitInfo.has(r.id)) {
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
}
}
// --- 4. Näringskontext: vad återstår av dagen? ---
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const targets =
profile?.weightKg && profile.heightCm && profile.birthYear
? computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: today.getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: myPrefs?.primaryGoal ?? undefined,
}).targets
: DEFAULT_TARGETS;
const todaysMeals = await app.db
.select({ nutrition: schema.meals.nutrition })
.from(schema.meals)
.where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso())));
const daySummary = summarizeDay(
todaysMeals.map((m) => m.nutrition),
targets,
);
// --- 5. Säsong & högtid (spec §28) ---
const events = await app.db
.select()
.from(schema.seasonEvents)
.where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, "SE")));
const activeHolidayTags = events
.filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today))
.map((e) => e.slug);
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
last: sql<string>`max(${schema.recipeCooks.cookedAt})`,
})
.from(schema.recipeCooks)
.where(eq(schema.recipeCooks.householdId, householdId))
.groupBy(schema.recipeCooks.recipeId);
const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last]));
const householdRatings = await app.db
.select({
recipeId: schema.recipeRatings.recipeId,
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
})
.from(schema.recipeRatings)
.where(inArray(schema.recipeRatings.userId, memberIds))
.groupBy(schema.recipeRatings.recipeId);
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
// --- 7. Tolka "jag är sugen på" (spec §19) ---
const craving = q.craving ? parseCraving(q.craving) : null;
const ctx: RecommendationContext = {
mealType: q.mealType,
persons: q.persons ?? members.length,
maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined,
maxCostMinorPerPortion: q.maxCostMinorPerPortion,
remainingProteinG: Math.max(0, daySummary.remaining.proteinG),
remainingKcal: Math.max(0, daySummary.remaining.kcal),
currentSeason: seasonForDate(today),
activeHolidayTags,
isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4,
favoriteCuisines: myPrefs?.favoriteCuisines ?? [],
cravingTags: craving?.tags,
cravingCuisine: craving?.cuisine,
cravingMaxKcal: craving?.maxKcal,
};
// --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
const scoredCandidates: RecommendationCandidate[] = [];
for (const recipe of candidates) {
const recipeIngredients = allIngredients
.filter((i) => i.recipeId === recipe.id)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
}));
const violations = checkRecipeSafety(
{
ingredients: recipeIngredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: combinedAllergens,
dietPattern: myPrefs?.dietPattern,
religiousRule: myPrefs?.religiousRule,
avoidIngredientIds: combinedAvoid,
spiceLevelMax: strictestSpice,
},
safetyMap,
);
if (!isRecipeSafe(violations)) continue;
const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today);
const lastDate = lastCooked.get(recipe.id);
scoredCandidates.push({
recipeId: recipe.id,
titleSv: recipe.titleSv,
cuisine: recipe.cuisine,
tags: recipe.tags as never,
totalTimeMinutes: recipe.totalTimeMinutes,
nutritionPerPortion: recipe.nutritionPerPortion,
estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion,
ratingAverage: recipe.ratingAverage,
ratingCount: recipe.ratingCount,
peakSeasons: recipe.peakSeasons,
holidayTags: recipe.holidayTags,
spiceLevel: recipe.spiceLevel,
coverage,
daysSinceLastCooked: lastDate
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
: null,
householdRating: householdRatingMap.get(recipe.id) ?? null,
});
}
let recommendations = rankAll(scoredCandidates, ctx, undefined, q.limit);
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
if (await app.flags.isEnabled("ai_rerank", req.userId)) {
const result = await app.aamos.runTask(
"RANK_RECIPES",
{
candidateIds: recommendations.map((r) => r.recipeId),
deterministicScores: Object.fromEntries(
recommendations.map((r) => [r.recipeId, r.score]),
),
contextSummary: summarizeContext(ctx),
},
{
correlationId: req.correlationId,
subjectRef: null,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status === "ok" && result.output) {
const order = new Map(result.output.rankedIds.map((id, i) => [id, i]));
recommendations = [...recommendations].sort(
(a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99),
);
}
}
// --- 10. Matlådor först när rimligt (spec §24) ---
const mealBoxes = q.includeLeftovers
? await app.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy)
.limit(5)
: [];
const mealBoxSuggestions = mealBoxes.map((box) => ({
mealBoxId: box.id,
titleSv: box.titleSv,
portionsRemaining: box.portionsRemaining,
recommendedUseBy: box.recommendedUseBy,
whySv:
Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000
? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.`
: "Färdig mat som väntar snabbaste middagen i huset.",
}));
return {
mealType: q.mealType,
context: {
persons: ctx.persons,
season: ctx.currentSeason,
activeHolidays: activeHolidayTags,
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
},
mealBoxSuggestions,
recommendations,
};
});
}
+368
View File
@@ -0,0 +1,368 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull } from "drizzle-orm";
import { schema } from "@app/database";
import type { JobType, ScanType } from "@app/shared-types";
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold } from "../lib/helpers.js";
import { consumeAiScan } from "../lib/entitlements.js";
/**
* Skanningsflödet (spec §50):
* App → POST /v1/scans (kvotkontroll + presignade upload-URL:er)
* → PUT bild(er) till storage
* → POST /v1/scans/:id/start (läggs på kö → worker → AAMOS)
* → GET /v1/scans/:id (poll: status + resultat)
* → POST /v1/scans/:id/confirm (användaren godkänner → lagret uppdateras)
*
* Användarbekräftelse är obligatorisk innan något skrivs till Food Twin
* (spec §10, §61.5). Korrigeringar sparas som ai_corrections (spec §33).
*/
const SCAN_TO_JOB: Record<ScanType, JobType> = {
fridge: "ANALYZE_FRIDGE_IMAGE",
freezer: "ANALYZE_FRIDGE_IMAGE",
pantry: "ANALYZE_PANTRY_IMAGE",
ingredients: "ANALYZE_PANTRY_IMAGE",
plate: "ANALYZE_MEAL_IMAGE",
receipt: "READ_RECEIPT",
barcode: "NORMALIZE_PRODUCTS",
expiry_date: "READ_EXPIRY_DATE",
nutrition_label: "READ_NUTRITION_LABEL",
product_package: "READ_NUTRITION_LABEL",
};
const S3_PREFIX: Partial<Record<ScanType, string>> = {
fridge: "fridge-scans",
freezer: "fridge-scans",
pantry: "pantry-scans",
ingredients: "pantry-scans",
plate: "meal-scans",
receipt: "receipts",
expiry_date: "product-images",
nutrition_label: "product-images",
product_package: "product-images",
};
export async function scanRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/scans", auth, async (req, reply) => {
const input = parse(createScanInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
// Streckkod är gratis uppslag utan AI hanteras direkt (spec §11: lokalt + databas).
if (input.scanType === "barcode") {
if (!input.barcode) throw errors.badRequest("barcode krävs för streckkodsskanning.");
const product = await lookupBarcode(app, input.barcode);
const [job] = await app.db
.insert(schema.scanJobs)
.values({
userId: req.userId,
householdId,
scanType: "barcode",
jobType: "NORMALIZE_PRODUCTS",
status: product ? "completed" : "failed",
result: product ? { product } : null,
error: product
? null
: "Produkten hittades inte. Fota framsida + näringsdeklaration så lägger vi till den.",
completedAt: new Date(),
})
.returning();
return reply.status(201).send({ scan: job, product });
}
// AI-skanning: kvotkontroll (fair use, spec §4546) och presignade URL:er.
await consumeAiScan(app.db, req.userId);
const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`;
const uploads = [];
for (let i = 0; i < Math.max(1, input.imageCount); i++) {
uploads.push(await app.storage.presignUpload(prefix, input.contentType));
}
const [job] = await app.db
.insert(schema.scanJobs)
.values({
userId: req.userId,
householdId,
scanType: input.scanType,
jobType: SCAN_TO_JOB[input.scanType],
status: "queued",
s3Keys: uploads.map((u) => u.key),
context: input.context ?? null,
})
.returning();
return reply.status(201).send({ scan: job, uploads });
});
app.post("/v1/scans/:id/start", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const job = await getOwnedScan(app, id, req.userId);
if (job.status !== "queued") throw errors.conflict(`Jobbet är redan ${job.status}.`);
await app.jobQueue.add(job.jobType, {
scanJobId: job.id,
jobType: job.jobType,
correlationId: req.correlationId,
});
return { ok: true, status: "queued" };
});
app.get("/v1/scans/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
return getOwnedScan(app, id, req.userId);
});
app.post("/v1/scans/:id/confirm", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(confirmScanInputSchema, req.body);
const job = await getOwnedScan(app, id, req.userId);
if (job.status !== "awaiting_confirmation" && job.status !== "completed") {
throw errors.conflict("Jobbet har inget resultat att bekräfta ännu.");
}
const householdId = job.householdId ?? (await requireActiveHousehold(app.db, req.userId));
const fallbackLocation =
input.storageLocationId ?? (await defaultLocation(app, householdId, job.scanType));
const created: string[] = [];
for (const item of input.items) {
if (item.action === "reject") {
await recordCorrection(app, job, item.tempId ?? null, { action: "reject" });
continue;
}
if (item.action === "edit" || item.action === "add") {
await recordCorrection(app, job, item.tempId ?? null, {
action: item.action,
corrected: { name: item.displayName, quantity: item.quantity, unit: item.unit },
});
}
const locationId = item.storageLocationId ?? fallbackLocation;
if (!locationId)
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
householdId,
canonicalIngredientId: item.canonicalIngredientId ?? null,
displayName: item.displayName,
brand: item.brand ?? null,
quantity: item.quantity,
unit: item.unit,
storageLocationId: locationId,
sublocation: item.sublocation ?? null,
bestBeforeDate: item.bestBeforeDate ?? null,
useByDate: item.useByDate ?? null,
priceMinor: item.priceMinor ?? null,
purchasedAt: new Date().toISOString().slice(0, 10),
source: scanSource(job.scanType),
confidence: item.action === "accept" ? 0.9 : 1,
verifiedByUser: true,
lastVerifiedAt: new Date(),
modelVersion: job.modelVersion,
promptVersion: job.promptVersion,
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: inv!.id,
type: "purchase",
quantityDelta: item.quantity,
unit: item.unit,
refType: "scan",
refId: job.id,
actorUserId: req.userId,
valueMinor: item.priceMinor ?? null,
});
await emitEvent(app.db, {
type: "PRODUCT_ADDED",
payload: {
inventoryItemId: inv!.id,
canonicalIngredientId: item.canonicalIngredientId ?? null,
quantity: item.quantity,
unit: item.unit,
source: scanSource(job.scanType),
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
created.push(inv!.id);
}
await app.db
.update(schema.scanJobs)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.scanJobs.id, id));
return { ok: true, createdItemIds: created };
});
app.get("/v1/scans", auth, async (req) => {
const jobs = await app.db
.select()
.from(schema.scanJobs)
.where(eq(schema.scanJobs.userId, req.userId))
.orderBy((await import("drizzle-orm")).desc(schema.scanJobs.createdAt))
.limit(30);
return { scans: jobs };
});
}
async function getOwnedScan(app: FastifyInstance, id: string, userId: string) {
const [job] = await app.db
.select()
.from(schema.scanJobs)
.where(eq(schema.scanJobs.id, id))
.limit(1);
if (!job || job.userId !== userId) throw errors.notFound("Skanningen finns inte.");
return job;
}
async function lookupBarcode(app: FastifyInstance, gtin: string) {
// 1. Egen produktdatabas (aktuell version)
const [own] = await app.db
.select()
.from(schema.products)
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
.limit(1);
if (own) return own;
// 2. Open Food Facts (laglig öppen källa, spec §11)
const off = app.connectors.get("open-food-facts");
if (off && "lookupBarcode" in off) {
try {
const result = await (off as { lookupBarcode(g: string): Promise<unknown> }).lookupBarcode(
gtin,
);
if (result && typeof result === "object") {
const p = result as {
gtin: string;
name?: string;
brand?: string;
ingredientsText?: string;
nutrimentsPer100g: Record<string, number | undefined>;
imageUrl?: string;
};
if (!p.name) return null;
const n = p.nutrimentsPer100g;
const [saved] = await app.db
.insert(schema.products)
.values({
gtin: p.gtin,
name: p.name,
brand: p.brand ?? null,
ingredientsText: p.ingredientsText ?? null,
nutrition:
n.kcal != null
? {
basis: "per_100_g",
values: {
kcal: n.kcal ?? 0,
proteinG: n.proteinG ?? 0,
carbsG: n.carbsG ?? 0,
fatG: n.fatG ?? 0,
saturatedFatG: n.saturatedFatG ?? 0,
fiberG: n.fiberG ?? 0,
sugarG: n.sugarG ?? 0,
saltG: n.saltG ?? 0,
},
}
: null,
imageUrls: p.imageUrl ? [p.imageUrl] : [],
dataSource: "open_food_facts",
verificationStatus: "unverified",
})
.onConflictDoNothing()
.returning();
return saved ?? null;
}
} catch (err) {
app.log.warn({ err, gtin }, "OFF-uppslag misslyckades");
}
}
return null;
}
function scanSource(scanType: ScanType) {
switch (scanType) {
case "fridge":
return "fridge_photo" as const;
case "freezer":
return "freezer_photo" as const;
case "pantry":
return "pantry_photo" as const;
case "ingredients":
return "ingredient_photo" as const;
case "receipt":
return "receipt" as const;
case "barcode":
return "barcode" as const;
default:
return "label_photo" as const;
}
}
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: ScanType) {
const wanted =
scanType === "freezer"
? "freezer"
: scanType === "pantry" || scanType === "ingredients"
? "pantry"
: "fridge";
const [loc] = await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, householdId),
eq(schema.storageLocations.type, wanted),
),
)
.limit(1);
return loc?.id ?? null;
}
async function recordCorrection(
app: FastifyInstance,
job: {
id: string;
userId: string;
jobType: string;
result: unknown;
modelVersion: string | null;
promptVersion: string | null;
},
tempId: string | null,
correction: Record<string, unknown>,
) {
const consents = await app.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, job.userId));
const snapshot = Object.fromEntries(consents.map((c) => [c.kind, c.status]));
await app.db.insert(schema.aiCorrections).values({
scanJobId: job.id,
userId: job.userId,
taskType: job.jobType,
aiOutput: { tempId, raw: job.result },
userCorrection: correction,
modelVersion: job.modelVersion,
promptVersion: job.promptVersion,
consentSnapshot: snapshot,
});
await emitEvent(app.db, {
type: "AI_CORRECTED",
payload: {
scanJobId: job.id,
taskType: job.jobType,
field: String(correction.action ?? "unknown"),
},
userId: job.userId,
});
}
+406
View File
@@ -0,0 +1,406 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { StoreSection } from "@app/shared-types";
import {
addShoppingItemInputSchema,
completeShoppingInputSchema,
createShoppingListInputSchema,
idParamSchema,
updateShoppingItemInputSchema,
} from "@app/validation";
import { convert } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
/**
* Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per
* butiksavdelning, dela i hushållet, uppdatera lagret efter köp.
*/
const CATEGORY_TO_SECTION: Record<string, StoreSection> = {
mejeri: "mejeri",
kott_fagel: "kott_fagel",
fisk: "fisk",
gronsaker: "frukt_gront",
frukt: "frukt_gront",
spannmal: "skafferi",
baljvaxter: "skafferi",
skafferi: "skafferi",
konserver: "konserver",
kryddor: "kryddor_bak",
brod: "brod",
};
export async function shoppingRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/shopping-lists", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const lists = await app.db
.select()
.from(schema.shoppingLists)
.where(
and(
eq(schema.shoppingLists.householdId, householdId),
eq(schema.shoppingLists.status, "active"),
),
);
return { lists };
});
app.post("/v1/shopping-lists", auth, async (req, reply) => {
const input = parse(createShoppingListInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [list] = await app.db
.insert(schema.shoppingLists)
.values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null })
.returning();
// Generera från veckoplan: receptbehov befintligt lager (spec §27)
if (input.generateFromPlan && input.weekPlanId) {
await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId);
}
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, list!.id));
return reply.status(201).send({ list, items });
});
app.get("/v1/shopping-lists/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, id))
.orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder);
const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0);
// Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8).
return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" };
});
app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
await getOwnedList(app, id, req.userId);
const input = parse(addShoppingItemInputSchema, req.body);
let section = input.storeSection;
let estimatedPrice = input.estimatedPriceMinor;
if (input.canonicalIngredientId) {
const [ing] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId))
.limit(1);
if (ing) {
section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt";
if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) {
const grams = convert(input.quantity, input.unit, "GRAM", {
densityGPerMl: ing.densityGPerMl,
gramsPerPiece: ing.gramsPerPiece,
});
if (grams != null)
estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10;
}
}
}
// Slå ihop med befintlig rad för samma ingrediens (spec §27)
if (input.canonicalIngredientId) {
const [existing] = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId),
eq(schema.shoppingListItems.unit, input.unit),
eq(schema.shoppingListItems.checked, false),
),
)
.limit(1);
if (existing) {
const [merged] = await app.db
.update(schema.shoppingListItems)
.set({
quantity: existing.quantity + input.quantity,
estimatedPriceMinor:
existing.estimatedPriceMinor != null && estimatedPrice != null
? existing.estimatedPriceMinor + estimatedPrice
: (existing.estimatedPriceMinor ?? estimatedPrice ?? null),
})
.where(eq(schema.shoppingListItems.id, existing.id))
.returning();
return reply.send({ item: merged, merged: true });
}
}
const [item] = await app.db
.insert(schema.shoppingListItems)
.values({
shoppingListId: id,
canonicalIngredientId: input.canonicalIngredientId ?? null,
displayName: input.displayName,
quantity: input.quantity,
unit: input.unit,
storeSection: section ?? "hygien_ovrigt",
estimatedPriceMinor: estimatedPrice ?? null,
addedByUserId: req.userId,
origin: "manual",
})
.returning();
return reply.status(201).send({ item, merged: false });
});
app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
const input = parse(updateShoppingItemInputSchema, req.body);
const [item] = await app.db
.update(schema.shoppingListItems)
.set(input)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
)
.returning();
if (!item) throw errors.notFound();
return item;
});
app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
await app.db
.delete(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
);
return { ok: true };
});
/** Avsluta köprundan: bockade varor in i lagret (spec §27). */
app.post("/v1/shopping-lists/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const input = parse(completeShoppingInputSchema, req.body);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.checked, true),
),
);
let added = 0;
if (input.addToInventory && items.length > 0) {
const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s]));
const fallback =
input.defaultStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, list.householdId),
eq(schema.storageLocations.type, "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet.");
for (const item of items) {
const override = overrides.get(item.id);
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
householdId: list.householdId,
canonicalIngredientId: item.canonicalIngredientId,
displayName: item.displayName,
quantity: item.quantity,
unit: item.unit,
storageLocationId: override?.storageLocationId ?? fallback,
purchasedAt: todayIso(),
bestBeforeDate: override?.bestBeforeDate ?? null,
priceMinor: override?.priceMinor ?? item.estimatedPriceMinor,
source: "manual_search",
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: new Date(),
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId: list.householdId,
inventoryItemId: inv!.id,
type: "purchase",
quantityDelta: item.quantity,
unit: item.unit,
refType: "shopping",
refId: id,
actorUserId: req.userId,
valueMinor: override?.priceMinor ?? item.estimatedPriceMinor,
});
added += 1;
}
}
await app.db
.update(schema.shoppingLists)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.shoppingLists.id, id));
await emitEvent(app.db, {
type: "SHOPPING_COMPLETED",
payload: { shoppingListId: id, itemsAdded: added },
userId: req.userId,
householdId: list.householdId,
correlationId: req.correlationId,
});
return { ok: true, itemsAddedToInventory: added };
});
}
async function getOwnedList(app: FastifyInstance, listId: string, userId: string) {
const [list] = await app.db
.select()
.from(schema.shoppingLists)
.where(eq(schema.shoppingLists.id, listId))
.limit(1);
if (!list) throw errors.notFound("Listan finns inte.");
await requireMembership(app.db, list.householdId, userId);
return list;
}
/** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */
async function generateItemsFromPlan(
app: FastifyInstance,
listId: string,
weekPlanId: string,
householdId: string,
userId: string,
) {
const entries = await app.db
.select()
.from(schema.weekPlanEntries)
.where(
and(
eq(schema.weekPlanEntries.weekPlanId, weekPlanId),
eq(schema.weekPlanEntries.status, "planned"),
),
);
const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))];
if (recipeIds.length === 0) return;
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
const recipes = await app.db
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
.from(schema.recipes)
.where(inArray(schema.recipes.id, recipeIds));
const portionsMap = new Map(recipes.map((r) => [r.id, r.portions]));
// Aggregera behov per ingrediens (i gram där möjligt)
const needs = new Map<string, { name: string; grams: number }>();
const infoRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(
inArray(schema.canonicalIngredients.id, [
...new Set(allIngredients.map((i) => i.canonicalIngredientId)),
]),
);
const infoMap = new Map(infoRows.map((r) => [r.id, r]));
for (const entry of entries) {
if (!entry.recipeId) continue;
const basePortions = portionsMap.get(entry.recipeId) ?? 4;
const factor = entry.portions / basePortions;
for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) {
const info = infoMap.get(ing.canonicalIngredientId);
const grams = convert(ing.quantity * factor, ing.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams == null) continue;
const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 };
current.grams += grams;
needs.set(ing.canonicalIngredientId, current);
}
}
// Dra av lager
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]),
),
);
for (const item of stock) {
if (!item.canonicalIngredientId) continue;
const need = needs.get(item.canonicalIngredientId);
if (!need) continue;
const info = infoMap.get(item.canonicalIngredientId);
const grams = convert(item.quantity, item.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams != null) need.grams = Math.max(0, need.grams - grams);
}
// Skapa rader för det som saknas
let sortOrder = 0;
for (const [ingredientId, need] of needs) {
if (need.grams < 5) continue;
const info = infoMap.get(ingredientId);
const section = info
? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt")
: "hygien_ovrigt";
// Konvertera tillbaka till naturlig enhet
const targetUnit = info?.defaultUnit ?? "GRAM";
const qty =
convert(need.grams, "GRAM", targetUnit, {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
}) ?? need.grams;
const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10;
const estimatedPrice =
info?.defaultPriceMinorPerKg != null
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10
: null;
await app.db.insert(schema.shoppingListItems).values({
shoppingListId: listId,
canonicalIngredientId: ingredientId,
displayName: need.name,
quantity: rounded,
unit: targetUnit,
storeSection: section,
estimatedPriceMinor: estimatedPrice,
addedByUserId: userId,
origin: "plan",
sortOrder: sortOrder++,
});
}
}
+146
View File
@@ -0,0 +1,146 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import { verifyPurchaseInputSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
import { audit, emitEvent, getActiveHouseholdId } from "../lib/helpers.js";
import { loadEntitlementsWithToken } from "../lib/entitlements.js";
/**
* Prenumerationer (spec §4547, Del 13).
* Backend verifierar ALLTID mot butiken och är source of truth (spec §61.14).
* Webhooks tas emot råa och processas asynkront av workern.
*/
export async function subscriptionRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/v1/subscriptions/verify", auth, async (req) => {
const input = parse(verifyPurchaseInputSchema, req.body);
const result =
input.provider === "apple"
? await app.storeVerifier.verifyApple(input.signedTransaction)
: await app.storeVerifier.verifyGoogle(
input.packageName,
input.productId,
input.purchaseToken,
);
if (!result.ok) throw errors.badRequest(`Kunde inte verifiera köpet: ${result.error}`);
const purchase = result.purchase;
const householdId = await getActiveHouseholdId(app.db, req.userId);
// Idempotent på originalTransactionId
const [existing] = await app.db
.select()
.from(schema.subscriptions)
.where(eq(schema.subscriptions.originalTransactionId, purchase.originalTransactionId))
.limit(1);
let subscriptionId: string;
if (existing) {
if (existing.userId !== req.userId) {
throw errors.conflict(
"Det här köpet är kopplat till ett annat konto. Använd Återställ köp på rätt konto.",
);
}
const [updated] = await app.db
.update(schema.subscriptions)
.set({
status: purchase.status,
expiresAt: purchase.expiresAt,
plan: purchase.plan,
productId: purchase.productId,
lastVerifiedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.subscriptions.id, existing.id))
.returning();
subscriptionId = updated!.id;
} else {
const [created] = await app.db
.insert(schema.subscriptions)
.values({
userId: req.userId,
householdId,
provider: purchase.provider,
productId: purchase.productId,
plan: purchase.plan,
originalTransactionId: purchase.originalTransactionId,
status: purchase.status,
purchasedAt: purchase.purchasedAt,
expiresAt: purchase.expiresAt,
lastVerifiedAt: new Date(),
})
.returning();
subscriptionId = created!.id;
await emitEvent(app.db, {
type: "SUBSCRIPTION_STARTED",
payload: { subscriptionId, plan: purchase.plan, provider: purchase.provider },
userId: req.userId,
householdId: householdId ?? undefined,
correlationId: req.correlationId,
});
}
await app.db.insert(schema.subscriptionEvents).values({
subscriptionId,
userId: req.userId,
eventType: existing ? "verified" : "purchased",
payload: { productId: purchase.productId },
});
await audit(app.db, {
actorUserId: req.userId,
action: "subscription.verified",
targetType: "subscription",
targetId: subscriptionId,
});
return { ok: true, entitlements: await loadEntitlementsWithToken(app, req.userId) };
});
app.post("/v1/subscriptions/restore", auth, async (req) => {
// Restore = samma flöde som verify; klienten skickar aktuellt kvitto/token.
return {
ok: true,
message: "Skicka aktuellt kvitto till /v1/subscriptions/verify så återställs köpet.",
};
});
/**
* App Store Server Notifications V2 (spec §47).
* Signaturverifiering av JWS sker i workern (PROCESS_STORE_NOTIFICATION).
*/
app.post(
"/v1/subscriptions/webhooks/apple",
{ config: { rateLimit: false } },
async (req, reply) => {
const [row] = await app.db
.insert(schema.storeNotifications)
.values({ provider: "apple", rawPayload: (req.body ?? {}) as Record<string, unknown> })
.returning();
await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", {
jobType: "PROCESS_STORE_NOTIFICATION",
notificationId: row!.id,
});
return reply.status(200).send({ ok: true });
},
);
/** Google Play Real-time Developer Notifications (via Pub/Sub push). */
app.post(
"/v1/subscriptions/webhooks/google",
{ config: { rateLimit: false } },
async (req, reply) => {
const [row] = await app.db
.insert(schema.storeNotifications)
.values({ provider: "google", rawPayload: (req.body ?? {}) as Record<string, unknown> })
.returning();
await app.jobQueue.add("PROCESS_STORE_NOTIFICATION", {
jobType: "PROCESS_STORE_NOTIFICATION",
notificationId: row!.id,
});
return reply.status(200).send({ ok: true });
},
);
}
+85
View File
@@ -0,0 +1,85 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import type { AppConfig } from "./config.js";
import { corePlugin } from "./plugins/core.js";
import { authPlugin } from "./plugins/auth.js";
import { storagePlugin } from "./plugins/storage.js";
import { healthRoutes } from "./routes/health.js";
import { authRoutes } from "./routes/auth.js";
import { meRoutes } from "./routes/me.js";
import { householdRoutes } from "./routes/households.js";
import { inventoryRoutes } from "./routes/inventory.js";
import { scanRoutes } from "./routes/scans.js";
import { recipeRoutes } from "./routes/recipes.js";
import { mealRoutes } from "./routes/meals.js";
import { shoppingRoutes } from "./routes/shopping.js";
import { planningRoutes } from "./routes/planning.js";
import { recommendationRoutes } from "./routes/recommendations.js";
import { memoryRoutes } from "./routes/memory.js";
import { budgetRoutes } from "./routes/budget.js";
import { subscriptionRoutes } from "./routes/subscriptions.js";
import { communityRoutes } from "./routes/community.js";
import { adminRoutes } from "./routes/admin.js";
declare module "fastify" {
interface FastifyInstance {
routeCatalog: Array<{ method: string; url: string }>;
}
}
export async function buildServer(config: AppConfig) {
const app = Fastify({
logger: {
level: config.LOG_LEVEL,
redact: ["req.headers.authorization", "req.headers.cookie"],
},
trustProxy: true,
bodyLimit: 1024 * 1024,
});
// Endpointkatalog för /docs hooken måste ligga före route-registreringen.
const routeCatalog: Array<{ method: string; url: string }> = [];
app.decorate("routeCatalog", routeCatalog);
app.addHook("onRoute", (route) => {
const methods = Array.isArray(route.method) ? route.method : [route.method];
for (const method of methods) {
if (method === "HEAD" || method === "OPTIONS") continue;
routeCatalog.push({ method, url: route.url });
}
});
await app.register(corePlugin, { config });
await app.register(cors, {
origin: config.CORS_ORIGINS.split(",").map((o) => o.trim()),
credentials: true,
});
await app.register(rateLimit, {
global: true,
// Överstyrbar för lasttest (scripts/loadtest.mjs) produktion använder default 300/min.
max: Number(process.env.RATE_LIMIT_MAX ?? 300),
timeWindow: "1 minute",
keyGenerator: (req) => `${req.ip}`,
});
await app.register(authPlugin);
await app.register(storagePlugin);
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(meRoutes);
await app.register(householdRoutes);
await app.register(inventoryRoutes);
await app.register(scanRoutes);
await app.register(recipeRoutes);
await app.register(mealRoutes);
await app.register(shoppingRoutes);
await app.register(planningRoutes);
await app.register(recommendationRoutes);
await app.register(memoryRoutes);
await app.register(budgetRoutes);
await app.register(subscriptionRoutes);
await app.register(communityRoutes);
await app.register(adminRoutes);
return app;
}
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/lib/passwords.js";
import { generateInviteCode, sha256 } from "../src/lib/helpers.js";
import { parse, ApiError } from "../src/lib/errors.js";
import { registerInputSchema, confirmScanInputSchema } from "@app/validation";
describe("lösenordshantering (scrypt)", () => {
it("hashar och verifierar", async () => {
const hash = await hashPassword("korrekt häst batteri 99");
expect(hash.startsWith("scrypt$")).toBe(true);
expect(await verifyPassword("korrekt häst batteri 99", hash)).toBe(true);
expect(await verifyPassword("fel lösenord", hash)).toBe(false);
});
it("unika salter ger olika hashar", async () => {
const a = await hashPassword("samma lösenord!");
const b = await hashPassword("samma lösenord!");
expect(a).not.toBe(b);
expect(await verifyPassword("samma lösenord!", a)).toBe(true);
expect(await verifyPassword("samma lösenord!", b)).toBe(true);
});
it("hanterar trasig lagrad hash utan att kasta", async () => {
expect(await verifyPassword("x", "inte-en-hash")).toBe(false);
expect(await verifyPassword("x", "scrypt$abc$8$1$AA$BB")).toBe(false);
});
});
describe("hjälpare", () => {
it("inbjudningskoder: 8 tecken utan förväxlingsbara", () => {
for (let i = 0; i < 20; i++) {
const code = generateInviteCode();
expect(code).toHaveLength(8);
expect(code).not.toMatch(/[01IO]/);
}
});
it("sha256 är deterministisk", () => {
expect(sha256("abc")).toBe(sha256("abc"));
expect(sha256("abc")).toHaveLength(64);
});
});
describe("validering via parse()", () => {
it("registrering: normaliserar e-post, kräver 10 teckens lösenord", () => {
const result = parse(registerInputSchema, {
email: " Johan@Example.COM",
password: "supersäkert lösen",
displayName: "Johan",
});
expect(result.email).toBe("johan@example.com");
expect(() =>
parse(registerInputSchema, { email: "a@b.se", password: "kort", displayName: "X" }),
).toThrowError(ApiError);
});
it("scan-bekräftelse: avvisar ogiltiga enheter", () => {
expect(() =>
parse(confirmScanInputSchema, {
items: [{ action: "accept", displayName: "Mjölk", quantity: 1, unit: "gallon" }],
}),
).toThrowError(ApiError);
});
it("ApiError bär status och kod", () => {
try {
parse(registerInputSchema, {});
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).statusCode).toBe(400);
expect((err as ApiError).code).toBe("VALIDATION_ERROR");
}
});
});
describe("lösenordsåterställning (mejlmallar + tokensäkerhet)", () => {
it("renderar återställningsmejl per språk med brand ur konfig", async () => {
const { renderMail } = await import("../src/lib/mailer.js");
const sv = renderMail("auth.password_reset", "sv-SE", {
email: "a@b.se",
link: "app://reset-password?token=x",
});
const en = renderMail("auth.password_reset", "en-US", {
email: "a@b.se",
link: "app://reset-password?token=x",
});
expect(sv.subject).toContain("Återställ");
expect(en.subject).toContain("Reset");
expect(sv.text).toContain("30 minuter");
expect(en.text).toContain("30 minutes");
// Namnet kommer ur brand.config.json aldrig hårdkodat i mallen.
const { BRAND } = await import("@app/shared-types");
expect(sv.subject).toContain(BRAND.name);
});
it("alla tolv språk har mallar; okänt språk faller till svenska; okänd mall kastar", async () => {
const { renderMail } = await import("../src/lib/mailer.js");
const subjects = {
"sv-SE": "Återställ",
"en-US": "Reset",
"es-ES": "Restablece",
"it-IT": "Reimposta",
"de-DE": "Setze",
"fr-FR": "Réinitialisez",
"da-DK": "Nulstil",
"nb-NO": "Tilbakestill",
"fi-FI": "Nollaa",
"nl-NL": "wachtwoord opnieuw",
"pl-PL": "Zresetuj",
"pt-PT": "Reponha",
};
for (const [tag, word] of Object.entries(subjects)) {
expect(renderMail("auth.password_reset", tag, { email: "x", link: "y" }).subject).toContain(
word,
);
}
const unknown = renderMail("auth.password_reset", "el-GR", { email: "x", link: "y" });
expect(unknown.subject).toContain("Återställ"); // ostött språk -> sv
expect(() => renderMail("finns.inte", "sv", {})).toThrow();
});
it("återställningstoken lagras aldrig i klartext (sha256 är envägs)", () => {
const token = "a".repeat(64);
const hash = sha256(token);
expect(hash).not.toBe(token);
expect(hash).toHaveLength(64);
expect(sha256(token)).toBe(hash); // deterministisk uppslag fungerar
expect(sha256("b".repeat(64))).not.toBe(hash);
});
it("log-mailern skickar aldrig på riktigt; okonfigurerad SMTP vägrar högljutt", async () => {
const { createMailer } = await import("../src/lib/mailer.js");
await expect(
createMailer("log").send({ to: "x@y.se", subject: "t", text: "b" }),
).resolves.toBeUndefined();
// smtp utan inställningar/host/avsändare -> tydligt fel direkt (aldrig tyst tappade mejl)
expect(() => createMailer("smtp")).toThrow(/SMTP/);
expect(() =>
createMailer("smtp", {
host: "",
port: 587,
secure: false,
user: "",
pass: "",
from: "x@y.se",
}),
).toThrow(/SMTP_HOST/);
expect(() =>
createMailer("smtp", {
host: "smtp.x.se",
port: 587,
secure: false,
user: "",
pass: "",
from: "",
}),
).toThrow(/MAIL_FROM/);
});
});
describe("TOTP (RFC 6238) admin-2FA utan externa beroenden", () => {
it("klarar RFC 6238-testvektorn (SHA1, 6 siffror)", async () => {
const { base32Encode, totpCode } = await import("../src/lib/totp.js");
// RFC 6238 bilaga B: secret "12345678901234567890", T=59 s → 6 sista siffrorna av 94287082.
const secret = base32Encode(Buffer.from("12345678901234567890", "ascii"));
expect(totpCode(secret, 59_000)).toBe("287082");
expect(totpCode(secret, 1_111_111_109_000)).toBe("081804");
});
it("verifierar med ±1 stegs fönster men inte längre bort", async () => {
const { generateTotpSecret, totpCode, verifyTotp } = await import("../src/lib/totp.js");
const secret = generateTotpSecret();
const now = 1_700_000_000_000;
const code = totpCode(secret, now);
expect(verifyTotp(secret, code, now)).not.toBeNull();
expect(verifyTotp(secret, code, now + 30_000)).not.toBeNull(); // föregående steg ok
expect(verifyTotp(secret, code, now + 90_000)).toBeNull(); // för gammalt
expect(verifyTotp(secret, "000000", now)).toBeNull();
expect(verifyTotp(secret, "12345", now)).toBeNull(); // fel format
});
it("base32 rundtur + otpauth-URL utan hårdkodat namn", async () => {
const { base32Decode, base32Encode, otpauthUrl } = await import("../src/lib/totp.js");
const buf = Buffer.from("hemlig-nyckel-123", "ascii");
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
const { BRAND } = await import("@app/shared-types");
const url = otpauthUrl("ABC234", "admin@example.com", `${BRAND.name} Admin`);
expect(url.startsWith("otpauth://totp/")).toBe(true);
expect(url).toContain("secret=ABC234");
expect(url).toContain(encodeURIComponent(BRAND.name));
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"],
"compilerOptions": {
"types": ["node"]
}
}