Files
Cibello-app/apps/api/src/routes/shopping.ts
T
2026-08-05 19:21:11 +07:00

407 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { StoreSection } from "@app/shared-types";
import {
addShoppingItemInputSchema,
completeShoppingInputSchema,
createShoppingListInputSchema,
idParamSchema,
updateShoppingItemInputSchema,
} from "@app/validation";
import { convert } from "@app/nutrition-engine";
import { errors, parse } from "../lib/errors.js";
import { emitEvent, requireActiveHousehold, requireMembership, todayIso } from "../lib/helpers.js";
/**
* Inköpslista (spec §27): dra av lager, slå ihop ingredienser, sortera per
* butiksavdelning, dela i hushållet, uppdatera lagret efter köp.
*/
const CATEGORY_TO_SECTION: Record<string, StoreSection> = {
mejeri: "mejeri",
kott_fagel: "kott_fagel",
fisk: "fisk",
gronsaker: "frukt_gront",
frukt: "frukt_gront",
spannmal: "skafferi",
baljvaxter: "skafferi",
skafferi: "skafferi",
konserver: "konserver",
kryddor: "kryddor_bak",
brod: "brod",
};
export async function shoppingRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/shopping-lists", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const lists = await app.db
.select()
.from(schema.shoppingLists)
.where(
and(
eq(schema.shoppingLists.householdId, householdId),
eq(schema.shoppingLists.status, "active"),
),
);
return { lists };
});
app.post("/v1/shopping-lists", auth, async (req, reply) => {
const input = parse(createShoppingListInputSchema, req.body);
const householdId = await requireActiveHousehold(app.db, req.userId);
const [list] = await app.db
.insert(schema.shoppingLists)
.values({ householdId, name: input.name, weekPlanId: input.weekPlanId ?? null })
.returning();
// Generera från veckoplan: receptbehov befintligt lager (spec §27)
if (input.generateFromPlan && input.weekPlanId) {
await generateItemsFromPlan(app, list!.id, input.weekPlanId, householdId, req.userId);
}
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, list!.id));
return reply.status(201).send({ list, items });
});
app.get("/v1/shopping-lists/:id", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(eq(schema.shoppingListItems.shoppingListId, id))
.orderBy(schema.shoppingListItems.storeSection, schema.shoppingListItems.sortOrder);
const estimatedTotal = items.reduce((sum, i) => sum + (i.estimatedPriceMinor ?? 0), 0);
// Prisuppskattningar härleds ur katalogens baspriser (SEK) tills per-marknads-priser (M8).
return { list, items, estimatedTotalMinor: Math.round(estimatedTotal), currency: "SEK" };
});
app.post("/v1/shopping-lists/:id/items", auth, async (req, reply) => {
const { id } = parse(idParamSchema, req.params);
await getOwnedList(app, id, req.userId);
const input = parse(addShoppingItemInputSchema, req.body);
let section = input.storeSection;
let estimatedPrice = input.estimatedPriceMinor;
if (input.canonicalIngredientId) {
const [ing] = await app.db
.select()
.from(schema.canonicalIngredients)
.where(eq(schema.canonicalIngredients.id, input.canonicalIngredientId))
.limit(1);
if (ing) {
section = section ?? CATEGORY_TO_SECTION[ing.category] ?? "hygien_ovrigt";
if (estimatedPrice == null && ing.defaultPriceMinorPerKg != null) {
const grams = convert(input.quantity, input.unit, "GRAM", {
densityGPerMl: ing.densityGPerMl,
gramsPerPiece: ing.gramsPerPiece,
});
if (grams != null)
estimatedPrice = Math.round((grams / 1000) * ing.defaultPriceMinorPerKg * 10) / 10;
}
}
}
// Slå ihop med befintlig rad för samma ingrediens (spec §27)
if (input.canonicalIngredientId) {
const [existing] = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.canonicalIngredientId, input.canonicalIngredientId),
eq(schema.shoppingListItems.unit, input.unit),
eq(schema.shoppingListItems.checked, false),
),
)
.limit(1);
if (existing) {
const [merged] = await app.db
.update(schema.shoppingListItems)
.set({
quantity: existing.quantity + input.quantity,
estimatedPriceMinor:
existing.estimatedPriceMinor != null && estimatedPrice != null
? existing.estimatedPriceMinor + estimatedPrice
: (existing.estimatedPriceMinor ?? estimatedPrice ?? null),
})
.where(eq(schema.shoppingListItems.id, existing.id))
.returning();
return reply.send({ item: merged, merged: true });
}
}
const [item] = await app.db
.insert(schema.shoppingListItems)
.values({
shoppingListId: id,
canonicalIngredientId: input.canonicalIngredientId ?? null,
displayName: input.displayName,
quantity: input.quantity,
unit: input.unit,
storeSection: section ?? "hygien_ovrigt",
estimatedPriceMinor: estimatedPrice ?? null,
addedByUserId: req.userId,
origin: "manual",
})
.returning();
return reply.status(201).send({ item, merged: false });
});
app.patch("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
const input = parse(updateShoppingItemInputSchema, req.body);
const [item] = await app.db
.update(schema.shoppingListItems)
.set(input)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
)
.returning();
if (!item) throw errors.notFound();
return item;
});
app.delete("/v1/shopping-lists/:id/items/:itemId", auth, async (req) => {
const params = req.params as { id: string; itemId: string };
await getOwnedList(app, params.id, req.userId);
await app.db
.delete(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.id, params.itemId),
eq(schema.shoppingListItems.shoppingListId, params.id),
),
);
return { ok: true };
});
/** Avsluta köprundan: bockade varor in i lagret (spec §27). */
app.post("/v1/shopping-lists/:id/complete", auth, async (req) => {
const { id } = parse(idParamSchema, req.params);
const list = await getOwnedList(app, id, req.userId);
const input = parse(completeShoppingInputSchema, req.body);
const items = await app.db
.select()
.from(schema.shoppingListItems)
.where(
and(
eq(schema.shoppingListItems.shoppingListId, id),
eq(schema.shoppingListItems.checked, true),
),
);
let added = 0;
if (input.addToInventory && items.length > 0) {
const overrides = new Map(input.storageDefaults.map((s) => [s.shoppingListItemId, s]));
const fallback =
input.defaultStorageLocationId ??
(
await app.db
.select({ id: schema.storageLocations.id })
.from(schema.storageLocations)
.where(
and(
eq(schema.storageLocations.householdId, list.householdId),
eq(schema.storageLocations.type, "fridge"),
),
)
.limit(1)
)[0]?.id;
if (!fallback) throw errors.badRequest("Ingen standardplats (kyl) hittades i hushållet.");
for (const item of items) {
const override = overrides.get(item.id);
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
householdId: list.householdId,
canonicalIngredientId: item.canonicalIngredientId,
displayName: item.displayName,
quantity: item.quantity,
unit: item.unit,
storageLocationId: override?.storageLocationId ?? fallback,
purchasedAt: todayIso(),
bestBeforeDate: override?.bestBeforeDate ?? null,
priceMinor: override?.priceMinor ?? item.estimatedPriceMinor,
source: "manual_search",
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: new Date(),
})
.returning();
await app.db.insert(schema.inventoryTransactions).values({
householdId: list.householdId,
inventoryItemId: inv!.id,
type: "purchase",
quantityDelta: item.quantity,
unit: item.unit,
refType: "shopping",
refId: id,
actorUserId: req.userId,
valueMinor: override?.priceMinor ?? item.estimatedPriceMinor,
});
added += 1;
}
}
await app.db
.update(schema.shoppingLists)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.shoppingLists.id, id));
await emitEvent(app.db, {
type: "SHOPPING_COMPLETED",
payload: { shoppingListId: id, itemsAdded: added },
userId: req.userId,
householdId: list.householdId,
correlationId: req.correlationId,
});
return { ok: true, itemsAddedToInventory: added };
});
}
async function getOwnedList(app: FastifyInstance, listId: string, userId: string) {
const [list] = await app.db
.select()
.from(schema.shoppingLists)
.where(eq(schema.shoppingLists.id, listId))
.limit(1);
if (!list) throw errors.notFound("Listan finns inte.");
await requireMembership(app.db, list.householdId, userId);
return list;
}
/** Aggregera receptbehov från plan, dra av befintligt lager, skapa rader. */
async function generateItemsFromPlan(
app: FastifyInstance,
listId: string,
weekPlanId: string,
householdId: string,
userId: string,
) {
const entries = await app.db
.select()
.from(schema.weekPlanEntries)
.where(
and(
eq(schema.weekPlanEntries.weekPlanId, weekPlanId),
eq(schema.weekPlanEntries.status, "planned"),
),
);
const recipeIds = [...new Set(entries.filter((e) => e.recipeId).map((e) => e.recipeId!))];
if (recipeIds.length === 0) return;
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(inArray(schema.recipeIngredients.recipeId, recipeIds));
const recipes = await app.db
.select({ id: schema.recipes.id, portions: schema.recipes.portions })
.from(schema.recipes)
.where(inArray(schema.recipes.id, recipeIds));
const portionsMap = new Map(recipes.map((r) => [r.id, r.portions]));
// Aggregera behov per ingrediens (i gram där möjligt)
const needs = new Map<string, { name: string; grams: number }>();
const infoRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(
inArray(schema.canonicalIngredients.id, [
...new Set(allIngredients.map((i) => i.canonicalIngredientId)),
]),
);
const infoMap = new Map(infoRows.map((r) => [r.id, r]));
for (const entry of entries) {
if (!entry.recipeId) continue;
const basePortions = portionsMap.get(entry.recipeId) ?? 4;
const factor = entry.portions / basePortions;
for (const ing of allIngredients.filter((i) => i.recipeId === entry.recipeId && !i.optional)) {
const info = infoMap.get(ing.canonicalIngredientId);
const grams = convert(ing.quantity * factor, ing.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams == null) continue;
const current = needs.get(ing.canonicalIngredientId) ?? { name: ing.displayNameSv, grams: 0 };
current.grams += grams;
needs.set(ing.canonicalIngredientId, current);
}
}
// Dra av lager
const stock = await app.db
.select()
.from(schema.inventoryItems)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
inArray(schema.inventoryItems.canonicalIngredientId, [...needs.keys()]),
),
);
for (const item of stock) {
if (!item.canonicalIngredientId) continue;
const need = needs.get(item.canonicalIngredientId);
if (!need) continue;
const info = infoMap.get(item.canonicalIngredientId);
const grams = convert(item.quantity, item.unit, "GRAM", {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
});
if (grams != null) need.grams = Math.max(0, need.grams - grams);
}
// Skapa rader för det som saknas
let sortOrder = 0;
for (const [ingredientId, need] of needs) {
if (need.grams < 5) continue;
const info = infoMap.get(ingredientId);
const section = info
? (CATEGORY_TO_SECTION[info.category] ?? "hygien_ovrigt")
: "hygien_ovrigt";
// Konvertera tillbaka till naturlig enhet
const targetUnit = info?.defaultUnit ?? "GRAM";
const qty =
convert(need.grams, "GRAM", targetUnit, {
densityGPerMl: info?.densityGPerMl,
gramsPerPiece: info?.gramsPerPiece,
}) ?? need.grams;
const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10;
const estimatedPrice =
info?.defaultPriceMinorPerKg != null
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10
: null;
await app.db.insert(schema.shoppingListItems).values({
shoppingListId: listId,
canonicalIngredientId: ingredientId,
displayName: need.name,
quantity: rounded,
unit: targetUnit,
storeSection: section,
estimatedPriceMinor: estimatedPrice,
addedByUserId: userId,
origin: "plan",
sortOrder: sortOrder++,
});
}
}