feat: harden decoders/SDK, add Google button, and align Member styles
This commit is contained in:
parent
d08e0370ac
commit
d7c9788f00
4 changed files with 84 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue