import { api } from '@/api/client' import type { components } from '@/api/types' import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient } from '@/domain/decoders' import type { Recipe, Meal, Ingredient, ShoppingList, ShoppingListItemWithRefs, CurrentShoppingListDTO, PurchasedShoppingListDTO, ShoppingLookups, } from '@/domain/types' import { fromOpenApiPage, type Page } from '@/domain/pagination' function httpError(response: Response, error: unknown): Error { if (error instanceof Error) return error if (typeof error === 'string') return new Error(error) return new Error(`${response.status} ${response.statusText || 'HTTP error'}`) } // Small helper to decode optional lookup maps without repeating loops everywhere function decodeLookup( raw: Record | null | undefined, decode: (v: TIn) => TOut | null ): Record | undefined { if (!raw) return undefined const out: Record = {} for (const key of Object.keys(raw)) { if (!Object.prototype.hasOwnProperty.call(raw, key)) continue const maybe = raw[key] if (maybe === undefined) continue const decoded = decode(maybe) if (decoded) out[String(key)] = decoded } return out } // Shopping list mapped view types now come from domain/types function attachItemRefs( items: Array | null | undefined, lookups: ShoppingLookups ): void { if (!Array.isArray(items)) return for (const item of items) { const ingredientId = item.ingredientId ?? undefined const mealId = item.mealId ?? undefined const recipeId = item.recipeId ?? undefined const listId = item.listId ?? undefined const created = item.createdDate if (ingredientId !== undefined && lookups.ingredientsLookup && lookups.ingredientsLookup[String(ingredientId)] !== undefined) { item.ingredient = lookups.ingredientsLookup[String(ingredientId)] } if (mealId !== undefined && lookups.mealsLookup && lookups.mealsLookup[String(mealId)] !== undefined) { item.meal = lookups.mealsLookup[String(mealId)] } if (recipeId !== undefined && lookups.recipesLookup && lookups.recipesLookup[String(recipeId)] !== undefined) { item.recipe = lookups.recipesLookup[String(recipeId)] } if (listId !== undefined && lookups.shoppingListLookup && lookups.shoppingListLookup[String(listId)] !== undefined) { item.list = lookups.shoppingListLookup[String(listId)] } if (created !== undefined) item.createdDate = toDate(created) } } export function mapPurchasedShoppingList(dto: components['schemas']['PurchasedShoppingList'] | null | undefined): PurchasedShoppingListDTO | null { if (!dto) return null const mealsLookupRaw = dto.mealsLookup const recipesLookupRaw = dto.recipesLookup const ingredientsLookupRaw = dto.ingredientsLookup const listRaw = dto.list const mealsLookup = decodeLookup(mealsLookupRaw, decodeMeal) const recipesLookup = decodeLookup(recipesLookupRaw, decodeRecipe) const ingredientsLookup = decodeLookup(ingredientsLookupRaw, decodeIngredient) let list: import('@/domain/types').ShoppingListWithRefs | undefined if (listRaw) { const base = decodeShoppingList(listRaw) if (base) { const items = Array.isArray(listRaw.items) ? decodeShoppingListItems(listRaw.items) : [] list = { ...base, items } const lookups: ShoppingLookups = { ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), shoppingListLookup: { [String(list.id)]: list }, } attachItemRefs(list.items, lookups) } } return { ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), ...(list && { list }), } } export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppingList'] | null | undefined): CurrentShoppingListDTO | null { if (!dto) return null const outstandingRaw = dto.outstandingItems const requestedRaw = dto.requestedMeals const purchasedRaw = dto.purchasedItems const ingredientsLookupRaw = dto.ingredientsLookup const mealsLookupRaw = dto.mealsLookup const recipesLookupRaw = dto.recipesLookup const shoppingListLookupRaw = dto.shoppingListLookup const ingredientsLookup = decodeLookup(ingredientsLookupRaw, decodeIngredient) const mealsLookup = decodeLookup(mealsLookupRaw, decodeMeal) const recipesLookup = decodeLookup(recipesLookupRaw, decodeRecipe) let shoppingListLookup: Record | undefined if (shoppingListLookupRaw) { shoppingListLookup = decodeLookup(shoppingListLookupRaw, decodeShoppingList) } const dtoOut: CurrentShoppingListDTO = { outstandingItems: decodeShoppingListItems(outstandingRaw ?? []), requestedMeals: decodeShoppingListItems(requestedRaw ?? []), purchasedItems: decodeShoppingListItems(purchasedRaw ?? []), ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), ...(shoppingListLookup && { shoppingListLookup }), } const lookups: ShoppingLookups = { ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), ...(shoppingListLookup && { shoppingListLookup }), } attachItemRefs(dtoOut.outstandingItems ?? null, lookups) attachItemRefs(dtoOut.requestedMeals ?? null, lookups) attachItemRefs(dtoOut.purchasedItems ?? null, lookups) return dtoOut } export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise> { const query: Record = {} if (params) { if (params.q !== undefined) query.q = params.q if (params.cursor !== undefined) query.cursor = params.cursor if (typeof params.limit === 'number') query.limit = params.limit } const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } }) if (!response.ok) throw httpError(response, error) const mapped = fromOpenApiPage(data ?? null, (r) => decodeRecipe(r)) return { ...mapped, items: mapped.items.filter((r): r is Recipe => !!r) } } export async function getRecipe(id: number | string): Promise { const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) return decodeRecipe(data) } export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise { const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } }) if (!response.ok) throw httpError(response, error) return decodeRecipe(data) } export async function deleteRecipe(id: number | string): Promise { const { error, response } = await api.DELETE('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) }, cookie: { user_id: 0 } } }) if (!response.ok) throw httpError(response, error) } export async function parseRecipe(url: string): Promise { const { data, error, response } = await api.GET('/api/v1/recipes/parse', { params: { query: { url }, cookie: { user_id: 0 } } }) if (!response.ok) throw httpError(response, error) return decodeRecipe(data) } export async function parseIngredients(lines: string[]): Promise { const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', { params: { query: { ingredients: lines } }, }) if (!response.ok) throw httpError(response, error) return decodeIngredients(data ?? []) } export async function parseProduct( ingredient: Pick, url: string ): Promise { const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] } const res = await api.POST('/api/v1/products', { body }) if (!res.response.ok) throw httpError(res.response, res.error) return res.data ?? null } // Persons export async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise> { const query: Record = {} if (params) { if (params.q !== undefined) query.q = params.q if (params.cursor !== undefined) query.cursor = params.cursor if (typeof params.limit === 'number') query.limit = params.limit } const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } }) if (!response.ok) throw httpError(response, error) const normalized = Array.isArray(data) ? { items: data } : (data ?? null) return fromOpenApiPage(normalized, (p) => p) } export async function getPersonsInHome(): Promise> { return listPersons() } export async function searchPersons(name: string): Promise> { return listPersons({ q: name }) } // Meals export async function getUpcomingMeals(from: Date, to: Date): Promise { const { data, error, response } = await api.GET('/api/v1/meals/upcoming', { params: { query: { from: from.toISOString(), to: to.toISOString() } }, }) if (!response.ok) throw httpError(response, error) const list = Array.isArray(data) ? data : [] return list .map((m) => decodeMeal(m)) .filter((m): m is Meal => !!m) .sort((a, b) => ((a.suggestedDate?.getTime() ?? 0) - (b.suggestedDate?.getTime() ?? 0))) } export async function getMeal(id: number | string): Promise { const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) return decodeMeal(data) } export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise { const hasId = typeof meal.id === 'number' && meal.id >= 0 if (hasId) { const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(meal.id) } }, body: meal, }) if (!response.ok) throw httpError(response, error) return decodeMeal(data) } else { const { data, error, response } = await api.POST('/api/v1/meals', { body: meal }) if (!response.ok) throw httpError(response, error) return decodeMeal(data) } } export async function markMealConsumed(mealId: number | string): Promise { const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', { params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) return decodeMeal(data) } export async function deleteMeal(mealId: number | string): Promise { const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) } // Shopping export async function getMyShoppingList(): Promise { const { data, error, response } = await api.GET('/api/v1/shopping/current/me/ingredients', { params: { cookie: { user_id: 0 } } }) if (!response.ok) throw httpError(response, error) return decodeIngredients(data ?? []) } export async function saveMyShoppingList(items: components['schemas']['Ingredient'][]): Promise { const { data, error, response } = await api.POST('/api/v1/shopping/current/me/ingredients', { body: items, params: { cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) return decodeIngredients(data ?? []) } export async function getShoppingList(id: number | string) { const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) const mapped = mapPurchasedShoppingList(data) return mapped?.list ?? null } export async function getCurrentShoppingList() { const { data, error, response } = await api.GET('/api/v1/shopping/current') if (!response.ok) throw httpError(response, error) return mapCurrentShoppingList(data) } type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null } type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null } export async function purchaseShoppingList( completedRequests: Array ): Promise { if (!Array.isArray(completedRequests) || completedRequests.length === 0) return null // Map incoming requests: if id provided and >= 0, use it; otherwise send identifiers for ingredient/recipe/meal const items: components['schemas']['ShoppingListItem'][] = completedRequests.map((i) => i.type === 'existing' ? { id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null } : { id: -1, personId: i.personId, ingredientId: i.ingredientId ?? null, recipeId: i.recipeId ?? null, mealId: i.mealId ?? null, } ) if (items.length === 0) return null const body: components['schemas']['ShoppingList'] = { id: -1, storeName: '', purchasedById: -1, items, } const { data, error, response } = await api.POST('/api/v1/shopping', { body, params: { cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) const mapped = mapPurchasedShoppingList(data) return mapped?.list ?? null } export async function requestMeal(mealId: number | string): Promise { const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', { body: { mealId: Number(mealId) }, params: { cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) } export async function unrequestMeal(mealId: number | string): Promise { const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', { params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, }) if (!response.ok) throw httpError(response, error) }