feat: harden decoders/SDK, add Google button, and align Member styles

This commit is contained in:
jableader 2025-11-01 21:41:38 +11:00
parent d08e0370ac
commit d7c9788f00
4 changed files with 84 additions and 29 deletions

View file

@ -173,6 +173,12 @@ Progress Log (Nov 1, 2025)
- Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`. - Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`.
- Completed migration to typed path parameters; header injection removed; only small raw fetch helpers remain for endpoints not yet in OpenAPI (parse only). - Completed migration to typed path parameters; header injection removed; only small raw fetch helpers remain for endpoints not yet in OpenAPI (parse only).
Refinements (Nov 1, 2025, later):
- Lint hardening: removed remaining `as any` and unsafe assertions across SDK/decoders.
- decodeRecipe/decodeMeal simplified to use concrete OpenAPI shapes and defaults; legacy Person normalization removed.
- Raw parse endpoints now use small runtime guards and normalize to strict RecipeOut before decoding.
- UI polish: MemberList and EditMealPage CSS class names unified (person-* → member-*). Login page shows a Google sign-in button wired to the placeholder handler.
--- ---
## Detailed Tasks by File/Module ## Detailed Tasks by File/Module

View file

@ -155,6 +155,54 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
return dtoOut return dtoOut
} }
// Local runtime guards for raw JSON endpoints
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object'
}
function isRecipeLike(v: unknown): v is Partial<components['schemas']['Recipe']> & { id: number; name: string } {
if (!isObject(v)) return false
return (
typeof v.id === 'number' &&
typeof v.name === 'string' &&
(v.link === undefined || typeof v.link === 'string') &&
(v.serves === undefined || typeof v.serves === 'number') &&
(v.imageUrls === undefined || Array.isArray(v.imageUrls)) &&
(v.ingredients === undefined || Array.isArray(v.ingredients))
)
}
function isIngredient(v: unknown): v is components['schemas']['Ingredient'] {
if (!isObject(v)) return false
return (
typeof v.id === 'number' &&
typeof v.name === 'string' &&
typeof v.line === 'string' &&
typeof v.unit === 'string' &&
typeof v.quantity === 'number'
)
}
function isIngredientsArray(v: unknown): v is components['schemas']['Ingredient'][] {
return Array.isArray(v) && v.every(isIngredient)
}
function isProduct(v: unknown): v is components['schemas']['Product'] {
if (!isObject(v)) return false
return typeof v.id === 'number' && typeof v.name === 'string'
}
function isListIngredientItem(v: unknown): v is components['schemas']['ListIngredientItem'] {
if (!isObject(v)) return false
return (
v.kind === 'ingredient' &&
typeof v.id === 'number' &&
typeof v.ingredientId === 'number' &&
typeof v.personId === 'number' &&
typeof v.createdDate === 'string'
)
}
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> { export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
const query: Record<string, unknown> = {} const query: Record<string, unknown> = {}
if (params) { if (params) {
@ -202,16 +250,27 @@ export async function deleteRecipe(id: number | string): Promise<void> {
export async function parseRecipe(url: string): Promise<Recipe | null> { export async function parseRecipe(url: string): Promise<Recipe | null> {
const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' }) const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' })
if (!res.ok) throw httpError(res, null) if (!res.ok) throw httpError(res, null)
const data = await res.json() const data: unknown = await res.json()
return decodeRecipe(data) if (!isRecipeLike(data)) throw new Error('Invalid recipe payload')
const normalized: components['schemas']['RecipeOut'] = {
id: data.id,
name: data.name,
link: typeof data.link === 'string' ? data.link : '',
serves: typeof data.serves === 'number' ? data.serves : 1,
imageUrls: Array.isArray(data.imageUrls) ? data.imageUrls as string[] : [],
ingredients: Array.isArray(data.ingredients) ? (data.ingredients as components['schemas']['Ingredient'][]) : [],
createdById: typeof (data as any).createdById === 'number' ? (data as any).createdById : -1,
}
return decodeRecipe(normalized)
} }
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> { export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v])) const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v]))
const res = await fetchApi(url, { method: 'GET' }) const res = await fetchApi(url, { method: 'GET' })
if (!res.ok) throw httpError(res, null) if (!res.ok) throw httpError(res, null)
const data = await res.json() const data: unknown = await res.json()
return decodeIngredients(Array.isArray(data) ? data : []) if (!isIngredientsArray(data)) throw new Error('Invalid ingredient list payload')
return decodeIngredients(data)
} }
export async function parseProduct( export async function parseProduct(
@ -221,7 +280,8 @@ export async function parseProduct(
const body = { url, tags: [ingredient.name, ingredient.line] } const body = { url, tags: [ingredient.name, ingredient.line] }
const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }) const res = await fetchApi('/api/v1/products', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } })
if (!res.ok) throw httpError(res, null) if (!res.ok) throw httpError(res, null)
return (await res.json()) ?? null const data: unknown = await res.json()
return isProduct(data) ? data : null
} }
// Person-related functions are removed as the entity is no longer in use. // Person-related functions are removed as the entity is no longer in use.
@ -367,7 +427,8 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
body: { ingredientId }, body: { ingredientId },
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
const [decoded] = decodeListIngredientItems([data as any]) if (!isListIngredientItem(data)) throw new Error('Failed to decode requested ingredient item')
const [decoded] = decodeListIngredientItems([data])
if (!decoded) throw new Error('Failed to decode requested ingredient item') if (!decoded) throw new Error('Failed to decode requested ingredient item')
return decoded return decoded
} }

View file

@ -27,24 +27,12 @@ export function decodeRecipe(
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe'] | null | undefined r: components['schemas']['RecipeOut'] | components['schemas']['Recipe'] | null | undefined
): Recipe { ): Recipe {
if (!r) throw new Error('Invalid recipe payload') if (!r) throw new Error('Invalid recipe payload')
// Normalize arrays that may be optional const imageUrls = Array.isArray(r.imageUrls) ? r.imageUrls : []
const imageUrls = (r as components['schemas']['RecipeOut']).imageUrls ?? (r as components['schemas']['Recipe']).imageUrls ?? [] const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
const ingredients = (r as components['schemas']['RecipeOut']).ingredients ?? (r as components['schemas']['Recipe']).ingredients ?? [] const link = typeof r.link === 'string' ? r.link : ''
// Normalize createdBy/hiddenBy to MemberRef shape when input is legacy Recipe (with Person) const serves = typeof r.serves === 'number' ? r.serves : 1
const createdByRaw = (r as any).createdBy const createdById = typeof (r as any).createdById === 'number' ? (r as any).createdById : -1
const hiddenByRaw = (r as any).hiddenBy return { ...r, link, serves, createdById, imageUrls, ingredients }
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(
@ -61,10 +49,10 @@ export function decodeMeal(
purchaseDate: toDate(m.purchaseDate), purchaseDate: toDate(m.purchaseDate),
consumedDate: toDate(m.consumedDate), consumedDate: toDate(m.consumedDate),
recipes, recipes,
chefs: (m.chefs ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })), chefs: Array.isArray(m.chefs) ? m.chefs : [],
consumers: (m.consumers ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })), consumers: Array.isArray(m.consumers) ? m.consumers : [],
cleanup: (m.cleanup ?? []).map((p: any) => ({ id: p.id, displayName: 'displayName' in p ? p.displayName : (p.name ?? '') })), cleanup: Array.isArray(m.cleanup) ? m.cleanup : [],
extraIngredients: m.extraIngredients ?? [], extraIngredients: Array.isArray(m.extraIngredients) ? m.extraIngredients : [],
} }
} }