import { api, getHouseholdSlug, fetchApi } from '@/api/client' import type { components } from '@/api/types' import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeListIngredientItems, decodeRequestedMealItems, decodeIngredient, decodeLookup, } from '@/domain/decoders' import type { Recipe, Meal, Ingredient, ShoppingList, ShoppingListItemWithRefs, ListIngredientItemWithRefs, RequestedMealItemWithRefs, CurrentShoppingListDTO, PurchasedShoppingListDTO, ShoppingLookups, } from '@/domain/types' import { fromOpenApiPage, type Page } from '@/domain/pagination' import type { PurchaseRequest } from '@/domain/commands' 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'}`) } function requireSlug(explicit?: string): string { const slug = explicit ?? getHouseholdSlug() ?? null if (!slug) throw new Error('Missing household slug') return slug } // decodeLookup moved to domain/decoders to be reused across SDK and other modules // 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) { // ingredient ref if ('ingredientId' in item && item.ingredientId !== undefined && lookups.ingredientsLookup) { const v = lookups.ingredientsLookup[String(item.ingredientId)] if (v !== undefined) item.ingredient = v } // meal ref (present on all item types) if ('mealId' in item && item.mealId !== undefined && lookups.mealsLookup) { const v = lookups.mealsLookup[String(item.mealId)] if (v !== undefined) item.meal = v } // recipe ref if ('recipeId' in item && item.recipeId !== undefined && lookups.recipesLookup) { const v = lookups.recipesLookup[String(item.recipeId)] if (v !== undefined) item.recipe = v } // list ref if ('listId' in item && item.listId !== undefined && lookups.shoppingListLookup) { const v = lookups.shoppingListLookup[String(item.listId)] if (v !== undefined) item.list = v } if ('createdDate' in item) item.createdDate = toDate(item.createdDate) } } 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 }), ...(list && { shoppingListLookup: { [String(list.id)]: list } }), } attachItemRefs(list.items, lookups) } } return { ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), ...(list && { list }), // include shoppingListLookup when list exists for consistency with lookups type ...(list && { shoppingListLookup: { [String(list.id)]: 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: decodeListIngredientItems(outstandingRaw ?? []), requestedMeals: decodeRequestedMealItems(requestedRaw ?? []), purchasedItems: decodeListIngredientItems(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 householdSlug = requireSlug() const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug }, query } }) if (!response.ok) throw httpError(response, error) return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r)) } export async function getRecipe(householdSlug: string, id: number | string): Promise { const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes/{recipe_id}', { params: { path: { householdSlug: requireSlug(householdSlug), recipe_id: Number(id) } }, }) if (!response.ok) throw httpError(response, error) const mapped = decodeRecipe(data) if (!mapped) throw new Error('Recipe not found') return mapped } export async function saveRecipe(recipe: components['schemas']['RecipeCreate']): Promise { const householdSlug = requireSlug() // Map "Recipe-Input" to "RecipeCreate" const body: components['schemas']['RecipeCreate'] = { name: recipe.name, link: recipe.link, serves: recipe.serves, imageUrls: recipe.imageUrls ?? [], ingredients: recipe.ingredients ?? [], } const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug } }, body }) if (!response.ok) throw httpError(response, error) return decodeRecipe(data) } export async function deleteRecipe(id: number | string): Promise { const householdSlug = requireSlug() const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/recipes/{recipe_id}', { params: { path: { householdSlug, recipe_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) } export async function parseRecipe(url: string): Promise { const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' }) if (!res.ok) throw httpError(res, null) const data = await res.json() return decodeRecipe(data) } export async function parseIngredients(lines: string[]): Promise { const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v])) const res = await fetchApi(url, { method: 'GET' }) if (!res.ok) throw httpError(res, null) const data = await res.json() return decodeIngredients(Array.isArray(data) ? data : []) } export async function parseProduct( ingredient: Pick, url: string ): Promise { const body = { url, tags: [ingredient.name, ingredient.line] } const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }) if (!res.ok) throw httpError(res, null) return (await res.json()) ?? null } // Persons 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 url = '/api/v1/persons' + (Object.keys(query).length ? ('?' + new URLSearchParams(query as Record)) : '') const res = await fetchApi(url, { method: 'GET' }) if (!res.ok) throw httpError(res, null) const data = await res.json() 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 householdSlug = requireSlug() const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/upcoming', { params: { path: { householdSlug }, 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 householdSlug = requireSlug() const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) const mapped = decodeMeal(data) if (!mapped) throw new Error('Meal not found') return mapped } export async function saveMeal(meal: components['schemas']['MealIn']): Promise { const hasId = typeof meal.id === 'number' && meal.id >= 0 const householdSlug = requireSlug() if (hasId) { const { data, error, response } = await api.PUT('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, 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/households/{householdSlug}/meals', { params: { path: { householdSlug } }, body: meal }) if (!response.ok) throw httpError(response, error) return decodeMeal(data) } } export async function markMealConsumed(mealId: number | string): Promise { const householdSlug = requireSlug() const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals/{meal_id}/consumed', { params: { path: { householdSlug, meal_id: Number(mealId) } }, }) if (!response.ok) throw httpError(response, error) const mapped = decodeMeal(data) if (!mapped) throw new Error('Meal not found') return mapped } export async function deleteMeal(mealId: number | string): Promise { const householdSlug = requireSlug() const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(mealId) } }, }) if (!response.ok) throw httpError(response, error) } // Shopping // getMyShoppingList/saveMyShoppingList endpoints removed in v2; keep temporary stubs for legacy UI export async function getMyShoppingList(): Promise { return [] } export async function saveMyShoppingList(ingredients: import('@/domain/types').Ingredient[]): Promise { return ingredients } export async function getShoppingList(id: number | string): Promise { const householdSlug = requireSlug() const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } }) if (!response.ok) throw httpError(response, error) const mapped = mapPurchasedShoppingList(data) return mapped?.list ?? null } export async function getCurrentShoppingList(): Promise { const householdSlug = requireSlug() const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/current', { params: { path: { householdSlug } } }) if (!response.ok) throw httpError(response, error) const mapped = mapCurrentShoppingList(data) if (!mapped) throw new Error('Failed to map current shopping list') return mapped } // PurchaseRequest comes from domain/commands export async function purchaseShoppingList( completedRequests: PurchaseRequest[] ): 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']['IngredientPurchaseItemIn'][] = completedRequests.map((i) => ({ personId: i.personId, ingredientId: i.ingredientId ?? -1, recipeId: i.type === 'refs' ? i.recipeId ?? null : null, mealId: i.type === 'refs' ? i.mealId ?? null : null, createdDate: null, })) if (items.length === 0) return null const body: components['schemas']['PurchaseListIn'] = { // Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths") storeName: 'home', items, } const householdSlug = requireSlug() const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping', { params: { path: { householdSlug } }, body, }) if (!response.ok) throw httpError(response, error) const mapped = mapPurchasedShoppingList(data) return mapped?.list ?? null } export async function requestMeal(mealId: number | string): Promise { const householdSlug = requireSlug() const { error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/meals/me', { params: { path: { householdSlug } }, body: { mealId: Number(mealId) }, }) if (!response.ok) throw httpError(response, error) } export async function unrequestMeal(mealId: number | string): Promise { const householdSlug = requireSlug() const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(mealId) } }, }) if (!response.ok) throw httpError(response, error) } export async function requestIngredient(ingredientId: number): Promise { const householdSlug = requireSlug() const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/ingredients', { params: { path: { householdSlug } }, body: { ingredientId }, }) if (!response.ok) throw httpError(response, error) const [decoded] = decodeListIngredientItems([data as any]) if (!decoded) throw new Error('Failed to decode requested ingredient item') return decoded } // Re-export domain command types for convenience at SDK surface export type { PurchaseRequest } from '@/domain/commands'