398 lines
16 KiB
TypeScript
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'
|