bbe7526ef3
- 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
871 lines
30 KiB
TypeScript
871 lines
30 KiB
TypeScript
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 §13–14): 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 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));
|
||
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.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.",
|
||
});
|
||
});
|
||
}
|
||
|
||
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,
|
||
},
|
||
]),
|
||
);
|
||
}
|