munch-ease-frontend/src/api/sdk.ts

398 lines
16 KiB
TypeScript

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<ShoppingListItemWithRefs | ListIngredientItemWithRefs | RequestedMealItemWithRefs> | 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<string, ShoppingList> | 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<Page<Recipe>> {
const query: Record<string, unknown> = {}
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<Recipe> {
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<Recipe | null> {
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<void> {
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<Recipe | null> {
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<Ingredient[]> {
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<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): Promise<components['schemas']['Product'] | null> {
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<Page<components['schemas']['Person']>> {
const query: Record<string, unknown> = {}
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<string, string>)) : '')
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<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
}
export async function getPersonsInHome(): Promise<Page<components['schemas']['Person']>> {
return listPersons()
}
export async function searchPersons(name: string): Promise<Page<components['schemas']['Person']>> {
return listPersons({ q: name })
}
// Meals
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
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<Meal> {
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<Meal | null> {
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<Meal> {
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<void> {
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<import('@/domain/types').Ingredient[]> {
return []
}
export async function saveMyShoppingList(ingredients: import('@/domain/types').Ingredient[]): Promise<import('@/domain/types').Ingredient[]> {
return ingredients
}
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
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<CurrentShoppingListDTO> {
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<import('@/domain/types').ShoppingListWithRefs | null> {
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<void> {
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<void> {
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<import('@/domain/types').ListIngredientItemWithRefs> {
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'