munch-ease-frontend/src/api/sdk.ts
2025-10-21 19:06:10 +11:00

356 lines
15 KiB
TypeScript

import { api } from '@/api/client'
import type { components } from '@/api/types'
import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient, decodeLookup } 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'}`)
}
// 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> | 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) {
const v = lookups.ingredientsLookup[String(ingredientId)]
if (v !== undefined) item.ingredient = v
}
if (mealId !== undefined && lookups.mealsLookup) {
const v = lookups.mealsLookup[String(mealId)]
if (v !== undefined) item.meal = v
}
if (recipeId !== undefined && lookups.recipesLookup) {
const v = lookups.recipesLookup[String(recipeId)]
if (v !== undefined) item.recipe = v
}
if (listId !== undefined && lookups.shoppingListLookup) {
const v = lookups.shoppingListLookup[String(listId)]
if (v !== undefined) item.list = v
}
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 decoded = Array.isArray(listRaw.items) ? decodeShoppingListItems(listRaw.items) : []
// start with decoded items and attach refs below; treat as WithRefs variant
const items = decoded as unknown as import('@/domain/types').ShoppingListItemWithRefs[]
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: (decodeShoppingListItems(outstandingRaw ?? []) as unknown) as ShoppingListItemWithRefs[],
requestedMeals: (decodeShoppingListItems(requestedRaw ?? []) as unknown) as ShoppingListItemWithRefs[],
purchasedItems: (decodeShoppingListItems(purchasedRaw ?? []) as unknown) as ShoppingListItemWithRefs[],
...(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 { 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<Recipe> {
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)
const mapped = decodeRecipe(data)
if (!mapped) throw new Error('Recipe not found')
return mapped
}
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
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<void> {
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<Recipe | null> {
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<Ingredient[]> {
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<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string
): Promise<components['schemas']['Product'] | null> {
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<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 { 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<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 { 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<Meal> {
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)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
}
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
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<Meal> {
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)
const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found')
return mapped
}
export async function deleteMeal(mealId: number | string): Promise<void> {
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<Ingredient[]> {
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<Ingredient[]> {
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): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
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(): Promise<CurrentShoppingListDTO> {
const { data, error, response } = await api.GET('/api/v1/shopping/current')
if (!response.ok) throw httpError(response, error)
const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list')
return mapped
}
export type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
export type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
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']['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<void> {
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<void> {
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)
}