Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+1031
View File
@@ -0,0 +1,1031 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
cookRecipeInputSchema,
createUserRecipeInputSchema,
idParamSchema,
rateRecipeInputSchema,
recipeQuerySchema,
substitutionQuerySchema,
} from "@app/validation";
import {
checkRecipeSafety,
deriveRecipeAllergens,
scaleIngredients,
type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { allocateFefo, classifyExpiry } from "@app/inventory-engine";
import { computeRecipeNutrition, scaleNutrition } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
import {
languageCandidates,
resolveIngredientNames,
resolveRecipeTranslation,
userLanguageTag,
} from "../lib/contentLanguage.js";
import {
emitEvent,
getActiveHouseholdId,
requireActiveHousehold,
todayIso,
} from "../lib/helpers.js";
import { requireFeature } from "../lib/entitlements.js";
/** Recept: sök, detalj, betyg, favoriter, "jag har lagat", substitutioner, användarrecept. */
export async function recipeRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recipes", auth, async (req) => {
const q = parse(recipeQuerySchema, req.query);
const conditions = [eq(schema.recipes.status, "published")];
if (q.search) {
conditions.push(
or(
ilike(schema.recipes.titleSv, `%${q.search}%`),
ilike(schema.recipes.descriptionSv, `%${q.search}%`),
)!,
);
}
if (q.cuisine) conditions.push(eq(schema.recipes.cuisine, q.cuisine));
if (q.mealType) conditions.push(sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`);
if (q.tags)
for (const tag of q.tags) conditions.push(sql`${tag} = ANY(${schema.recipes.tags})`);
if (q.method) conditions.push(sql`${q.method} = ANY(${schema.recipes.methods})`);
if (q.maxTotalMinutes) conditions.push(lte(schema.recipes.totalTimeMinutes, q.maxTotalMinutes));
if (q.difficulty) conditions.push(eq(schema.recipes.difficulty, q.difficulty));
if (q.creatorUserId) conditions.push(eq(schema.recipes.creatorUserId, q.creatorUserId));
if (q.maxKcalPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'kcal')::float <= ${q.maxKcalPerPortion}`,
);
}
if (q.minProteinPerPortion) {
conditions.push(
sql`(${schema.recipes.nutritionPerPortion}->>'proteinG')::float >= ${q.minProteinPerPortion}`,
);
}
if (q.maxCostMinorPerPortion) {
conditions.push(lte(schema.recipes.estimatedCostMinorPerPortion, q.maxCostMinorPerPortion));
}
// Deterministisk allergifiltrering på databasnivå (spec §61.2)
if (q.excludeAllergens) {
for (const allergen of q.excludeAllergens) {
conditions.push(sql`NOT (${allergen} = ANY(${schema.recipes.allergens}))`);
}
}
const orderBy =
q.sort === "rating"
? desc(schema.recipes.ratingAverage)
: q.sort === "cooked"
? desc(schema.recipes.cookCount)
: q.sort === "newest"
? desc(schema.recipes.createdAt)
: q.sort === "time"
? schema.recipes.totalTimeMinutes
: q.sort === "cost"
? schema.recipes.estimatedCostMinorPerPortion
: desc(schema.recipes.cookCount);
const rows = await app.db
.select({
id: schema.recipes.id,
slug: schema.recipes.slug,
titleSv: schema.recipes.titleSv,
descriptionSv: schema.recipes.descriptionSv,
cuisine: schema.recipes.cuisine,
mealTypes: schema.recipes.mealTypes,
tags: schema.recipes.tags,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
portions: schema.recipes.portions,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
allergens: schema.recipes.allergens,
spiceLevel: schema.recipes.spiceLevel,
estimatedCostMinorPerPortion: schema.recipes.estimatedCostMinorPerPortion,
costCurrency: sql<string>`'SEK'`,
difficulty: schema.recipes.difficulty,
imageUrls: schema.recipes.imageUrls,
ratingAverage: schema.recipes.ratingAverage,
ratingCount: schema.recipes.ratingCount,
cookCount: schema.recipes.cookCount,
verificationStatus: schema.recipes.verificationStatus,
creatorDisplayName: schema.recipes.creatorDisplayName,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(and(...conditions))
.orderBy(orderBy)
.limit(q.limit)
.offset(q.offset);
// Titlar på användarens språk där publicerad översättning finns (i18n M3).
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
let titleMap = new Map<string, string>();
if (!candidates.includes("sv") && rows.length > 0) {
const translations = await app.db
.select({
recipeId: schema.recipeTranslations.recipeId,
languageTag: schema.recipeTranslations.languageTag,
title: schema.recipeTranslations.title,
})
.from(schema.recipeTranslations)
.where(
and(
inArray(
schema.recipeTranslations.recipeId,
rows.map((r) => r.id),
),
inArray(schema.recipeTranslations.languageTag, candidates),
eq(schema.recipeTranslations.status, "published"),
),
);
for (const candidate of [...candidates].reverse()) {
for (const tr of translations)
if (tr.languageTag === candidate) titleMap.set(tr.recipeId, tr.title);
}
}
return {
recipes: rows.map((r) => ({ ...r, title: titleMap.get(r.id) ?? r.titleSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
app.get("/v1/recipes/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const recipe = await loadFullRecipe(app, id);
// Personlig säkerhetskontroll deterministisk (spec §61.2).
const [prefs] = await app.db
.select()
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, req.userId))
.limit(1);
let safety: { safe: boolean; violations: unknown[] } = { safe: true, violations: [] };
if (prefs) {
const info = await ingredientSafetyMap(
app,
recipe.ingredients.map((i) => i.canonicalIngredientId),
);
const violations = checkRecipeSafety(
{
ingredients: recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: prefs.allergens,
dietPattern: prefs.dietPattern,
religiousRule: prefs.religiousRule,
avoidIngredientIds: prefs.avoidIngredientIds,
spiceLevelMax: prefs.spiceLevelMax,
},
info,
);
safety = { safe: !violations.some((v) => v.severity === "blocker"), violations };
}
// Varianter (spec §16)
const variants = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
variantType: schema.recipes.variantType,
})
.from(schema.recipes)
.where(
and(
or(
eq(schema.recipes.variantOfRecipeId, id),
recipe.variantOfRecipeId ? eq(schema.recipes.id, recipe.variantOfRecipeId) : sql`false`,
),
eq(schema.recipes.status, "published"),
),
);
const [myRating] = await app.db
.select()
.from(schema.recipeRatings)
.where(
and(eq(schema.recipeRatings.recipeId, id), eq(schema.recipeRatings.userId, req.userId)),
)
.limit(1);
const [favorite] = await app.db
.select()
.from(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
)
.limit(1);
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
app.db,
recipe.ingredients.map((i) => i.canonicalIngredientId),
languageTag,
);
return {
...recipe,
language: translation?.language ?? "sv",
title: translation?.title ?? recipe.titleSv,
description: translation ? translation.description : recipe.descriptionSv,
storageGuidance: translation ? translation.storageGuidance : recipe.storageGuidanceSv,
ingredients: recipe.ingredients.map((i) => ({
...i,
displayName: ingredientNames.get(i.canonicalIngredientId) ?? i.displayNameSv,
})),
steps: recipe.steps.map((s) => {
const ts = translation?.steps.get(s.stepNumber);
return { ...s, instruction: ts?.instruction ?? s.instructionSv, tip: ts?.tip ?? s.tip };
}),
safety,
variants,
myRating: myRating ?? null,
isFavorite: Boolean(favorite),
};
});
/** Skala recept (Cooking Mode: "skala till sex personer"). */
app.get("/v1/recipes/:id/scaled", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const portions = Number((req.query as { portions?: string }).portions ?? 4);
if (!Number.isInteger(portions) || portions < 1 || portions > 24) {
throw errors.badRequest("portions måste vara 124.");
}
const recipe = await loadFullRecipe(app, id);
const scaled = scaleIngredients(
recipe.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
recipe.portions,
portions,
);
return {
recipeId: id,
portions,
ingredients: scaled,
nutritionPerPortion: recipe.nutritionPerPortion,
};
});
app.post("/v1/recipes/:id/rate", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(rateRecipeInputSchema, req.body);
await loadFullRecipe(app, id);
await app.db
.insert(schema.recipeRatings)
.values({
recipeId: id,
userId: req.userId,
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
})
.onConflictDoUpdate({
target: [schema.recipeRatings.recipeId, schema.recipeRatings.userId],
set: {
stars: input.stars,
feedbackTags: input.feedbackTags,
comment: input.comment ?? null,
updatedAt: new Date(),
},
});
// Uppdatera aggregat
const [agg] = await app.db
.select({
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
count: sql<number>`count(*)`,
})
.from(schema.recipeRatings)
.where(eq(schema.recipeRatings.recipeId, id));
await app.db
.update(schema.recipes)
.set({
ratingAverage: agg ? Number(agg.avg) : null,
ratingCount: agg ? Number(agg.count) : 0,
})
.where(eq(schema.recipes.id, id));
// Smaksignaler ur feedback (spec §30) explicit användarsignal.
const tagToAxis: Record<
string,
{ axis: "spice" | "salt" | "acid" | "creaminess"; dir: number }
> = {
too_spicy: { axis: "spice", dir: -1 },
too_mild: { axis: "spice", dir: 1 },
too_salty: { axis: "salt", dir: -1 },
too_sour: { axis: "acid", dir: -1 },
too_little_sauce: { axis: "creaminess", dir: 1 },
};
for (const tag of input.feedbackTags) {
const mapping = tagToAxis[tag];
if (mapping) {
await app.db.insert(schema.tasteSignals).values({
userId: req.userId,
axis: mapping.axis,
direction: mapping.dir,
strength: 0.7,
origin: "user_stated",
refRecipeId: id,
});
}
}
await emitEvent(app.db, {
type: "RECIPE_RATED",
payload: { recipeId: id, stars: input.stars, feedbackTags: input.feedbackTags },
userId: req.userId,
correlationId: req.correlationId,
});
return { ok: true };
});
app.post("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.insert(schema.recipeFavorites)
.values({ recipeId: id, userId: req.userId })
.onConflictDoNothing();
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.delete("/v1/recipes/:id/favorite", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
await app.db
.delete(schema.recipeFavorites)
.where(
and(eq(schema.recipeFavorites.recipeId, id), eq(schema.recipeFavorites.userId, req.userId)),
);
await app.db
.update(schema.recipes)
.set({ favoriteCount: sql`GREATEST(${schema.recipes.favoriteCount} - 1, 0)` })
.where(eq(schema.recipes.id, id));
return { ok: true };
});
app.get("/v1/recipes/favorites/mine", auth, async (req) => {
const rows = await app.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
totalTimeMinutes: schema.recipes.totalTimeMinutes,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
imageUrls: schema.recipes.imageUrls,
})
.from(schema.recipeFavorites)
.innerJoin(schema.recipes, eq(schema.recipeFavorites.recipeId, schema.recipes.id))
.where(eq(schema.recipeFavorites.userId, req.userId))
.orderBy(desc(schema.recipeFavorites.createdAt));
return { recipes: rows };
});
/**
* "Jag har lagat detta" (spec §23): förslag på lagerdragning (FEFO),
* måltidslogg per ätare, matlådor, events. Kärnflödet i hela appen.
*/
app.post("/v1/recipes/:id/cook", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const input = parse(cookRecipeInputSchema, req.body);
const recipe = await loadFullRecipe(app, id);
const householdId = await requireActiveHousehold(app.db, req.userId);
const date = input.date ?? todayIso();
// 1. Dra lager enligt FEFO (spec §17: prioritera utgångsdatum)
const deductions: Array<{ itemId: string; quantity: number; unit: string; name: string }> = [];
if (input.deductInventory) {
const factor = input.portionsCooked / recipe.portions;
const overrides = new Map(input.inventoryOverrides.map((o) => [o.canonicalIngredientId, o]));
for (const ing of recipe.ingredients) {
if (ing.optional) continue;
const override = overrides.get(ing.canonicalIngredientId);
const requiredQty = override ? override.quantityUsed : ing.quantity * factor;
const requiredUnit = override ? override.unit : ing.unit;
if (requiredQty <= 0) continue;
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, ing.canonicalIngredientId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
if (stock.length === 0) continue;
const [info] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, ing.canonicalIngredientId))
.limit(1);
const allocation = allocateFefo(
requiredQty,
requiredUnit,
stock.map((s) => ({
id: s.id,
canonicalIngredientId: s.canonicalIngredientId,
quantity: s.quantity,
unit: s.unit,
bestBeforeDate: s.bestBeforeDate,
useByDate: s.useByDate,
openedAt: s.openedAt,
frozenAt: s.frozenAt,
thawedAt: s.thawedAt,
purchasedAt: s.purchasedAt,
})),
{ densityGPerMl: info?.densityGPerMl, gramsPerPiece: info?.gramsPerPiece },
);
for (const alloc of allocation.allocations) {
const item = stock.find((s) => s.id === alloc.itemId)!;
const newQty = Math.max(0, Math.round((item.quantity - alloc.quantity) * 1000) / 1000);
await app.db.insert(schema.inventoryTransactions).values({
householdId,
inventoryItemId: alloc.itemId,
type: "cook_use",
quantityDelta: -(item.quantity - newQty),
unit: item.unit,
refType: "recipe_cook",
refId: id,
actorUserId: req.userId,
});
await app.db
.update(schema.inventoryItems)
.set({
quantity: newQty,
depletedAt: newQty <= 0 ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, alloc.itemId));
deductions.push({
itemId: alloc.itemId,
quantity: alloc.quantity,
unit: alloc.unit,
name: item.displayName,
});
}
}
}
// 2. Logga måltid per ätare med portionsandel (spec §7: individuellt)
const eaters =
input.eaters.length > 0 ? input.eaters : [{ userId: req.userId, portionFraction: 1 }];
const mealIds: string[] = [];
for (const eater of eaters) {
const nutrition = scaleNutrition(recipe.nutritionPerPortion, eater.portionFraction);
const [meal] = await app.db
.insert(schema.meals)
.values({
userId: eater.userId,
householdId,
date,
mealType: input.mealType,
source: "cooked_recipe",
recipeId: id,
titleSv: recipe.titleSv,
portionFraction: eater.portionFraction,
nutrition,
nutritionIsEstimate: false,
})
.returning();
mealIds.push(meal!.id);
await emitEvent(app.db, {
type: "MEAL_LOGGED",
payload: {
mealId: meal!.id,
mealType: input.mealType,
kcal: nutrition.kcal,
source: "cooked_recipe",
},
userId: eater.userId,
householdId,
correlationId: req.correlationId,
});
}
// 3. Matlådor (spec §24)
let mealBoxId: string | null = null;
if (input.mealBoxPortions > 0) {
const locationId =
input.mealBoxStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, householdId),
eq(schema.storageLocations.type, input.mealBoxFrozen ? "freezer" : "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!locationId) throw errors.badRequest("Ingen förvaringsplats för matlådor hittades.");
const useByDays = input.mealBoxFrozen ? 90 : 3;
const recommendedUseBy = new Date(Date.now() + useByDays * 86_400_000)
.toISOString()
.slice(0, 10);
const [box] = await app.db
.insert(schema.mealBoxes)
.values({
householdId,
recipeId: id,
titleSv: recipe.titleSv,
portions: input.mealBoxPortions,
portionsRemaining: input.mealBoxPortions,
nutritionPerPortion: recipe.nutritionPerPortion,
cookedAt: date,
storageLocationId: locationId,
frozen: input.mealBoxFrozen,
recommendedUseBy,
})
.returning();
mealBoxId = box!.id;
await emitEvent(app.db, {
type: "MEAL_BOX_CREATED",
payload: { mealBoxId: box!.id, portions: input.mealBoxPortions },
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
}
// 4. Statistik + event
await app.db.insert(schema.recipeCooks).values({
recipeId: id,
userId: req.userId,
householdId,
portionsCooked: input.portionsCooked,
});
await app.db
.update(schema.recipes)
.set({ cookCount: sql`${schema.recipes.cookCount} + 1` })
.where(eq(schema.recipes.id, id));
await emitEvent(app.db, {
type: "RECIPE_COOKED",
payload: {
recipeId: id,
portions: input.portionsCooked,
mealBoxPortions: input.mealBoxPortions,
},
userId: req.userId,
householdId,
correlationId: req.correlationId,
});
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
});
/** Substitutionsförslag för en ingrediens (spec §20). */
app.get("/v1/substitutions", auth, async (req) => {
const q = parse(substitutionQuerySchema, req.query);
const subs = await app.db
.select({
sub: schema.substitutions,
toName: schema.canonicalIngredients.nameSv,
})
.from(schema.substitutions)
.innerJoin(
schema.canonicalIngredients,
eq(schema.substitutions.toIngredientId, schema.canonicalIngredients.id),
)
.where(eq(schema.substitutions.fromIngredientId, q.fromIngredientId))
.orderBy(desc(schema.substitutions.priority));
return {
substitutions: subs.map((s) => ({
...s.sub,
toNameSv: s.toName,
contextWarning:
q.context && s.sub.notRecommendedFor.includes(q.context)
? `Rekommenderas inte för ${q.context}.`
: null,
})),
};
});
/**
* Ingrediens-sök (manuell registrering, spec §9; i18n M2).
* Söker i svenska namn/alias OCH i publicerade översättningar för
* användarens språk; svaret bär namn upplöst till användarens språk.
*/
app.get("/v1/ingredients", auth, async (req) => {
const search = String((req.query as { search?: string }).search ?? "").trim();
const languageTag = await userLanguageTag(app.db, req.userId);
const candidates = languageCandidates(languageTag);
// i18n M7: accentokänsligt (unaccent) + tolerant mot stavfel (pg_trgm).
// "creme" hittar "crème fraiche", "jordgubar" hittar "jordgubbar".
const pattern = `%${search}%`;
const translationMatch = sql`EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = ${schema.canonicalIngredients.id}
AND it.status = 'published'
AND it.language_tag IN ${candidates}
AND (unaccent(it.name) ILIKE unaccent(${pattern})
OR similarity(it.name, ${search}) > 0.35
OR EXISTS (SELECT 1 FROM unnest(it.aliases) ta WHERE unaccent(ta) ILIKE unaccent(${pattern})))
)`;
const conditions = search
? or(
sql`unaccent(${schema.canonicalIngredients.nameSv}) ILIKE unaccent(${pattern})`,
sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) > 0.35`,
sql`EXISTS (SELECT 1 FROM unnest(${schema.canonicalIngredients.aliases}) a WHERE unaccent(a) ILIKE unaccent(${pattern}))`,
...(candidates.includes("sv") ? [] : [translationMatch]),
)
: undefined;
const rows = await app.db
.select({
id: schema.canonicalIngredients.id,
nameSv: schema.canonicalIngredients.nameSv,
category: schema.canonicalIngredients.category,
defaultUnit: schema.canonicalIngredients.defaultUnit,
allergens: schema.canonicalIngredients.allergens,
})
.from(schema.canonicalIngredients)
.where(conditions)
.orderBy(
search
? sql`similarity(${schema.canonicalIngredients.nameSv}, ${search}) DESC, ${schema.canonicalIngredients.nameSv}`
: schema.canonicalIngredients.nameSv,
)
.limit(30);
const names = await resolveIngredientNames(
app.db,
rows.map((r) => r.id),
languageTag,
);
return {
ingredients: rows.map((r) => ({ ...r, name: names.get(r.id) ?? r.nameSv })),
language: candidates.includes("sv") ? "sv" : languageTag,
};
});
/**
* Marknadsprofil för näringsvisning + allergenframhävning (i18n M6).
* Fallback: EU. Styr endast VISNING säkerhetsfiltrering per användare
* (spec §61.2) påverkas aldrig av marknadsprofilen.
*/
app.get("/v1/i18n/nutrition-profile", auth, async (req) => {
const requested = String((req.query as { region?: string }).region ?? "").toUpperCase();
const region =
requested || (await loadLocalePreferences(app.db, req.userId)).regionCode.toUpperCase();
const [profile] =
(await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, region))
.limit(1)) ?? [];
const [fallback] = profile
? [profile]
: await app.db
.select()
.from(schema.nutritionDisplayProfiles)
.where(eq(schema.nutritionDisplayProfiles.regionCode, "EU"))
.limit(1);
const effective = fallback!;
const allergens = await app.db
.select({ allergen: schema.allergenMarketRules.allergen })
.from(schema.allergenMarketRules)
.where(eq(schema.allergenMarketRules.regionCode, effective.regionCode));
return {
regionCode: effective.regionCode,
requestedRegion: region,
energyDisplay: effective.energyDisplay,
saltDisplay: effective.saltDisplay,
energyLabelKey: effective.energyLabelKey,
highlightAllergens: allergens.map((a) => a.allergen).sort(),
};
});
/** Enhetsetiketter per språk (i18n M2) för klienter som inte vill hårdkoda. */
app.get("/v1/i18n/units", auth, async (req) => {
const languageTag = String((req.query as { languageTag?: string }).languageTag ?? "sv");
const candidates = languageCandidates(languageTag);
const rows = await app.db
.select()
.from(schema.unitTranslations)
.where(inArray(schema.unitTranslations.languageTag, candidates));
const byUnit = new Map<string, (typeof rows)[number]>();
for (const candidate of [...candidates].reverse())
for (const r of rows) if (r.languageTag === candidate) byUnit.set(r.unitCode, r);
return {
languageTag,
units: [...byUnit.values()].map((r) => ({
unitCode: r.unitCode,
abbreviation: r.abbreviation,
name: r.name,
})),
};
});
/**
* Användarrecept (spec §35): strukturerat direkt, eller fritext som AAMOS
* strukturerar. Näring/allergener beräknas ALLTID deterministiskt här.
* Publiceringsflöde: submitted → AI-kontroll → moderation → published.
*/
app.post("/v1/recipes", auth, async (req, reply) => {
await requireFeature(app.db, req.userId, "communityPublish", "Egna recept");
const input = parse(createUserRecipeInputSchema, req.body);
const [user] = await app.db
.select({ displayName: schema.users.displayName })
.from(schema.users)
.where(eq(schema.users.id, req.userId))
.limit(1);
let structured: {
titleSv: string;
descriptionSv: string;
ingredients: Array<{
canonicalIngredientId: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
steps: Array<{
instructionSv: string;
timerSeconds?: number | null;
temperatureC?: number | null;
}>;
prepMin: number;
cookMin: number;
portions: number;
cuisine: string;
mealTypes: string[];
tags: string[];
methods: string[];
equipment: string[];
difficulty: string;
spiceLevel: number;
};
if (input.mode === "free_text") {
const result = await app.aamos.runTask(
"STRUCTURE_RECIPE_TEXT",
{ text: input.text, marketLocale: "sv-SE" },
{
correlationId: req.correlationId,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status !== "ok" || !result.output) {
throw errors.badRequest(
"Receptet kunde inte tolkas automatiskt just nu. Prova strukturerad inmatning.",
);
}
const out = result.output;
const ingredients = out.ingredients
.filter((i) => i.canonicalIngredientId != null && i.quantity != null && i.unit != null)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId!,
displayNameSv: i.displayNameSv,
quantity: i.quantity!,
unit: i.unit!,
optional: i.optional,
}));
if (ingredients.length === 0) {
throw errors.badRequest(
"Inga ingredienser kunde tolkas säkert. Komplettera och försök igen.",
);
}
structured = {
titleSv: out.titleSv ?? "Mitt recept",
descriptionSv: out.descriptionSv ?? "",
ingredients,
steps: out.steps,
prepMin: out.prepTimeMinutes ?? 15,
cookMin: out.cookTimeMinutes ?? 20,
portions: out.portions ?? 4,
cuisine: out.suggestedCuisine ?? "international",
mealTypes: out.suggestedMealTypes.length > 0 ? out.suggestedMealTypes : ["dinner"],
tags: [],
methods: [],
equipment: [],
difficulty: "easy",
spiceLevel: 0,
};
} else {
structured = {
titleSv: input.titleSv,
descriptionSv: input.descriptionSv,
ingredients: input.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
})),
steps: input.steps.map((s) => ({
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
prepMin: input.prepTimeMinutes,
cookMin: input.cookTimeMinutes,
portions: input.portions,
cuisine: input.cuisine,
mealTypes: input.mealTypes,
tags: input.tags,
methods: input.methods,
equipment: input.equipment,
difficulty: input.difficulty,
spiceLevel: input.spiceLevel,
};
}
// Deterministisk näring + allergener (spec §61.12)
const ingredientIds = structured.ingredients.map((i) => i.canonicalIngredientId);
const dbIngredients = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const sourceMap = new Map(
dbIngredients.map((i) => [
i.id,
{
nutritionPer100: i.nutritionPer100,
densityGPerMl: i.densityGPerMl,
gramsPerPiece: i.gramsPerPiece,
},
]),
);
const missing = ingredientIds.filter((id) => !sourceMap.has(id));
if (missing.length > 0) {
throw errors.badRequest(`Okända ingredienser: ${missing.join(", ")}`, { missing });
}
const nutrition = computeRecipeNutrition(
structured.ingredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
})),
structured.portions,
sourceMap,
);
const safetyInfo = await ingredientSafetyMap(app, ingredientIds);
const allergens = deriveRecipeAllergens(
structured.ingredients.filter((i) => !i.optional).map((i) => i.canonicalIngredientId),
safetyInfo,
);
const slug = `${structured.titleSv
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/ö/g, "o")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60)}-${Date.now().toString(36)}`;
const [recipe] = await app.db
.insert(schema.recipes)
.values({
slug,
titleSv: structured.titleSv,
descriptionSv: structured.descriptionSv,
cuisine: structured.cuisine as never,
mealTypes: structured.mealTypes as never,
tags: structured.tags,
methods: structured.methods,
equipment: structured.equipment,
difficulty: structured.difficulty as never,
prepTimeMinutes: structured.prepMin,
cookTimeMinutes: structured.cookMin,
totalTimeMinutes: structured.prepMin + structured.cookMin,
portions: structured.portions,
nutritionPerPortion: nutrition.perPortion,
allergens,
spiceLevel: structured.spiceLevel,
dna: {
cuisine: structured.cuisine as never,
vegetables: [],
flavorProfile: [],
spiceLevel: structured.spiceLevel,
method: (structured.methods[0] ?? "stovetop") as never,
timeMinutes: structured.prepMin + structured.cookMin,
calories: nutrition.perPortion.kcal,
proteinGrams: Math.round(nutrition.perPortion.proteinG),
},
status: "submitted",
verificationStatus: "unverified",
sourceType: "user_generated",
creatorUserId: req.userId,
creatorDisplayName: user?.displayName ?? "Okänd",
})
.returning();
await app.db.insert(schema.recipeIngredients).values(
structured.ingredients.map((i, idx) => ({
recipeId: recipe!.id,
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit as never,
optional: i.optional,
sortOrder: idx,
})),
);
await app.db.insert(schema.recipeSteps).values(
structured.steps.map((s, idx) => ({
recipeId: recipe!.id,
stepNumber: idx + 1,
instructionSv: s.instructionSv,
timerSeconds: s.timerSeconds ?? null,
temperatureC: s.temperatureC ?? null,
})),
);
// AI-kontroll + moderering sker asynkront i workern (spec §35 steg 24).
await app.jobQueue.add("MODERATE_RECIPE", {
jobType: "MODERATE_RECIPE",
recipeId: recipe!.id,
correlationId: req.correlationId,
});
await emitEvent(app.db, {
type: "RECIPE_CREATED",
payload: { recipeId: recipe!.id, sourceType: "user_generated" },
userId: req.userId,
correlationId: req.correlationId,
});
return reply.status(201).send({
recipe,
uncomputableIngredients: nutrition.uncomputableIngredientIds,
message: "Receptet är inskickat och granskas innan publicering.",
});
});
}
async function loadFullRecipe(app: FastifyInstance, id: string) {
const [recipe] = await app.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, id))
.limit(1);
if (!recipe) throw errors.notFound("Receptet finns inte.");
const ingredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, id))
.orderBy(schema.recipeIngredients.sortOrder);
const steps = await app.db
.select()
.from(schema.recipeSteps)
.where(eq(schema.recipeSteps.recipeId, id))
.orderBy(schema.recipeSteps.stepNumber);
return { ...recipe, ingredients, steps };
}
async function ingredientSafetyMap(
app: FastifyInstance,
ids: string[],
): Promise<Map<string, IngredientSafetyInfo>> {
if (ids.length === 0) return new Map();
const rows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ids));
return new Map(
rows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
}