From b08561c9bbc23a5a30aaa6ea57a72775a985102c Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 12:22:52 +1100 Subject: [PATCH] Squashed commit of the following: commit 594a14009096f7321ff92638e05cfa7c5ae6ca07 Author: jableader Date: Sat Nov 1 12:20:33 2025 +1100 API update - avoid optional arrays commit 85787384cfc24dc28953ebb4630fe7614cb0ead1 Author: jableader Date: Sat Nov 1 10:14:51 2025 +1100 refactor(types): add NonNullableArrays and apply to Meal arrays; test: add shopping mapper boundary tests; all checks green commit 1c50b7e23585cd4a34b5a691ffbf87d6d123f474 Author: jableader Date: Sun Oct 26 15:30:45 2025 +1100 Use discriminated unions and overloads --- src/api/sdk.ts | 11 ++- src/api/types.ts | 115 +++++++++++++++++++----- src/domain/commands.ts | 5 ++ src/domain/decoders.ts | 30 +++---- src/domain/types.ts | 21 +++-- tests/shopping.mappers.boundary.test.ts | 48 ++++++++++ 6 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 src/domain/commands.ts create mode 100644 tests/shopping.mappers.boundary.test.ts diff --git a/src/api/sdk.ts b/src/api/sdk.ts index ccf863b..b7b13df 100644 --- a/src/api/sdk.ts +++ b/src/api/sdk.ts @@ -26,6 +26,7 @@ import type { } from '@/domain/types' import { fromOpenApiPage, type Page } from '@/domain/pagination' +import type { PurchaseRequest } from '@/domain/commands' function httpError(response: Response, error: unknown): Error { if (error instanceof Error) return error if (typeof error === 'string') return new Error(error) @@ -310,9 +311,7 @@ export async function getCurrentShoppingList(): Promise return mapped } -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 type PurchaseRequest = PurchaseExisting | PurchaseRefs +// PurchaseRequest comes from domain/commands export async function purchaseShoppingList( completedRequests: PurchaseRequest[] @@ -330,7 +329,8 @@ export async function purchaseShoppingList( if (items.length === 0) return null const body: components['schemas']['PurchaseListIn'] = { - storeName: '', + // Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths") + storeName: 'home', items, } const { data, error, response } = await api.POST('/api/v1/shopping', { @@ -356,3 +356,6 @@ export async function unrequestMeal(mealId: number | string): Promise { }) if (!response.ok) throw httpError(response, error) } + +// Re-export domain command types for convenience at SDK surface +export type { PurchaseRequest } from '@/domain/commands' diff --git a/src/api/types.ts b/src/api/types.ts index b01a23d..827c9cf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -344,21 +344,21 @@ export interface components { /** Requestedmeals */ requestedMeals: components["schemas"]["RequestedMealItem"][]; /** Purchaseditems */ - purchasedItems?: components["schemas"]["ListIngredientItem"][]; + purchasedItems: components["schemas"]["ListIngredientItem"][]; /** Ingredientslookup */ - ingredientsLookup?: { + ingredientsLookup: { [key: string]: components["schemas"]["Ingredient"]; }; /** Mealslookup */ - mealsLookup?: { + mealsLookup: { [key: string]: components["schemas"]["Meal-Output"]; }; /** Shoppinglistlookup */ - shoppingListLookup?: { + shoppingListLookup: { [key: string]: components["schemas"]["ShoppingListOut"]; }; /** Recipeslookup */ - recipesLookup?: { + recipesLookup: { [key: string]: components["schemas"]["Recipe-Output"]; }; }; @@ -511,6 +511,33 @@ export interface components { /** Mealid */ mealId: number; }; + /** MealOut */ + MealOut: { + /** + * 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: components["schemas"]["Ingredient"][]; + /** Purchasedate */ + purchaseDate?: string | null; + }; /** MealRecipe */ "MealRecipe-Input": { /** Mealid */ @@ -554,10 +581,10 @@ export interface components { */ total: number; }; - /** Page[Recipe] */ - Page_Recipe_: { + /** Page[RecipeOut] */ + Page_RecipeOut_: { /** Items */ - items: components["schemas"]["Recipe-Output"][]; + items: components["schemas"]["RecipeOut"][]; /** Nextcursor */ nextCursor?: string | null; /** Prevcursor */ @@ -630,7 +657,7 @@ export interface components { }; /** PurchaseListIn */ PurchaseListIn: { - storeName: components["schemas"]["StoreEnum"]; + storeName: components["schemas"]["StoreNameOut"]; /** Items */ items: components["schemas"]["IngredientPurchaseItemIn"][]; }; @@ -638,15 +665,15 @@ export interface components { PurchasedShoppingList: { list: components["schemas"]["ShoppingListOut"]; /** Mealslookup */ - mealsLookup?: { + mealsLookup: { [key: string]: components["schemas"]["Meal-Output"]; }; /** Ingredientslookup */ - ingredientsLookup?: { + ingredientsLookup: { [key: string]: components["schemas"]["Ingredient"]; }; /** Recipeslookup */ - recipesLookup?: { + recipesLookup: { [key: string]: components["schemas"]["Recipe-Output"]; }; }; @@ -716,6 +743,39 @@ export interface components { hiddenById?: number | null; hiddenBy?: components["schemas"]["Person"] | null; }; + /** RecipeOut */ + RecipeOut: { + /** + * 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; + }; /** RequestedMealItem */ RequestedMealItem: { /** @@ -749,12 +809,16 @@ export interface components { * Format: date-time */ createdDate: string; - storeName: components["schemas"]["StoreEnum"]; + /** + * Storename + * @enum {string} + */ + storeName: "woolworths" | "coles" | "home"; /** Purchasedbyid */ purchasedById: number; purchasedBy?: components["schemas"]["Person"] | null; /** Items */ - items?: components["schemas"]["ListIngredientItem"][]; + items: components["schemas"]["ListIngredientItem"][]; }; /** * StoreEnum @@ -770,6 +834,11 @@ export interface components { /** Error Type */ type: string; }; + /** + * StoreNameOut + * @enum {string} + */ + StoreNameOut: "woolworths" | "coles" | "home"; }; responses: { /** @description Bad Request */ @@ -862,7 +931,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Recipe-Output"]; + "application/json": components["schemas"]["RecipeOut"]; }; }; 400: components["responses"]["Problem400"]; @@ -947,7 +1016,7 @@ export interface operations { * "total": 1 * } */ - "application/json": components["schemas"]["Page_Recipe_"]; + "application/json": components["schemas"]["Page_RecipeOut_"]; }; }; /** @description Validation Error */ @@ -982,7 +1051,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Recipe-Output"]; + "application/json": components["schemas"]["RecipeOut"]; }; }; 400: components["responses"]["Problem400"]; @@ -1014,7 +1083,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Recipe-Output"]; + "application/json": components["schemas"]["RecipeOut"]; }; }; 404: components["responses"]["Problem404"]; @@ -1112,7 +1181,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Meal-Output"]; + "application/json": components["schemas"]["MealOut"]; }; }; 404: components["responses"]["Problem404"]; @@ -1148,7 +1217,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Meal-Output"]; + "application/json": components["schemas"]["MealOut"]; }; }; 400: components["responses"]["Problem400"]; @@ -1183,7 +1252,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Meal-Output"]; + "application/json": components["schemas"]["MealOut"]; }; }; 404: components["responses"]["Problem404"]; @@ -1217,7 +1286,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Meal-Output"]; + "application/json": components["schemas"]["MealOut"]; }; }; 400: components["responses"]["Problem400"]; @@ -1253,7 +1322,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Meal-Output"]; + "application/json": components["schemas"]["MealOut"]; }; }; 400: components["responses"]["Problem400"]; diff --git a/src/domain/commands.ts b/src/domain/commands.ts new file mode 100644 index 0000000..34cc649 --- /dev/null +++ b/src/domain/commands.ts @@ -0,0 +1,5 @@ +// Domain command types: stable UI intents, translated to OpenAPI at the SDK boundary + +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 diff --git a/src/domain/decoders.ts b/src/domain/decoders.ts index 5d2de67..c743a69 100644 --- a/src/domain/decoders.ts +++ b/src/domain/decoders.ts @@ -1,19 +1,4 @@ -import type { - RecipeOut, - Recipe, - MealOut, - Meal, - MealRecipe, - Ingredient as DomainIngredient, - ShoppingList, - ShoppingListItem, - ShoppingListItemWithRefs, - MealInput, - ListIngredientItem, - RequestedMealItem, - ListIngredientItemWithRefs, - RequestedMealItemWithRefs, -} from './types' +import type { Recipe, 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 { @@ -38,16 +23,25 @@ export function decodeLookup( return out } -export function decodeRecipe(r: RecipeOut | null | undefined): Recipe { +export function decodeRecipe( + r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined +): Recipe { if (!r) throw new Error('Invalid recipe payload') + // Normalize arrays that may be optional in legacy Recipe-Output + const imageUrls = r.imageUrls ?? [] + const ingredients = r.ingredients ?? [] return { ...r, + imageUrls, + ingredients, dateCreated: toDate(r.dateCreated), dateHidden: toDate(r.dateHidden), } } -export function decodeMeal(m: MealOut | null | undefined): Meal { +export function decodeMeal( + m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined +): Meal { if (!m) throw new Error('Invalid meal payload') const recipes = Array.isArray(m.recipes) ? m.recipes.map((mr) => decodeMealRecipe(mr)) diff --git a/src/domain/types.ts b/src/domain/types.ts index de4eb4f..eef107c 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -3,6 +3,10 @@ import type { components } from '@/api/types' // Utility mapped types export type Replace = Omit & M export type WithDates = Replace +// Convert selected array keys to their non-nullable array counterparts +export type NonNullableArrays = Replace extends Array ? U[] : T[P] }> +// Make selected keys required (non-optional) on a type +export type RequiredKeys = Replace }> // Common helpers (intentionally minimal to avoid unused exports) export type Lookup = Record @@ -10,8 +14,8 @@ export type Lookup = Record export type WithRefs = T & { [K in keyof Refs]?: Refs[K] | undefined } // Domain type aliases -export type RecipeOut = components['schemas']['Recipe-Output'] -export type MealOut = components['schemas']['Meal-Output'] +export type RecipeOut = components['schemas']['RecipeOut'] +export type MealOut = components['schemas']['MealOut'] export type Ingredient = components['schemas']['Ingredient'] export type Product = components['schemas']['Product'] export type Person = components['schemas']['Person'] @@ -27,15 +31,10 @@ export type MealRecipe = Replace // Meal with decoded dates and nested MealRecipe with decoded recipe dates // Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec -export type Meal = Replace< - WithDates, - { - recipes: MealRecipe[] - chefs: Person[] - consumers: Person[] - cleanup: Person[] - extraIngredients: Ingredient[] - } +type MealBase = Replace, { recipes: MealRecipe[] }> +export type Meal = RequiredKeys< + NonNullableArrays, + 'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients' > // Shopping domain shapes with dates normalized diff --git a/tests/shopping.mappers.boundary.test.ts b/tests/shopping.mappers.boundary.test.ts new file mode 100644 index 0000000..1edb65b --- /dev/null +++ b/tests/shopping.mappers.boundary.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk' + +// These tests lock boundary behavior for partial/missing lookups and date normalization + +describe('shopping mappers boundary', () => { + it('handles missing lookups gracefully (no refs attached)', () => { + const dto = { + outstandingItems: [ + { ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }, + ], + requestedMeals: [ + { mealId: 2, createdDate: '2025-01-02T00:00:00Z' }, + ], + purchasedItems: [], + } + const mapped = mapCurrentShoppingList(dto as any) + expect(mapped.outstandingItems[0].ingredient).toBeUndefined() + expect(mapped.outstandingItems[0].meal).toBeUndefined() + expect(mapped.outstandingItems[0].recipe).toBeUndefined() + expect(mapped.outstandingItems[0].list).toBeUndefined() + expect(mapped.requestedMeals[0].meal).toBeUndefined() + // Dates normalized + expect(mapped.outstandingItems[0].createdDate).toBeInstanceOf(Date) + expect(mapped.requestedMeals[0].createdDate).toBeInstanceOf(Date) + }) + + it('normalizes dates on purchased list and items even with partial lookups', () => { + const dto = { + ingredientsLookup: { 10: { id: 10, name: 'Milk' } }, + list: { + id: 11, + createdDate: '2025-03-03T00:00:00Z', + items: [ + { ingredientId: 10, listId: 11, createdDate: '2025-03-03T00:00:00Z' }, + { ingredientId: 99, listId: 11, createdDate: '2025-03-03T00:00:00Z' }, + ], + }, + } + const mapped = mapPurchasedShoppingList(dto as any) + expect(mapped.list.createdDate).toBeInstanceOf(Date) + expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date) + expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date) + // First item gets ingredient ref, second does not + expect(mapped.list.items[0].ingredient?.name).toBe('Milk') + expect(mapped.list.items[1].ingredient).toBeUndefined() + }) +})