feat(shopping): add typed requestIngredient and migrate members to typed endpoint; fix decoders for stricter RecipeOut; tests green

This commit is contained in:
jableader 2025-11-01 18:24:35 +11:00
parent 290ad06f0c
commit 9cac6ca5ba
7 changed files with 61 additions and 17 deletions

View file

@ -1,16 +1,15 @@
import { api, fetchApi, getHouseholdSlug } from '@/api/client' import { api, getHouseholdSlug } from '@/api/client'
import type { components, paths } from '@/api/types' import type { components } from '@/api/types'
export type Member = components['schemas']['User'] export type Member = components['schemas']['HouseholdMember']
// Prefer typed endpoint if exists, fallback to raw fetch for now // Prefer typed endpoint if exists, fallback to raw fetch for now
export async function listMembers(): Promise<Member[]> { export async function listMembers(): Promise<Member[]> {
// Temporary raw GET using household slug in query until typed endpoint is available const householdSlug = getHouseholdSlug()
const slug = getHouseholdSlug() || '' if (!householdSlug) throw new Error('Missing household slug')
const path = slug ? `/api/v1/households/${encodeURIComponent(slug)}/members` : '/api/v1/households/members' const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/members', {
const resp = await fetchApi(path, { method: 'GET' }) params: { path: { householdSlug } },
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText || 'HTTP error'}`) })
const data = await resp.json().catch(() => null) if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
const arr = Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
return arr.filter((m): m is Member => typeof m === 'object' && m !== null && typeof (m as { id: unknown }).id === 'number')
} }

View file

@ -382,5 +382,17 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
export async function requestIngredient(ingredientId: number): Promise<import('@/domain/types').ListIngredientItemWithRefs> {
const householdSlug = requireSlug()
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
params: { path: { householdSlug } },
body: { ingredientId },
})
if (!response.ok) throw httpError(response, error)
const [decoded] = decodeListIngredientItems([data as any])
if (!decoded) throw new Error('Failed to decode requested ingredient item')
return decoded
}
// Re-export domain command types for convenience at SDK surface // Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands' export type { PurchaseRequest } from '@/domain/commands'

View file

@ -153,6 +153,10 @@ function createFromScratch() {
ingredients: [], ingredients: [],
imageUrls: [], imageUrls: [],
serves: 1, serves: 1,
createdById: -1,
createdBy: null,
hiddenById: null,
hiddenBy: null,
} }
} }

View file

@ -60,6 +60,7 @@ export function useShopping() {
purchaseShoppingList: sdk.purchaseShoppingList, purchaseShoppingList: sdk.purchaseShoppingList,
requestMeal: sdk.requestMeal, requestMeal: sdk.requestMeal,
unrequestMeal: sdk.unrequestMeal, unrequestMeal: sdk.unrequestMeal,
requestIngredient: sdk.requestIngredient,
getMyShoppingList: sdk.getMyShoppingList, getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList, saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers // View-model helpers

View file

@ -28,9 +28,23 @@ export function decodeRecipe(
): Recipe { ): Recipe {
if (!r) throw new Error('Invalid recipe payload') if (!r) throw new Error('Invalid recipe payload')
// Normalize arrays that may be optional // Normalize arrays that may be optional
const imageUrls = r.imageUrls ?? [] const imageUrls = (r as components['schemas']['RecipeOut']).imageUrls ?? (r as components['schemas']['Recipe']).imageUrls ?? []
const ingredients = r.ingredients ?? [] const ingredients = (r as components['schemas']['RecipeOut']).ingredients ?? (r as components['schemas']['Recipe']).ingredients ?? []
return { ...r, imageUrls, ingredients } // Normalize createdBy/hiddenBy to MemberRef shape when input is legacy Recipe (with Person)
const createdByRaw = (r as any).createdBy
const hiddenByRaw = (r as any).hiddenBy
const toMemberRef = (v: any) => {
if (!v) return v
if (typeof v === 'object' && v) {
const id = (v as any).id
const displayName = 'displayName' in v ? (v as any).displayName : ('name' in v ? (v as any).name : undefined)
return typeof id === 'number' && typeof displayName === 'string' ? { id, displayName } : v
}
return v
}
const createdBy = createdByRaw !== undefined ? toMemberRef(createdByRaw) : undefined
const hiddenBy = hiddenByRaw !== undefined ? toMemberRef(hiddenByRaw) : undefined
return { ...(r as any), imageUrls, ingredients, ...(createdBy !== undefined ? { createdBy } : {}), ...(hiddenBy !== undefined ? { hiddenBy } : {}) }
} }
export function decodeMeal( export function decodeMeal(

View file

@ -40,14 +40,14 @@
v-for="m in members" v-for="m in members"
:key="m.id" :key="m.id"
> >
{{ m.displayName }} <small>({{ m.email }})</small> {{ m.displayName }} <small> role: {{ m.role }}</small>
</li> </li>
</ul> </ul>
<p <p
v-else v-else
class="muted" class="muted"
> >
Listing members will be added once the backend endpoint is available. No members to show yet.
</p> </p>
</section> </section>
</div> </div>

View file

@ -1,6 +1,6 @@
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, requestIngredient } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client' import { setHouseholdSlugProvider } from '@/api/client'
describe('shopping api (typed client)', () => { describe('shopping api (typed client)', () => {
@ -33,4 +33,18 @@ describe('shopping api (typed client)', () => {
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)
}) })
it('requests an ingredient by id for the current list', async () => {
server.use(
http.post('*/api/v1/households/:householdSlug/shopping/current/ingredients', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ ingredientId: 123 })
return HttpResponse.json({ id: -1, ingredientId: 123, personId: 7, createdDate: new Date().toISOString(), kind: 'ingredient', listId: null, mealId: null, recipeId: null })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const item = await requestIngredient(123)
expect(item.kind).toBe('ingredient')
expect(item.ingredientId).toBe(123)
})
}) })