From d7c9788f00183177927d6e4250f8cb414438a5df Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 21:41:38 +1100 Subject: [PATCH] feat: harden decoders/SDK, add Google button, and align Member styles --- frontend-spec.md | 6 +++ src/api/sdk.ts | 73 ++++++++++++++++++++++++--- src/components/meals/EditMealPage.vue | 2 +- src/domain/decoders.ts | 32 ++++-------- 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/frontend-spec.md b/frontend-spec.md index fea20e1..bc728e6 100644 --- a/frontend-spec.md +++ b/frontend-spec.md @@ -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 diff --git a/src/api/sdk.ts b/src/api/sdk.ts index 32aa04b..7139a0d 100644 --- a/src/api/sdk.ts +++ b/src/api/sdk.ts @@ -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 { + return v !== null && typeof v === 'object' +} + +function isRecipeLike(v: unknown): v is Partial & { 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> { const query: Record = {} if (params) { @@ -202,16 +250,27 @@ export async function deleteRecipe(id: number | string): Promise { export async function parseRecipe(url: string): Promise { 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 { 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 -
+
Cooked by { - 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 : [], } }