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 }`.
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
:date="meal.suggestedDate ?? new Date()"
|
:date="meal.suggestedDate ?? new Date()"
|
||||||
@date-selected="selectDate"
|
@date-selected="selectDate"
|
||||||
/>
|
/>
|
||||||
<div class="members-list">
|
<div class="members-list">
|
||||||
Cooked by
|
Cooked by
|
||||||
<member-list
|
<member-list
|
||||||
:people="meal.chefs"
|
:people="meal.chefs"
|
||||||
|
|
|
||||||
|
|
@ -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 : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue