From f2dee9ca30bdb8242878aebb7fe4f57971978f21 Mon Sep 17 00:00:00 2001 From: jableader Date: Sun, 26 Oct 2025 15:13:33 +1100 Subject: [PATCH] Stricter api changes --- README.md | 12 ++++ src/api/sdk.ts | 73 +++++++++++--------- src/api/types.ts | 122 ++++++++++++++++++++++----------- src/composables/useShopping.ts | 24 +++---- src/domain/decoders.ts | 50 ++++++++++++-- src/domain/types.ts | 19 +++-- 6 files changed, 203 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index c6e2253..708b641 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,15 @@ Environment Testing - Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking + +### CurrentShoppingList item kinds + +The OpenAPI spec models current shopping list items as distinct kinds: +- outstandingItems: ListIngredientItem[] +- requestedMeals: RequestedMealItem[] +- purchasedItems: ListIngredientItem[] + +The SDK maps these to a domain DTO (`CurrentShoppingListDTO`) and may attach refs (`ingredient`, `recipe`, `meal`, `list`) for convenience. UI code should: +- Prefer stable IDs (`ingredientId`, `mealId`, `recipeId`, `listId`) for actions and lookups +- Treat attached refs as optional view helpers (never required) +- Keep all normalization at the boundary (decoders); avoid casts and runtime type checks in app code diff --git a/src/api/sdk.ts b/src/api/sdk.ts index ca1c644..ccf863b 100644 --- a/src/api/sdk.ts +++ b/src/api/sdk.ts @@ -1,12 +1,25 @@ import { api } from '@/api/client' import type { components } from '@/api/types' -import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient, decodeLookup } from '@/domain/decoders' +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, @@ -24,34 +37,32 @@ function httpError(response: Response, error: unknown): Error { // Shopping list mapped view types now come from domain/types function attachItemRefs( - items: Array | null | undefined, + items: Array | 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)] + // ingredient ref + if ('ingredientId' in item && item.ingredientId !== undefined && lookups.ingredientsLookup) { + const v = lookups.ingredientsLookup[String(item.ingredientId)] if (v !== undefined) item.ingredient = v } - if (mealId !== undefined && lookups.mealsLookup) { - const v = lookups.mealsLookup[String(mealId)] + // 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 } - if (recipeId !== undefined && lookups.recipesLookup) { - const v = lookups.recipesLookup[String(recipeId)] + // recipe ref + if ('recipeId' in item && item.recipeId !== undefined && lookups.recipesLookup) { + const v = lookups.recipesLookup[String(item.recipeId)] if (v !== undefined) item.recipe = v } - if (listId !== undefined && lookups.shoppingListLookup) { - const v = lookups.shoppingListLookup[String(listId)] + // 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 (created !== undefined) item.createdDate = toDate(created) + if ('createdDate' in item) item.createdDate = toDate(item.createdDate) } } @@ -115,9 +126,9 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi } const dtoOut: CurrentShoppingListDTO = { - outstandingItems: decodeShoppingListItems(outstandingRaw ?? []), - requestedMeals: decodeShoppingListItems(requestedRaw ?? []), - purchasedItems: decodeShoppingListItems(purchasedRaw ?? []), + outstandingItems: decodeListIngredientItems(outstandingRaw ?? []), + requestedMeals: decodeRequestedMealItems(requestedRaw ?? []), + purchasedItems: decodeListIngredientItems(purchasedRaw ?? []), ...(ingredientsLookup && { ingredientsLookup }), ...(mealsLookup && { mealsLookup }), ...(recipesLookup && { recipesLookup }), @@ -308,24 +319,18 @@ export async function purchaseShoppingList( ): Promise { 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, - } - ) + 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']['ShoppingList'] = { - id: -1, + const body: components['schemas']['PurchaseListIn'] = { storeName: '', - purchasedById: -1, items, } const { data, error, response } = await api.POST('/api/v1/shopping', { diff --git a/src/api/types.ts b/src/api/types.ts index b49e475..b01a23d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -340,11 +340,11 @@ export interface components { /** CurrentShoppingList */ CurrentShoppingList: { /** Outstandingitems */ - outstandingItems: components["schemas"]["ShoppingListItem"][]; + outstandingItems: components["schemas"]["ListIngredientItem"][]; /** Requestedmeals */ - requestedMeals: components["schemas"]["ShoppingListItem"][]; + requestedMeals: components["schemas"]["RequestedMealItem"][]; /** Purchaseditems */ - purchasedItems?: components["schemas"]["ShoppingListItem"][]; + purchasedItems?: components["schemas"]["ListIngredientItem"][]; /** Ingredientslookup */ ingredientsLookup?: { [key: string]: components["schemas"]["Ingredient"]; @@ -355,7 +355,7 @@ export interface components { }; /** Shoppinglistlookup */ shoppingListLookup?: { - [key: string]: components["schemas"]["ShoppingList"]; + [key: string]: components["schemas"]["ShoppingListOut"]; }; /** Recipeslookup */ recipesLookup?: { @@ -404,6 +404,49 @@ export interface components { mealId?: number | null; product?: components["schemas"]["Product"] | null; }; + /** IngredientPurchaseItemIn */ + IngredientPurchaseItemIn: { + /** Ingredientid */ + ingredientId: number; + /** Personid */ + personId: number; + /** Createddate */ + createdDate?: string | null; + /** Mealid */ + mealId?: number | null; + /** Recipeid */ + recipeId?: number | null; + }; + /** ListIngredientItem */ + ListIngredientItem: { + /** + * Kind + * @default ingredient + * @constant + * @enum {string} + */ + kind: "ingredient"; + /** + * Id + * @default -1 + */ + id: number; + /** Ingredientid */ + ingredientId: number; + /** Personid */ + personId: number; + /** + * Createddate + * Format: date-time + */ + createdDate: string; + /** Listid */ + listId?: number | null; + /** Mealid */ + mealId?: number | null; + /** Recipeid */ + recipeId?: number | null; + }; /** LoginBody */ LoginBody: { /** Username */ @@ -585,9 +628,15 @@ export interface components { /** Tags */ tags?: string[]; }; + /** PurchaseListIn */ + PurchaseListIn: { + storeName: components["schemas"]["StoreEnum"]; + /** Items */ + items: components["schemas"]["IngredientPurchaseItemIn"][]; + }; /** PurchasedShoppingList */ PurchasedShoppingList: { - list: components["schemas"]["ShoppingList"]; + list: components["schemas"]["ShoppingListOut"]; /** Mealslookup */ mealsLookup?: { [key: string]: components["schemas"]["Meal-Output"]; @@ -667,54 +716,45 @@ export interface components { hiddenById?: number | null; hiddenBy?: components["schemas"]["Person"] | null; }; - /** ShoppingList */ - ShoppingList: { + /** RequestedMealItem */ + RequestedMealItem: { + /** + * Kind + * @default requestedMeal + * @constant + * @enum {string} + */ + kind: "requestedMeal"; /** * Id * @default -1 */ id: number; + /** Personid */ + personId: number; + /** Mealid */ + mealId: number; /** * Createddate * Format: date-time */ - createdDate?: string; - /** @default */ - storeName: components["schemas"]["StoreEnum"]; + createdDate: string; + }; + /** ShoppingListOut */ + ShoppingListOut: { + /** Id */ + id: number; /** - * Purchasedbyid - * @default -1 + * Createddate + * Format: date-time */ + createdDate: string; + storeName: components["schemas"]["StoreEnum"]; + /** Purchasedbyid */ purchasedById: number; purchasedBy?: components["schemas"]["Person"] | null; /** Items */ - items?: components["schemas"]["ShoppingListItem"][]; - }; - /** ShoppingListItem */ - ShoppingListItem: { - /** - * Id - * @default -1 - */ - id: number; - /** Listid */ - listId?: number | null; - /** - * Personid - * @default -1 - */ - personId: number; - /** Ingredientid */ - ingredientId?: number | null; - /** Recipeid */ - recipeId?: number | null; - /** Mealid */ - mealId?: number | null; - /** - * Createddate - * Format: date-time - */ - createdDate?: string; + items?: components["schemas"]["ListIngredientItem"][]; }; /** * StoreEnum @@ -1293,7 +1333,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ShoppingList"]; + "application/json": components["schemas"]["PurchaseListIn"]; }; }; responses: { @@ -1415,7 +1455,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ShoppingListItem"]; + "application/json": components["schemas"]["RequestedMealItem"]; }; }; 404: components["responses"]["Problem404"]; diff --git a/src/composables/useShopping.ts b/src/composables/useShopping.ts index 6c59922..e61b8f0 100644 --- a/src/composables/useShopping.ts +++ b/src/composables/useShopping.ts @@ -1,15 +1,15 @@ import * as sdk from '@/api/sdk' -import type { ShoppingListItemWithRefs, Product, Meal } from '@/domain/types' +import type { ListIngredientItemWithRefs, RequestedMealItemWithRefs, Product, Meal } from '@/domain/types' -export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ShoppingListItemWithRefs[] } -export type GroupByName = { type: 'name'; name: string; shoppingListItems: ShoppingListItemWithRefs[] } +export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ListIngredientItemWithRefs[] } +export type GroupByName = { type: 'name'; name: string; shoppingListItems: ListIngredientItemWithRefs[] } export type Group = GroupByProduct | GroupByName -export function groupsToItems(groups: Group[]): ShoppingListItemWithRefs[] { +export function groupsToItems(groups: Group[]): ListIngredientItemWithRefs[] { return groups.map((g) => g.shoppingListItems).flat() } -export function uniqueMeals(shoppingListItems: ShoppingListItemWithRefs[]): Meal[] { +export function uniqueMeals(shoppingListItems: Array): Meal[] { const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m): m is Meal => !!m) const mealsLookup: Record = mealsWithDuplicates.reduce>( (acc, meal) => { @@ -21,7 +21,7 @@ export function uniqueMeals(shoppingListItems: ShoppingListItemWithRefs[]): Meal return Object.values(mealsLookup) } -export function itemsToGroups(shoppingListItems: ShoppingListItemWithRefs[]): Group[] { +export function itemsToGroups(shoppingListItems: ListIngredientItemWithRefs[]): Group[] { const ingredients_by_product_id: Record = {} const ingredients_by_name: Record = {} for (const item of shoppingListItems) { @@ -52,8 +52,8 @@ export function itemsToGroups(shoppingListItems: ShoppingListItemWithRefs[]): Gr } export function useShopping() { - const groupsFrom = (items?: ShoppingListItemWithRefs[]): Group[] => itemsToGroups(items ?? []) - const mealsFrom = (items?: ShoppingListItemWithRefs[]): Meal[] => uniqueMeals(items ?? []) + const groupsFrom = (items?: ListIngredientItemWithRefs[]): Group[] => itemsToGroups(items ?? []) + const mealsFrom = (items?: Array): Meal[] => uniqueMeals(items ?? []) return { getCurrentShoppingList: sdk.getCurrentShoppingList, getShoppingList: sdk.getShoppingList, @@ -68,14 +68,14 @@ export function useShopping() { async purchaseFromGroups(groups: Group[]) { const items = groupsToItems(groups).map((i): sdk.PurchaseRequest => { if (typeof i.id === 'number' && i.id >= 0) { - return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredient?.id ?? null } + return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null } } return { type: 'refs', personId: i.personId, - ingredientId: i.ingredient?.id ?? null, - recipeId: i.recipe?.id ?? null, - mealId: i.meal?.id ?? null, + ingredientId: i.ingredientId ?? null, + recipeId: i.recipeId ?? null, + mealId: i.mealId ?? null, } }) if (!items?.length) return null diff --git a/src/domain/decoders.ts b/src/domain/decoders.ts index a3ac419..5d2de67 100644 --- a/src/domain/decoders.ts +++ b/src/domain/decoders.ts @@ -1,4 +1,19 @@ -import type { RecipeOut, Recipe, MealOut, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput } from './types' +import type { + RecipeOut, + Recipe, + MealOut, + Meal, + MealRecipe, + Ingredient as DomainIngredient, + ShoppingList, + ShoppingListItem, + ShoppingListItemWithRefs, + MealInput, + ListIngredientItem, + RequestedMealItem, + ListIngredientItemWithRefs, + RequestedMealItemWithRefs, +} from './types' import type { components } from '@/api/types' export function toDate(value: string | Date | null | undefined): Date | null { @@ -70,7 +85,7 @@ export function decodeIngredients(list: components['schemas']['Ingredient'][] | return list.map((i) => decodeIngredient(i)) } -function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | null | undefined): ShoppingListItem { +function decodeShoppingListItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ShoppingListItem { if (!i) throw new Error('Invalid shopping list item payload') return { ...i, @@ -78,13 +93,13 @@ function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | n } } -export function decodeShoppingListItems(list: components['schemas']['ShoppingListItem'][] | null | undefined): ShoppingListItemWithRefs[] { +export function decodeShoppingListItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ShoppingListItemWithRefs[] { if (!Array.isArray(list)) return [] // Build a new array with item clones to allow optional refs to be attached later return list.map((raw) => ({ ...decodeShoppingListItem(raw) })) } -export function decodeShoppingList(v: components['schemas']['ShoppingList'] | null | undefined): ShoppingList { +export function decodeShoppingList(v: components['schemas']['ShoppingListOut'] | null | undefined): ShoppingList { if (!v) throw new Error('Invalid shopping list payload') const { items: rawItems, ...rest } = v const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined @@ -95,6 +110,33 @@ export function decodeShoppingList(v: components['schemas']['ShoppingList'] | nu } } +// New item decoders for tightened CurrentShoppingList +function decodeListIngredientItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ListIngredientItem { + if (!i) throw new Error('Invalid list ingredient item payload') + return { + ...i, + createdDate: toDate(i.createdDate), + } +} + +export function decodeListIngredientItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ListIngredientItemWithRefs[] { + if (!Array.isArray(list)) return [] + return list.map((raw) => ({ ...decodeListIngredientItem(raw) })) +} + +function decodeRequestedMealItem(i: components['schemas']['RequestedMealItem'] | null | undefined): RequestedMealItem { + if (!i) throw new Error('Invalid requested meal item payload') + return { + ...i, + createdDate: toDate(i.createdDate), + } +} + +export function decodeRequestedMealItems(list: components['schemas']['RequestedMealItem'][] | null | undefined): RequestedMealItemWithRefs[] { + if (!Array.isArray(list)) return [] + return list.map((raw) => ({ ...decodeRequestedMealItem(raw) })) +} + // Helper to convert domain Meal to MealInput, keeping Date→string conversion in boundary export function toMealInput(meal: Meal): MealInput { return { diff --git a/src/domain/types.ts b/src/domain/types.ts index 54c02e3..de4eb4f 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -39,12 +39,19 @@ export type Meal = Replace< > // Shopping domain shapes with dates normalized -export type ShoppingListItem = WithDates -type ShoppingListBase = WithDates +// Items inside purchased lists and current lists share the ListIngredientItem shape +export type ShoppingListItem = WithDates +type ShoppingListBase = WithDates export type ShoppingList = Replace -// Refs attached to shopping list items +// Current shopping list item types (tightened OpenAPI) +export type ListIngredientItem = WithDates +export type RequestedMealItem = WithDates + +// Refs attached to items export type ShoppingListItemWithRefs = WithRefs +export type ListIngredientItemWithRefs = WithRefs +export type RequestedMealItemWithRefs = WithRefs export type ShoppingListWithRefs = Replace @@ -58,9 +65,9 @@ export type ShoppingLookups = { // DTO shapes returned by SDK for shopping pages export type CurrentShoppingListDTO = { - outstandingItems: ShoppingListItemWithRefs[] - requestedMeals: ShoppingListItemWithRefs[] - purchasedItems: ShoppingListItemWithRefs[] + outstandingItems: ListIngredientItemWithRefs[] + requestedMeals: RequestedMealItemWithRefs[] + purchasedItems: ListIngredientItemWithRefs[] } & ShoppingLookups export type PurchasedShoppingListDTO = ShoppingLookups & {