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