Initial commit (unpacked platform)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user