This commit is contained in:
jableader 2025-11-01 16:59:22 +11:00
parent 630c6dabcf
commit a6d729509e
10 changed files with 222 additions and 175 deletions

View file

@ -16,6 +16,11 @@ export function setHouseholdSlugProvider(provider: (() => string | null) | null)
householdSlugProvider = provider householdSlugProvider = provider
} }
// Expose current household slug for SDK convenience (temporary during migration)
export function getHouseholdSlug(): string | null {
return householdSlugProvider ? householdSlugProvider() : null
}
export function setAuthTokenProvider(provider: (() => string | null) | null) { export function setAuthTokenProvider(provider: (() => string | null) | null) {
authTokenProvider = provider authTokenProvider = provider
} }

View file

@ -1,4 +1,4 @@
import { api } from '@/api/client' import { api, getHouseholdSlug, fetchApi } from '@/api/client'
import type { components } from '@/api/types' import type { components } from '@/api/types'
import { import {
toDate, toDate,
@ -33,6 +33,12 @@ function httpError(response: Response, error: unknown): Error {
return new Error(`${response.status} ${response.statusText || 'HTTP 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 // decodeLookup moved to domain/decoders to be reused across SDK and other modules
// Shopping list mapped view types now come from domain/types // Shopping list mapped view types now come from domain/types
@ -156,52 +162,66 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
if (params.cursor !== undefined) query.cursor = params.cursor if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit if (typeof params.limit === 'number') query.limit = params.limit
} }
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } }) 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) if (!response.ok) throw httpError(response, error)
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r)) return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
} }
export async function getRecipe(id: number | string): Promise<Recipe> { export async function getRecipe(householdSlug: string, id: number | string): Promise<Recipe> {
const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } }) 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) if (!response.ok) throw httpError(response, error)
const mapped = decodeRecipe(data) const mapped = decodeRecipe(data)
if (!mapped) throw new Error('Recipe not found') if (!mapped) throw new Error('Recipe not found')
return mapped return mapped
} }
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> { export async function saveRecipe(recipe: components['schemas']['RecipeCreate']): Promise<Recipe | null> {
const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } }) 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) if (!response.ok) throw httpError(response, error)
return decodeRecipe(data) return decodeRecipe(data)
} }
export async function deleteRecipe(id: number | string): Promise<void> { 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 } } }) 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) if (!response.ok) throw httpError(response, error)
} }
export async function parseRecipe(url: string): Promise<Recipe | null> { 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 } } }) const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' })
if (!response.ok) throw httpError(response, error) if (!res.ok) throw httpError(res, null)
const data = await res.json()
return decodeRecipe(data) return decodeRecipe(data)
} }
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> { export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', { const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v]))
params: { query: { ingredients: lines } }, const res = await fetchApi(url, { method: 'GET' })
}) if (!res.ok) throw httpError(res, null)
if (!response.ok) throw httpError(response, error) const data = await res.json()
return decodeIngredients(data ?? []) return decodeIngredients(Array.isArray(data) ? data : [])
} }
export async function parseProduct( export async function parseProduct(
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>, ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
url: string url: string
): Promise<components['schemas']['Product'] | null> { ): Promise<components['schemas']['Product'] | null> {
const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] } const body = { url, tags: [ingredient.name, ingredient.line] }
const res = await api.POST('/api/v1/products', { body }) const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } })
if (!res.response.ok) throw httpError(res.response, res.error) if (!res.ok) throw httpError(res, null)
return res.data ?? null return (await res.json()) ?? null
} }
// Persons // Persons
@ -212,8 +232,10 @@ async function listPersons(params?: { q?: string | null; cursor?: string | null;
if (params.cursor !== undefined) query.cursor = params.cursor if (params.cursor !== undefined) query.cursor = params.cursor
if (typeof params.limit === 'number') query.limit = params.limit if (typeof params.limit === 'number') query.limit = params.limit
} }
const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } }) const url = '/api/v1/persons' + (Object.keys(query).length ? ('?' + new URLSearchParams(query as Record<string, string>)) : '')
if (!response.ok) throw httpError(response, error) 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) const normalized = Array.isArray(data) ? { items: data } : (data ?? null)
return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p) return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
} }
@ -228,8 +250,9 @@ export async function searchPersons(name: string): Promise<Page<components['sche
// Meals // Meals
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> { export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
const { data, error, response } = await api.GET('/api/v1/meals/upcoming', { const householdSlug = requireSlug()
params: { query: { from: from.toISOString(), to: to.toISOString() } }, 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) if (!response.ok) throw httpError(response, error)
const list = Array.isArray(data) ? data : [] const list = Array.isArray(data) ? data : []
@ -240,7 +263,8 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
} }
export async function getMeal(id: number | string): Promise<Meal> { 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) } } }) 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) if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data) const mapped = decodeMeal(data)
if (!mapped) throw new Error('Meal not found') if (!mapped) throw new Error('Meal not found')
@ -249,23 +273,25 @@ export async function getMeal(id: number | string): Promise<Meal> {
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> { export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
const hasId = typeof meal.id === 'number' && meal.id >= 0 const hasId = typeof meal.id === 'number' && meal.id >= 0
const householdSlug = requireSlug()
if (hasId) { if (hasId) {
const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', { const { data, error, response } = await api.PUT('/api/v1/households/{householdSlug}/meals/{meal_id}', {
params: { path: { meal_id: Number(meal.id) } }, params: { path: { householdSlug, meal_id: Number(meal.id) } },
body: meal, body: meal,
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
return decodeMeal(data) return decodeMeal(data)
} else { } else {
const { data, error, response } = await api.POST('/api/v1/meals', { body: meal }) const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals', { params: { path: { householdSlug } }, body: meal })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
return decodeMeal(data) return decodeMeal(data)
} }
} }
export async function markMealConsumed(mealId: number | string): Promise<Meal> { export async function markMealConsumed(mealId: number | string): Promise<Meal> {
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', { const householdSlug = requireSlug()
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, 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) if (!response.ok) throw httpError(response, error)
const mapped = decodeMeal(data) const mapped = decodeMeal(data)
@ -274,37 +300,27 @@ export async function markMealConsumed(mealId: number | string): Promise<Meal> {
} }
export async function deleteMeal(mealId: number | string): Promise<void> { export async function deleteMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', { const householdSlug = requireSlug()
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, 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) if (!response.ok) throw httpError(response, error)
} }
// Shopping // Shopping
export async function getMyShoppingList(): Promise<Ingredient[]> { // getMyShoppingList/saveMyShoppingList endpoints removed in v2; not used by UI currently
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> { 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) } } }) 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) if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data) const mapped = mapPurchasedShoppingList(data)
return mapped?.list ?? null return mapped?.list ?? null
} }
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> { export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
const { data, error, response } = await api.GET('/api/v1/shopping/current') 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) if (!response.ok) throw httpError(response, error)
const mapped = mapCurrentShoppingList(data) const mapped = mapCurrentShoppingList(data)
if (!mapped) throw new Error('Failed to map current shopping list') if (!mapped) throw new Error('Failed to map current shopping list')
@ -333,9 +349,10 @@ export async function purchaseShoppingList(
storeName: 'home', storeName: 'home',
items, items,
} }
const { data, error, response } = await api.POST('/api/v1/shopping', { const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping', {
params: { path: { householdSlug } },
body, body,
params: { cookie: { user_id: 0 } },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
const mapped = mapPurchasedShoppingList(data) const mapped = mapPurchasedShoppingList(data)
@ -343,16 +360,18 @@ export async function purchaseShoppingList(
} }
export async function requestMeal(mealId: number | string): Promise<void> { export async function requestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', { const householdSlug = requireSlug()
const { error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/meals/me', {
params: { path: { householdSlug } },
body: { mealId: Number(mealId) }, body: { mealId: Number(mealId) },
params: { cookie: { user_id: 0 } },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
export async function unrequestMeal(mealId: number | string): Promise<void> { export async function unrequestMeal(mealId: number | string): Promise<void> {
const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', { const householdSlug = requireSlug()
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } }, 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) if (!response.ok) throw httpError(response, error)
} }

View file

@ -393,7 +393,7 @@ export interface components {
}; };
/** Mealslookup */ /** Mealslookup */
mealsLookup: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal"];
}; };
/** Shoppinglistlookup */ /** Shoppinglistlookup */
shoppingListLookup: { shoppingListLookup: {
@ -401,7 +401,7 @@ export interface components {
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe"];
}; };
}; };
/** HTTPValidationError */ /** HTTPValidationError */
@ -521,7 +521,7 @@ export interface components {
consumedDate?: string | null; consumedDate?: string | null;
}; };
/** Meal */ /** Meal */
"Meal-Input": { Meal: {
/** /**
* Id * Id
* @default -1 * @default -1
@ -541,34 +541,7 @@ export interface components {
/** Consumers */ /** Consumers */
consumers?: components["schemas"]["Person"][]; consumers?: components["schemas"]["Person"][];
/** Recipes */ /** Recipes */
recipes?: components["schemas"]["MealRecipe-Input"][]; recipes?: components["schemas"]["MealRecipe"][];
/** Extraingredients */
extraIngredients?: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
};
/** Meal */
"Meal-Output": {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs?: components["schemas"]["Person"][];
/** Cleanup */
cleanup?: components["schemas"]["Person"][];
/** Consumers */
consumers?: components["schemas"]["Person"][];
/** Recipes */
recipes?: components["schemas"]["MealRecipe-Output"][];
/** Extraingredients */ /** Extraingredients */
extraIngredients?: components["schemas"]["Ingredient"][]; extraIngredients?: components["schemas"]["Ingredient"][];
/** Purchasedate */ /** Purchasedate */
@ -579,25 +552,89 @@ export interface components {
/** Mealid */ /** Mealid */
mealId: number; mealId: number;
}; };
/** MealRecipe */ /** MealIn */
"MealRecipe-Input": { MealIn: {
/** Mealid */ /**
mealId: number; * Id
/** Recipeid */ * @default -1
recipeId: number; */
/** Servings */ id: number;
servings: number; /**
recipe?: components["schemas"]["Recipe-Input"] | null; * Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["MemberRef"][];
/** Cleanup */
cleanup: components["schemas"]["MemberRef"][];
/** Consumers */
consumers: components["schemas"]["MemberRef"][];
/**
* Recipes
* @default []
*/
recipes: components["schemas"]["MealRecipeIn"][];
/**
* Extraingredients
* @default []
*/
extraIngredients: components["schemas"]["Ingredient"][];
};
/** MealOut */
MealOut: {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["MemberRef"][];
/** Cleanup */
cleanup: components["schemas"]["MemberRef"][];
/** Consumers */
consumers: components["schemas"]["MemberRef"][];
/** Recipes */
recipes: components["schemas"]["MealRecipe"][];
/** Extraingredients */
extraIngredients: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
}; };
/** MealRecipe */ /** MealRecipe */
"MealRecipe-Output": { MealRecipe: {
/** Mealid */ /** Mealid */
mealId: number; mealId: number;
/** Recipeid */ /** Recipeid */
recipeId: number; recipeId: number;
/** Servings */ /** Servings */
servings: number; servings: number;
recipe?: components["schemas"]["Recipe-Output"] | null; recipe?: components["schemas"]["Recipe"] | null;
};
/** MealRecipeIn */
MealRecipeIn: {
/** Mealid */
mealId: number;
/** Recipeid */
recipeId: number;
/** Servings */
servings: number;
};
/** MemberRef */
MemberRef: {
/** Id */
id: number;
/** Displayname */
displayName: string;
}; };
/** Ok */ /** Ok */
Ok: { Ok: {
@ -685,7 +722,7 @@ export interface components {
list: components["schemas"]["ShoppingListOut"]; list: components["schemas"]["ShoppingListOut"];
/** Mealslookup */ /** Mealslookup */
mealsLookup: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal"];
}; };
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup: { ingredientsLookup: {
@ -693,44 +730,11 @@ export interface components {
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe"];
}; };
}; };
/** Recipe */ /** Recipe */
"Recipe-Input": { Recipe: {
/**
* Id
* @default -1
*/
id: number;
/** Name */
name: string;
/** Link */
link: string;
/** Serves */
serves: number;
/** Imageurls */
imageUrls?: string[];
/** Ingredients */
ingredients?: components["schemas"]["Ingredient"][];
/** Basedonrecipe */
basedOnRecipe?: number | null;
/**
* Datecreated
* Format: date-time
*/
dateCreated?: string;
/** Createdbyid */
createdById: number;
createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */
dateHidden?: string | null;
/** Hiddenbyid */
hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null;
};
/** Recipe */
"Recipe-Output": {
/** /**
* Id * Id
* @default -1 * @default -1
@ -1382,7 +1386,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"][]; "application/json": components["schemas"]["MealOut"][];
}; };
}; };
403: components["responses"]["Problem403"]; 403: components["responses"]["Problem403"];
@ -1415,7 +1419,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
403: components["responses"]["Problem403"]; 403: components["responses"]["Problem403"];
@ -1443,7 +1447,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["Meal-Input"]; "application/json": components["schemas"]["MealIn"];
}; };
}; };
responses: { responses: {
@ -1453,7 +1457,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1488,7 +1492,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
403: components["responses"]["Problem403"]; 403: components["responses"]["Problem403"];
@ -1526,7 +1530,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1554,7 +1558,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["Meal-Input"]; "application/json": components["schemas"]["MealIn"];
}; };
}; };
responses: { responses: {
@ -1564,7 +1568,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];

View file

@ -213,16 +213,8 @@ function addPerson(list: PeopleKey, person: Person) {
async function selectRecipe(recipe: { id: number | string }) { async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details // Refetch to get additional details
const r = await getRecipe(recipe.id) const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)
addPersonIfNotExists(meal.consumers, r.createdBy)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, r.createdBy)
}
}
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves }) meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
} }
@ -231,7 +223,7 @@ async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) { if (editing && meal.extraIngredients.length === 0) {
addIngredient() addIngredient()
} else { } else {
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line) meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
} }
} }

View file

@ -75,7 +75,8 @@ import { useAlert } from '@/composables/useAlert'
import { parseQueryString } from '@/router/helpers' import { parseQueryString } from '@/router/helpers'
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk' import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types' import type { Recipe as DomainRecipe, Ingredient } from '@/domain/types'
import type { components } from '@/api/types'
const props = defineProps({ const props = defineProps({
id: { type: String, required: false, default: undefined }, id: { type: String, required: false, default: undefined },
@ -108,7 +109,8 @@ function parseLink() {
async function refreshRecipe() { async function refreshRecipe() {
const id = props.id ? parseInt(props.id) : null const id = props.id ? parseInt(props.id) : null
if (id !== null && id >= 0) { if (id !== null && id >= 0) {
const r = await getRecipe(id) const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, id)
recipe.value = r recipe.value = r
link.value = r.link ?? '' link.value = r.link ?? ''
return return
@ -134,7 +136,7 @@ function deleteIngredient(ingredient: Ingredient) {
} }
async function saveRecipe() { async function saveRecipe() {
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null const saved = recipe.value ? await saveRecipeApi(toRecipeCreate(recipe.value)) : null
if (saved && saved.id >= 0) { if (saved && saved.id >= 0) {
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' }) showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
router.push(`/recipes/${saved.id}`) router.push(`/recipes/${saved.id}`)
@ -147,7 +149,6 @@ function createFromScratch() {
recipe.value = { recipe.value = {
id: -1, id: -1,
name: 'My new recipe', name: 'My new recipe',
createdById: -1,
link: '', link: '',
ingredients: [], ingredients: [],
imageUrls: [], imageUrls: [],
@ -189,21 +190,13 @@ watch(
) )
// expose functions for template binding names (automatic in <script setup>) // expose functions for template binding names (automatic in <script setup>)
function toRecipeInput(r: DomainRecipe): RecipeInput { function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate'] {
return { return {
id: r.id,
name: r.name, name: r.name,
link: r.link, link: r.link,
serves: r.serves, serves: r.serves,
imageUrls: r.imageUrls ?? [], imageUrls: r.imageUrls ?? [],
ingredients: r.ingredients ?? [], ingredients: r.ingredients ?? [],
basedOnRecipe: r.basedOnRecipe ?? null,
// let backend set created/hidden dates
createdById: r.createdById,
createdBy: r.createdBy ?? null,
// dateHidden omitted
hiddenById: r.hiddenById ?? null,
hiddenBy: r.hiddenBy ?? null,
} }
} }
</script> </script>

View file

@ -1,31 +1,34 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' import { server, http, HttpResponse } from './test-setup'
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk' import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('meals api (typed client)', () => { describe('meals api (typed client)', () => {
it('gets a meal by id', async () => { it('gets a meal by id', async () => {
server.use( server.use(
http.get('*/api/v1/meals/:id', ({ params }) => { http.get('*/api/v1/households/:householdSlug/meals/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' }) return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
}) })
) )
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await getMeal(10) const meal = await getMeal(10)
expect(meal.id).toBeDefined() expect(meal.id).toBeDefined()
}) })
it('marks a meal consumed', async () => { it('marks a meal consumed', async () => {
server.use( server.use(
http.post('*/api/v1/meals/:id/consumed', () => { http.post('*/api/v1/households/:householdSlug/meals/:id/consumed', () => {
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' }) return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
}) })
) )
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await markMealConsumed(10) const meal = await markMealConsumed(10)
expect(meal.consumedDate).toBeInstanceOf(Date) expect(meal.consumedDate).toBeInstanceOf(Date)
}) })
it('lists upcoming meals', async () => { it('lists upcoming meals', async () => {
server.use( server.use(
http.get('*/api/v1/meals/upcoming', ({ request }) => { http.get('*/api/v1/households/:householdSlug/meals/upcoming', ({ request }) => {
const url = new URL(request.url) const url = new URL(request.url)
if (!url.searchParams.get('from') || !url.searchParams.get('to')) { if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
return new HttpResponse(null, { status: 400 }) return new HttpResponse(null, { status: 400 })
@ -36,6 +39,7 @@ describe('meals api (typed client)', () => {
]) ])
}) })
) )
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z')) const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
expect(Array.isArray(list)).toBe(true) expect(Array.isArray(list)).toBe(true)
expect(list[0].suggestedDate).toBeInstanceOf(Date) expect(list[0].suggestedDate).toBeInstanceOf(Date)

View file

@ -5,8 +5,8 @@ import { getRecipe } from '@/api/sdk'
describe('recipes api (errors)', () => { describe('recipes api (errors)', () => {
it('throws on 404 getRecipe', async () => { it('throws on 404 getRecipe', async () => {
server.use( server.use(
http.get('*/api/v1/recipes/:id', () => new HttpResponse(null, { status: 404 })) http.get('*/api/v1/households/:householdSlug/recipes/:id', () => new HttpResponse(null, { status: 404 }))
) )
await expect(getRecipe('999')).rejects.toBeTruthy() await expect(getRecipe('the-smiths', '999')).rejects.toBeTruthy()
}) })
}) })

View file

@ -1,13 +1,14 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk' import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Tests validate the typed client wrapper behavior without needing the backend // Tests validate the typed client wrapper behavior without needing the backend
describe('recipes api (typed client)', () => { describe('recipes api (typed client)', () => {
it('gets a recipe by id', async () => { it('gets a recipe by id', async () => {
server.use( server.use(
http.get('*/api/v1/recipes/:id', ({ params }) => { http.get('*/api/v1/households/:householdSlug/recipes/:id', ({ params }) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('MSW handler hit with params:', params) console.log('MSW handler hit with params:', params)
const { id } = params const { id } = params
@ -15,7 +16,8 @@ describe('recipes api (typed client)', () => {
}) })
) )
const data = await getRecipe('123') setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', '123')
expect(data).toBeTruthy() expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes') expect(data.name).toBe('Pancakes')
}) })

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Drive migration to path-scoped recipes via householdSlug
describe('recipes api v2 (household-scoped)', () => {
it('gets a recipe by id with householdSlug path', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/recipes/:recipe_id', ({ params }) => {
const { householdSlug, recipe_id } = params
expect(householdSlug).toBe('the-smiths')
return HttpResponse.json({ id: Number(recipe_id), name: 'Pancakes', ingredients: [] }, { status: 200 })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', 123)
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})
})

View file

@ -1,30 +1,35 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup' import { server, http, HttpResponse } from './test-setup'
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk' import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('shopping api (typed client)', () => { describe('shopping api (typed client)', () => {
it('gets current shopping list', async () => { it('gets current shopping list', async () => {
server.use(http.get('*/api/v1/shopping/current', () => HttpResponse.json({ outstandingItems: [] }))) server.use(http.get('*/api/v1/households/:householdSlug/shopping/current', () => HttpResponse.json({ outstandingItems: [], requestedMeals: [], purchasedItems: [], ingredientsLookup: {}, mealsLookup: {}, shoppingListLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getCurrentShoppingList() const list = await getCurrentShoppingList()
expect(list).toBeTruthy() expect(list).toBeTruthy()
}) })
it('gets a purchased shopping list by id', async () => { it('gets a purchased shopping list by id', async () => {
server.use(http.get('*/api/v1/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] } }))) server.use(http.get('*/api/v1/households/:householdSlug/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getShoppingList(99) const list = await getShoppingList(99)
expect(list.id).toBe(99) expect(list.id).toBe(99)
}) })
it('requests and unrequests a meal', async () => { it('requests and unrequests a meal', async () => {
server.use(http.post('*/api/v1/shopping/current/meals/me', () => HttpResponse.json([{ id: 1 }]))) server.use(http.post('*/api/v1/households/:householdSlug/shopping/current/meals/me', () => HttpResponse.json({ id: 1, personId: 1, mealId: 44, createdDate: new Date().toISOString(), kind: 'requestedMeal' })))
setHouseholdSlugProvider(() => 'the-smiths')
await requestMeal(44) await requestMeal(44)
server.use(http.delete('*/api/v1/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 }))) server.use(http.delete('*/api/v1/households/:householdSlug/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
await unrequestMeal(44) await unrequestMeal(44)
}) })
it('purchases a list', async () => { it('purchases a list', async () => {
server.use(http.post('*/api/v1/shopping', () => HttpResponse.json({ list: { id: 1, items: [] } }))) server.use(http.post('*/api/v1/households/:householdSlug/shopping', () => HttpResponse.json({ list: { id: 1, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }]) const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
expect(list.id).toBe(1) expect(list.id).toBe(1)
}) })