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 }`.
- 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

View file

@ -155,6 +155,54 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
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>> {
const query: Record<string, unknown> = {}
if (params) {
@ -202,16 +250,27 @@ export async function deleteRecipe(id: number | string): Promise<void> {
export async function parseRecipe(url: string): Promise<Recipe | null> {
const res = await fetchApi('/api/v1/recipes/parse?url=' + encodeURIComponent(url), { method: 'GET' })
if (!res.ok) throw httpError(res, null)
const data = await res.json()
return decodeRecipe(data)
const data: unknown = await res.json()
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[]> {
const url = '/api/v1/recipes/ingredients/parse?' + new URLSearchParams(lines.map((v) => ['ingredients', v]))
const res = await fetchApi(url, { method: 'GET' })
if (!res.ok) throw httpError(res, null)
const data = await res.json()
return decodeIngredients(Array.isArray(data) ? data : [])
const data: unknown = await res.json()
if (!isIngredientsArray(data)) throw new Error('Invalid ingredient list payload')
return decodeIngredients(data)
}
export async function parseProduct(
@ -221,7 +280,8 @@ export async function parseProduct(
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' } })
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.
@ -367,7 +427,8 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
body: { ingredientId },
})
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')
return decoded
}

View file

@ -5,7 +5,7 @@
:date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate"
/>
<div class="members-list">
<div class="members-list">
Cooked by
<member-list
:people="meal.chefs"

View file

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