Files
Cibello-app/apps/api/src/routes/recipes.ts
T
Sven (AAMOS AI) bbe7526ef3 fix(3b): delad kompletteringsväg för legacy /cook + deterministiska cooking-assumptions
- cookRecipeInputSchema utökas med actualPortionsEaten, leftoverEstimatePortions, leftoverNote
- completeCookingSession-wrapper i apps/api/src/lib/cooking.ts hanterar summavalidering,
  persistens av svar, profiluppdateringar och analytics (started+completed) för båda vägarna
- Legacy POST /v1/recipes/:id/cook använder wrappern med emitStartedEvent
- GET /v1/recipes/:id/cooking-assumptions väljer deterministiskt bland icke-valfria ingredienser
- i18n: nyckel cooked.portionsSumExceedsPlanned i samtliga 12 lokaler + server-i18n
- Nya tester för legacy /cook och cooking-assumptions med valfri första ingrediens

Refs: steg 3b-fix, granskningsrunda 2026-08-07
2026-08-07 05:13:35 +07:00

871 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { markMilestone, 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";
import { completeCookingSession } from "../lib/cooking.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recipes", auth, async (req) => {
const q = parse(recipeQuerySchema, req.query);
const conditions = [eq(schema.recipes.status, "published")];
if (q.search) {
conditions.push(
or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
)!,
);
}
if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine));
if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`);
if (q.tags)
for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`);
if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`);
if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes));
if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty));
if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId));
if (q.maxKcalPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`,
);
}
if (q.minProteinPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`,
);
}
if (q.maxCostMinorPerPortion) {
conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion));
}
// Deterministisk allergifiltrering på databasnivå (spec §61.2)
if (q.excludeAllergens) {
for (const allergen of q.excludeAllergens) {
conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`);
}
}
const orderBy =
q.sort === "rating"
? desc(schema.recipes.ratingAverage)
: q.sort === "cooked"
? desc(schema.recipes.cookCount)
: q.sort === "newest"
? desc(schema.recipes.createdAt)
: q.sort === "time"
? schema.recipes.totalTimeMinutes
: q.sort === "cost"
? schema.recipes.estimatedCostMinorPerPortion
: desc(schema.recipes.cookCount);
const rows = await app.db
.select({
id: schema.recipes.id,
slug: schema.recipes.slug,
titleSv: schema.recipes.titleSv,
descriptionSv: schema.recipes.descriptionSv,
cuisine: schema.recipes.cuisine,
mealTypes: schema.recipes.mealTypes,
tags: schema.recipes.tags,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
portions: schema.recipes.portions,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
allergens: schema.recipes.allergens,
spiceLevel: schema.recipes.spiceLevel,
estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion,
costCurrency: sql<string>`'SEK'`,
difficulty: schema.recipes.difficulty,
imageUrls: schema.recipes.imageUrls,
ratingAverage: schema.recipes.ratingAverage,
ratingCount: schema.recipes.ratingCount,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
creatorDisplayName: schema.recipes.creatorDisplayName,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(and(...conditions))
.orderBy(orderBy)
.limit(q.limit)
.offset(q.offset);
// Titlar på användarens språk där publicerad översättning finns (i18n M3).
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
let titleMap = new Map<string, string>();
if (!candidates.includes("sv") && rows.length > 0) {
const translations = await app.db
.select({
recipeId: schema.recipeTranslations.recipeId,
languageTag: schema.recipeTranslations.languageTag,
title: schema.recipeTranslations.title,
})
.from(schema.recipeTranslations)
.where(
and(
inArray(
schema.recipeTranslations.recipeId,
rows.map((r) => r.id),
),
inArray(schema.recipeTranslations.languageTag, candidates),
eq(schema.recipeTranslations.status, "published"),
),
);
for (const candidate of [...candidates].reverse()) {
for (const tr of translations)
if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title);
}
}
return {
recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
app.get("/v1/recipes/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const recipe = await loadFullRecipe(app, id);
// Personlig säkerhetskontroll deterministisk (spec §61.2).
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] };
if (prefs) {
const info = await ingredientSafetyMap(
app,
recipe.ingredients.map((i) => i.canonicalIngredientId),
);
const violations = checkRecipeSafety(
{
ingredients: recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: prefs.allergens,
dietPattern: prefs.dietPattern,
religiousRule: prefs.religiousRule,
avoidIngredientIds: prefs.avoidIngredientIds,
spiceLevelMax: prefs.spiceLevelMax,
},
info,
);
safety = { safe: !violations.some((v) => v.severity === "blocker"), violations };
}
// Varianter (spec §16)
const variants = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(
and(
or(
eq(schema.recipes.variantOfRecipeId, id),
recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`,
),
eq(schema.recipes.status, "published"),
),
);
const [myRating] = await app.db
.select()
.from(schema.recipeRatings)
.where(
and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)),
)
.limit(1);
const [favorite] = await app.db
.select()
.from(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
)
.limit(1);
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt");
}
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
app.db,
recipe.ingredients.map((i) => i.canonicalIngredientId),
languageTag,
);
return {
...recipe,
language: translation?.language ?? "sv",
title: translation?.title ?? recipe.titleSv,
description: translation ? translation.description : recipe.descriptionSv,
storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv,
ingredients: recipe.ingredients.map((i) => ({
...i,
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
})),
steps: recipe.steps.map((s) => {
const ts = translation?.steps.get(s.stepNumber);
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
}),
safety,
variants,
myRating: myRating ?? null,
isFavorite: Boolean(favorite),
};
});
/** Skala recept (Cooking Mode: "skala till sex personer"). */
app.get("/v1/recipes/:id/scaled", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const portions = Number((req.query as { portions?: string }).portions ?? 4);
if (!Number.isInteger(portions) || portions < 1 || portions > 24) {
throw errors.badRequest("portions måste vara 124.");
}
const recipe = await loadFullRecipe(app, id);
const scaled = scaleIngredients(
recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
recipe.portions,
portions,
);
return {
recipeId: id,
portions,
ingredients: scaled,
nutritionPerPortion: recipe.nutritionPerPortion,
};
});
app.post("/v1/recipes/:id/rate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(rateRecipeInputSchema, req.body);
await loadFullRecipe(app, id);
await app.db
.insert(schema.recipeRatings)
.values({
recipeId: id,
userId: req.userId,
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
})
.onConflictDoUpdate({
target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId],
set: {
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
updatedAt: new Date(),
},
});
// Uppdatera aggregat
const [agg] = await app.db
.select({
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
count: sql<number>`count(*)`,
})
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.recipeId, id));
await app.db
.update(schema.recipes)
.set({
ratingAverage: agg ? Number(agg.avg) : null,
ratingCount: agg ? Number(agg.count) : 0,
})
.where(eq(schema.recipes.id, id));
// Smaksignaler ur feedback (spec §30) explicit användarsignal.
const tagToAxis: Record<
string,
{ axis: "spice" | "salt" | "acid" | "creaminess"; dir: number }
> = {
too_spicy: { axis: "spice", dir: -1 },
too_mild: { axis: "spice", dir: 1 },
too_salty: { axis: "salt", dir: -1 },
too_sour: { axis: "acid", dir: -1 },
too_little_sauce: { axis: "creaminess", dir: 1 },
};
for (const tag of input.feedbackTags) {
const mapping = tagToAxis[tag];
if (mapping) {
await app.db.insert(schema.tasteSignals).values({
userId: req.userId,
axis: mapping.axis,
direction: mapping.dir,
strength: 0.7,
origin: "user_stated",
refRecipeId: id,
});
}
}
await emitEvent(app.db, {
type: "RECIPE_RATED",
payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags },
userId: req.userId,
correlationId: req.correlationId,
});
return { ok: true };
});
app.post("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.insert(schema.recipeFavorites)
.values({ recipeId: id, userId: req.userId })
.onConflictDoNothing();
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeSavedOrStartedAt");
}
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);
await loadFullRecipe(app, id);
const householdId = await requireActiveHousehold(app.db, req.userId);
// Bakåtkompatibel shortcut: skapa session + complete direkt.
const [session] = await app.db
.insert(schema.cookingSessions)
.values({
recipeId: id,
householdId,
startedByUserId: req.userId,
status: "started",
plannedPortions: input.portionsCooked,
plannedMealType: input.mealType,
startedAt: new Date(),
})
.returning();
const result = await completeCookingSession(app, session!, req.userId, input, req.correlationId, {
emitStartedEvent: true,
});
return { sessionId: session!.id, ...result };
});
/** Substitutionsförslag för en ingrediens (spec §20). */
app.get("/v1/substitutions", auth, async (req) => {
const q = parse(substitutionQuerySchema, req.query);
const subs = await app.db
.select({
sub: schema.substitutions,
toName: schema.canonicalIngredients.nameSv,
})
.from(schema.substitutions)
.innerJoin(
schema.canonicalIngredients,
eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id),
)
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
.orderBy(desc(schema.substitutions.priority));
return {
substitutions: subs.map((s) => ({
...s.sub,
toNameSv: s.toName,
contextWarning:
q.context && s.sub.notRecommendedFor.includes(q.context)
? `Rekommenderas inte för ${q.context}.`
: null,
})),
};
});
/**
* Ingrediens-sök (manuell registrering, spec §9; i18n M2).
* Söker i svenska namn/alias OCH i publicerade översättningar för
* användarens språk; svaret bär namn upplöst till användarens språk.
*/
app.get("/v1/ingredients", auth, async (req) => {
const search = String((req.query as { search?: string }).search ?? "").trim();
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
// i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm).
// "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar".
const pattern = `%${search}%`;
const translationMatch = sql`EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = ${schema.canonicalIngredients.id}
AND it.status = 'published'
AND it.language_tag IN ${candidates}
AND (unaccent(it.name) ILIKE unaccent(${pattern})
OR similarity(it.name, ${search}) > 0.35
OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern})))
)`;
const conditions = search
? or(
sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`,
sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`,
sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`,
...(candidates.includes("sv") ? [] : [translationMatch]),
)
: undefined;
const rows = await app.db
.select({
id: schema.canonicalIngredients.id,
nameSv: schema.canonicalIngredients.nameSv,
category: schema.canonicalIngredients.category,
defaultUnit: schema.canonicalIngredients.defaultUnit,
allergens: schema.canonicalIngredients.allergens,
})
.from(schema.canonicalIngredients)
.where(conditions)
.orderBy(
search
? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}`
: schema.canonicalIngredients.nameSv,
)
.limit(30);
const names = await resolveIngredientNames(
app.db,
rows.map((r) => r.id),
languageTag,
);
return {
ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
/**
* Marknadsprofil för näringsvisning + allergenframhävning (i18n M6).
* Fallback: EU. Styr endast VISNING säkerhetsfiltrering per användare
* (spec §61.2) påverkas aldrig av marknadsprofilen.
*/
app.get("/v1/i18n/nutrition-profile", auth, async (req) => {
const requested = String((req.query as { region?: string }).region ?? "").toUpperCase();
const region =
requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase();
const [profile] =
(await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, region))
.limit(1)) ?? [];
const [fallback] = profile
? [profile]
: await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, "EU"))
.limit(1);
const effective = fallback!;
const allergens = await app.db
.select({ allergen: schema.allergenMarketRules.allergen })
.from(schema.allergenMarketRules)
.where(eq(schema.allergenMarketRules.regionCode, effective.regionCode));
return {
regionCode: effective.regionCode,
requestedRegion: region,
energyDisplay: effective.energyDisplay,
saltDisplay: effective.saltDisplay,
energyLabelKey: effective.energyLabelKey,
highlightAllergens: allergens.map((a) => a.allergen).sort(),
};
});
/** Enhetsetiketter per språk (i18n M2) för klienter som inte vill hårdkoda. */
app.get("/v1/i18n/units", auth, async (req) => {
const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv");
const candidates = languageCandidates(languageTag);
const rows = await app.db
.select()
.from(schema.unitTranslations)
.where(inArray(schema.unitTranslations.languageTag, candidates));
const byUnit = new Map<string, (typeof rows)[number]>();
for (const candidate of [...candidates].reverse())
for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r);
return {
languageTag,
units: [...byUnit.values()].map((r) => ({
unitCode: r.unitCode,
abbreviation: r.abbreviation,
name: r.name,
})),
};
});
/**
* Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS
* strukturerar. Näring/allergener beräknas ALLTID deterministiskt här.
* Publiceringsflöde: submitted → AI-kontroll → moderation → published.
*/
app.post("/v1/recipes", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "communityPublish", "Egna recept");
const input = parse(createUserRecipeInputSchema, req.body);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
let structured: {
titleSv: string;
descriptionSv: string;
ingredients: Array<{
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
steps: Array<{
instructionSv: string;
timerSeconds?: number | null;
temperatureC?: number | null;
}>;
prepMin: number;
cookMin: number;
portions: number;
cuisine: string;
mealTypes: string[];
tags: string[];
methods: string[];
equipment: string[];
difficulty: string;
spiceLevel: number;
};
if (input.mode === "free_text") {
const result = await app.aamos.runTask(
"STRUCTURE_RECIPE_TEXT",
{ text: input.text, marketLocale: "sv-SE" },
{
correlationId: req.correlationId,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status !== "ok" || !result.output) {
throw errors.badRequest(
"Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.",
);
}
const out = result.output;
const ingredients = out.ingredients
.filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId!,
displayNameSv: i.displayNameSv,
quantity: i.quantity!,
unit: i.unit!,
optional: i.optional,
}));
if (ingredients.length === 0) {
throw errors.badRequest(
"Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.",
);
}
structured = {
titleSv: out.titleSv ?? "Mitt recept",
descriptionSv: out.descriptionSv ?? "",
ingredients,
steps: out.steps,
prepMin: out.prepTimeMinutes ?? 15,
cookMin: out.cookTimeMinutes ?? 20,
portions: out.portions ?? 4,
cuisine: out.suggestedCuisine ?? "international",
mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
spiceLevel: 0,
};
} else {
structured = {
titleSv: input.titleSv,
descriptionSv: input.descriptionSv,
ingredients: input.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
steps: input.steps.map((s) => ({
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
prepMin: input.prepTimeMinutes,
cookMin: input.cookTimeMinutes,
portions: input.portions,
cuisine: input.cuisine,
mealTypes: input.mealTypes,
tags: input.tags,
methods: input.methods,
equipment: input.equipment,
difficulty: input.difficulty,
spiceLevel: input.spiceLevel,
};
}
// Deterministisk näring + allergener (spec §61.12)
const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId);
const dbIngredients = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const sourceMap = new Map(
dbIngredients.map((i) => [
i.id,
{
nutritionPer100: i.nutritionPer100,
densityGPerMl: i.densityGPerMl,
gramsPerPiece: i.gramsPerPiece,
},
]),
);
const missing = ingredientIds.filter((id) => !sourceMap.has(id));
if (missing.length > 0) {
throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing });
}
const nutrition = computeRecipeNutrition(
structured.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
})),
structured.portions,
sourceMap,
);
const safetyInfo = await ingredientSafetyMap(app, ingredientIds);
const allergens = deriveRecipeAllergens(
structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId),
safetyInfo,
);
const slug = `${structured.titleSv
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/ö/g, "o")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)}-${Date.now().toString(36)}`;
const [recipe] = await app.db
.insert(schema.recipes)
.values({
slug,
titleSv: structured.titleSv,
descriptionSv: structured.descriptionSv,
cuisine: structured.cuisine as never,
mealTypes: structured.mealTypes as never,
tags: structured.tags,
methods: structured.methods,
equipment: structured.equipment,
difficulty: structured.difficulty as never,
prepTimeMinutes: structured.prepMin,
cookTimeMinutes: structured.cookMin,
totalTimeMinutes: structured.prepMin + structured.cookMin,
portions: structured.portions,
nutritionPerPortion: nutrition.perPortion,
allergens,
spiceLevel: structured.spiceLevel,
dna: {
cuisine: structured.cuisine as never,
vegetables: [],
flavorProfile: [],
spiceLevel: structured.spiceLevel,
method: (structured.methods[0] ?? "stovetop") as never,
timeMinutes: structured.prepMin + structured.cookMin,
calories: nutrition.perPortion.kcal,
proteinGrams: Math.round(nutrition.perPortion.proteinG),
},
status: "submitted",
verificationStatus: "unverified",
sourceType: "user_generated",
creatorUserId: req.userId,
creatorDisplayName: user?.displayName ?? "Okänd",
})
.returning();
await app.db.insert(schema.recipeIngredients).values(
structured.ingredients.map((i, idx) => ({
recipeId: recipe!.id,
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
sortOrder: idx,
})),
);
await app.db.insert(schema.recipeSteps).values(
structured.steps.map((s, idx) => ({
recipeId: recipe!.id,
stepNumber: idx + 1,
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
);
// AI-kontroll + moderering sker asynkront i workern (spec §35 steg 24).
await app.jobQueue.add("MODERATE_RECIPE", {
jobType: "MODERATE_RECIPE",
recipeId: recipe!.id,
correlationId: req.correlationId,
});
await emitEvent(app.db, {
type: "RECIPE_CREATED",
payload: { recipeId: recipe!.id, sourceType: "user_generated" },
userId: req.userId,
correlationId: req.correlationId,
});
return reply.status(201).send({
recipe,
uncomputableIngredients: nutrition.uncomputableIngredientIds,
message: "Receptet är inskickat och granskas innan publicering.",
});
});
}
export 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,
},
]),
);
}