Squashed commit of the following:
Some checks failed
CI / build-test (push) Has been cancelled

commit 594a14009096f7321ff92638e05cfa7c5ae6ca07
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Nov 1 12:20:33 2025 +1100

    API update - avoid optional arrays

commit 85787384cfc24dc28953ebb4630fe7614cb0ead1
Author: jableader <jacobdunk@gmail.com>
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 <jacobdunk@gmail.com>
Date:   Sun Oct 26 15:30:45 2025 +1100

    Use discriminated unions and overloads
This commit is contained in:
jableader 2025-11-01 12:22:52 +11:00
parent f2dee9ca30
commit b08561c9bb
6 changed files with 174 additions and 56 deletions

View file

@ -26,6 +26,7 @@ import type {
} from '@/domain/types' } from '@/domain/types'
import { fromOpenApiPage, type Page } from '@/domain/pagination' import { fromOpenApiPage, type Page } from '@/domain/pagination'
import type { PurchaseRequest } from '@/domain/commands'
function httpError(response: Response, error: unknown): Error { function httpError(response: Response, error: unknown): Error {
if (error instanceof Error) return error if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error) if (typeof error === 'string') return new Error(error)
@ -310,9 +311,7 @@ export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO>
return mapped return mapped
} }
type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null } // PurchaseRequest comes from domain/commands
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
export async function purchaseShoppingList( export async function purchaseShoppingList(
completedRequests: PurchaseRequest[] completedRequests: PurchaseRequest[]
@ -330,7 +329,8 @@ export async function purchaseShoppingList(
if (items.length === 0) return null if (items.length === 0) return null
const body: components['schemas']['PurchaseListIn'] = { const body: components['schemas']['PurchaseListIn'] = {
storeName: '', // Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths")
storeName: 'home',
items, items,
} }
const { data, error, response } = await api.POST('/api/v1/shopping', { const { data, error, response } = await api.POST('/api/v1/shopping', {
@ -356,3 +356,6 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
// Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands'

View file

@ -344,21 +344,21 @@ export interface components {
/** Requestedmeals */ /** Requestedmeals */
requestedMeals: components["schemas"]["RequestedMealItem"][]; requestedMeals: components["schemas"]["RequestedMealItem"][];
/** Purchaseditems */ /** Purchaseditems */
purchasedItems?: components["schemas"]["ListIngredientItem"][]; purchasedItems: components["schemas"]["ListIngredientItem"][];
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup?: { ingredientsLookup: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Mealslookup */ /** Mealslookup */
mealsLookup?: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Shoppinglistlookup */ /** Shoppinglistlookup */
shoppingListLookup?: { shoppingListLookup: {
[key: string]: components["schemas"]["ShoppingListOut"]; [key: string]: components["schemas"]["ShoppingListOut"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup?: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -511,6 +511,33 @@ export interface components {
/** Mealid */ /** Mealid */
mealId: number; 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 */
"MealRecipe-Input": { "MealRecipe-Input": {
/** Mealid */ /** Mealid */
@ -554,10 +581,10 @@ export interface components {
*/ */
total: number; total: number;
}; };
/** Page[Recipe] */ /** Page[RecipeOut] */
Page_Recipe_: { Page_RecipeOut_: {
/** Items */ /** Items */
items: components["schemas"]["Recipe-Output"][]; items: components["schemas"]["RecipeOut"][];
/** Nextcursor */ /** Nextcursor */
nextCursor?: string | null; nextCursor?: string | null;
/** Prevcursor */ /** Prevcursor */
@ -630,7 +657,7 @@ export interface components {
}; };
/** PurchaseListIn */ /** PurchaseListIn */
PurchaseListIn: { PurchaseListIn: {
storeName: components["schemas"]["StoreEnum"]; storeName: components["schemas"]["StoreNameOut"];
/** Items */ /** Items */
items: components["schemas"]["IngredientPurchaseItemIn"][]; items: components["schemas"]["IngredientPurchaseItemIn"][];
}; };
@ -638,15 +665,15 @@ export interface components {
PurchasedShoppingList: { PurchasedShoppingList: {
list: components["schemas"]["ShoppingListOut"]; list: components["schemas"]["ShoppingListOut"];
/** Mealslookup */ /** Mealslookup */
mealsLookup?: { mealsLookup: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup?: { ingredientsLookup: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup?: { recipesLookup: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -716,6 +743,39 @@ export interface components {
hiddenById?: number | null; hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | 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 */
RequestedMealItem: { RequestedMealItem: {
/** /**
@ -749,12 +809,16 @@ export interface components {
* Format: date-time * Format: date-time
*/ */
createdDate: string; createdDate: string;
storeName: components["schemas"]["StoreEnum"]; /**
* Storename
* @enum {string}
*/
storeName: "woolworths" | "coles" | "home";
/** Purchasedbyid */ /** Purchasedbyid */
purchasedById: number; purchasedById: number;
purchasedBy?: components["schemas"]["Person"] | null; purchasedBy?: components["schemas"]["Person"] | null;
/** Items */ /** Items */
items?: components["schemas"]["ListIngredientItem"][]; items: components["schemas"]["ListIngredientItem"][];
}; };
/** /**
* StoreEnum * StoreEnum
@ -770,6 +834,11 @@ export interface components {
/** Error Type */ /** Error Type */
type: string; type: string;
}; };
/**
* StoreNameOut
* @enum {string}
*/
StoreNameOut: "woolworths" | "coles" | "home";
}; };
responses: { responses: {
/** @description Bad Request */ /** @description Bad Request */
@ -862,7 +931,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -947,7 +1016,7 @@ export interface operations {
* "total": 1 * "total": 1
* } * }
*/ */
"application/json": components["schemas"]["Page_Recipe_"]; "application/json": components["schemas"]["Page_RecipeOut_"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@ -982,7 +1051,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1014,7 +1083,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Recipe-Output"]; "application/json": components["schemas"]["RecipeOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1112,7 +1181,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1148,7 +1217,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"];
@ -1183,7 +1252,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["Meal-Output"]; "application/json": components["schemas"]["MealOut"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1217,7 +1286,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"];
@ -1253,7 +1322,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"];

5
src/domain/commands.ts Normal file
View file

@ -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

View file

@ -1,19 +1,4 @@
import type { import type { Recipe, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput, ListIngredientItem, RequestedMealItem, ListIngredientItemWithRefs, RequestedMealItemWithRefs } from './types'
RecipeOut,
Recipe,
MealOut,
Meal,
MealRecipe,
Ingredient as DomainIngredient,
ShoppingList,
ShoppingListItem,
ShoppingListItemWithRefs,
MealInput,
ListIngredientItem,
RequestedMealItem,
ListIngredientItemWithRefs,
RequestedMealItemWithRefs,
} from './types'
import type { components } from '@/api/types' import type { components } from '@/api/types'
export function toDate(value: string | Date | null | undefined): Date | null { export function toDate(value: string | Date | null | undefined): Date | null {
@ -38,16 +23,25 @@ export function decodeLookup<TIn, TOut>(
return out 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') 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 { return {
...r, ...r,
imageUrls,
ingredients,
dateCreated: toDate(r.dateCreated), dateCreated: toDate(r.dateCreated),
dateHidden: toDate(r.dateHidden), 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') if (!m) throw new Error('Invalid meal payload')
const recipes = Array.isArray(m.recipes) const recipes = Array.isArray(m.recipes)
? m.recipes.map((mr) => decodeMealRecipe(mr)) ? m.recipes.map((mr) => decodeMealRecipe(mr))

View file

@ -3,6 +3,10 @@ import type { components } from '@/api/types'
// Utility mapped types // Utility mapped types
export type Replace<T, M> = Omit<T, keyof M> & M export type Replace<T, M> = Omit<T, keyof M> & M
export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }> export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }>
// Convert selected array keys to their non-nullable array counterparts
export type NonNullableArrays<T, K extends keyof T> = Replace<T, { [P in K]: NonNullable<T[P]> extends Array<infer U> ? U[] : T[P] }>
// Make selected keys required (non-optional) on a type
export type RequiredKeys<T, K extends keyof T> = Replace<T, { [P in K]-?: NonNullable<T[P]> }>
// Common helpers (intentionally minimal to avoid unused exports) // Common helpers (intentionally minimal to avoid unused exports)
export type Lookup<T> = Record<string, T> export type Lookup<T> = Record<string, T>
@ -10,8 +14,8 @@ export type Lookup<T> = Record<string, T>
export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined } export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
// Domain type aliases // Domain type aliases
export type RecipeOut = components['schemas']['Recipe-Output'] export type RecipeOut = components['schemas']['RecipeOut']
export type MealOut = components['schemas']['Meal-Output'] export type MealOut = components['schemas']['MealOut']
export type Ingredient = components['schemas']['Ingredient'] export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product'] export type Product = components['schemas']['Product']
export type Person = components['schemas']['Person'] export type Person = components['schemas']['Person']
@ -27,15 +31,10 @@ export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
// Meal with decoded dates and nested MealRecipe with decoded recipe dates // Meal with decoded dates and nested MealRecipe with decoded recipe dates
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec // Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
export type Meal = Replace< type MealBase = Replace<WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, { recipes: MealRecipe[] }>
WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, export type Meal = RequiredKeys<
{ NonNullableArrays<MealBase, 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'>,
recipes: MealRecipe[] 'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'
chefs: Person[]
consumers: Person[]
cleanup: Person[]
extraIngredients: Ingredient[]
}
> >
// Shopping domain shapes with dates normalized // Shopping domain shapes with dates normalized

View file

@ -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()
})
})