355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
|
|
import { api } from '@/api/client'
|
||
|
|
import type { components } from '@/api/types'
|
||
|
|
import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient } 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'}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Small helper to decode optional lookup maps without repeating loops everywhere
|
||
|
|
function decodeLookup<TIn, TOut>(
|
||
|
|
raw: Record<string, TIn> | null | undefined,
|
||
|
|
decode: (v: TIn) => TOut | null
|
||
|
|
): Record<string, TOut> | undefined {
|
||
|
|
if (!raw) return undefined
|
||
|
|
const out: Record<string, TOut> = {}
|
||
|
|
for (const key of Object.keys(raw)) {
|
||
|
|
if (!Object.prototype.hasOwnProperty.call(raw, key)) continue
|
||
|
|
const maybe = raw[key]
|
||
|
|
if (maybe === undefined) continue
|
||
|
|
const decoded = decode(maybe)
|
||
|
|
if (decoded) out[String(key)] = decoded
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 && lookups.ingredientsLookup[String(ingredientId)] !== undefined) {
|
||
|
|
item.ingredient = lookups.ingredientsLookup[String(ingredientId)]
|
||
|
|
}
|
||
|
|
if (mealId !== undefined && lookups.mealsLookup && lookups.mealsLookup[String(mealId)] !== undefined) {
|
||
|
|
item.meal = lookups.mealsLookup[String(mealId)]
|
||
|
|
}
|
||
|
|
if (recipeId !== undefined && lookups.recipesLookup && lookups.recipesLookup[String(recipeId)] !== undefined) {
|
||
|
|
item.recipe = lookups.recipesLookup[String(recipeId)]
|
||
|
|
}
|
||
|
|
if (listId !== undefined && lookups.shoppingListLookup && lookups.shoppingListLookup[String(listId)] !== undefined) {
|
||
|
|
item.list = lookups.shoppingListLookup[String(listId)]
|
||
|
|
}
|
||
|
|
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 items = Array.isArray(listRaw.items) ? decodeShoppingListItems(listRaw.items) : []
|
||
|
|
list = { ...base, items }
|
||
|
|
const lookups: ShoppingLookups = {
|
||
|
|
...(ingredientsLookup && { ingredientsLookup }),
|
||
|
|
...(mealsLookup && { mealsLookup }),
|
||
|
|
...(recipesLookup && { recipesLookup }),
|
||
|
|
shoppingListLookup: { [String(list.id)]: list },
|
||
|
|
}
|
||
|
|
attachItemRefs(list.items, lookups)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
...(ingredientsLookup && { ingredientsLookup }),
|
||
|
|
...(mealsLookup && { mealsLookup }),
|
||
|
|
...(recipesLookup && { recipesLookup }),
|
||
|
|
...(list && { 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 ?? []),
|
||
|
|
requestedMeals: decodeShoppingListItems(requestedRaw ?? []),
|
||
|
|
purchasedItems: decodeShoppingListItems(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 { 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 | null> {
|
||
|
|
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)
|
||
|
|
return decodeRecipe(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
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 | null> {
|
||
|
|
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)
|
||
|
|
return decodeMeal(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
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 | null> {
|
||
|
|
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)
|
||
|
|
return decodeMeal(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
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() {
|
||
|
|
const { data, error, response } = await api.GET('/api/v1/shopping/current')
|
||
|
|
if (!response.ok) throw httpError(response, error)
|
||
|
|
return mapCurrentShoppingList(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
|
||
|
|
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
|
||
|
|
|
||
|
|
export async function purchaseShoppingList(
|
||
|
|
completedRequests: Array<PurchaseExisting | PurchaseRefs>
|
||
|
|
): 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)
|
||
|
|
}
|