356 lines
15 KiB
TypeScript
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)
|
|
}
|