36e7698096
Keep maxRecipes as a creations-per-month counter; deleting a recipe does not free up quota. No code change needed, current behavior already matches.
13 KiB
13 KiB
Audit fixes handoff (2026-07-09) — for next session (Sonnet 5)
A full 4-agent audit (bugs / UI-UX / backend consistency / feature gaps) was run, then fixes were fanned out to parallel agents. Session token limit killed 3 fix agents mid-flight. This file records: what's DONE, what's PARTIAL/UNVERIFIED, what's NOT STARTED. Nothing is committed. (Prior resolved backlog lives in TODO.md — don't confuse the two.)
First step for next session: pnpm typecheck && pnpm lint, then verify the PARTIAL items below by reading git diff before doing anything else.
✅ DONE (in working tree, uncommitted, reported complete by agents)
AI tier-quota bypass (critical — money leak)
apps/web/app/api/v1/ai/recipe-ideas/route.ts—generateObjectwrapped inwithAiQuota.apps/web/app/api/v1/ai/recipe-chat/route.ts—generateTextwrapped inwithAiQuota(also maps provider errors).apps/web/app/api/v1/ai/meal-plan/generate/route.ts—withAiQuotaadded; recipe tier limit now charged per inserted draft recipe viacheckAndIncrementTierLimit(..., "recipe")loop with refund (incrementUsage(userId, "recipe", -charged)) onTierLimitError; returns same 403 shape asrecipes/route.tsPOST.apps/web/app/api/v1/recipes/[id]/nutrition/route.ts— POST now author-only (was: any authed user could burn paid AI calls on public recipes), rate-limited (rl:ai:${userId}, 10/60), wrapped inwithAiQuota. GET unchanged.
Webhooks (security + drift)
apps/web/lib/webhooks.ts— SSRF fix:dispatchWebhookre-runsvalidateWebhookUrlper delivery (DNS re-resolution, private/link-local/loopback/metadata ranges rejected);redirect: "manual"on fetch (3xx = failed delivery); validation failure recorded as delivery failure (statusCode 0). ExportedWEBHOOK_EVENTSas const array;WebhookEventtype derived from it.apps/web/app/api/v1/webhooks/route.ts+[id]/route.ts— drifted localVALID_EVENTS(4 events) removed; both usez.enum(WEBHOOK_EVENTS)(7 events). Users can now subscribe tomeal_plan.updated,shopping_list.completed,comment.added.- Leftover:
recipe.publishedsubscribable but never dispatched. Either dispatch on private→public transition inrecipes/[id]/route.tsPUT, or drop the event.
DB schema + migration (generated, NOT applied)
packages/db/src/schema/recipes.ts— indexes onrecipe_ingredients.recipe_id,recipe_steps.recipe_id.packages/db/src/schema/meal-planning.ts— indexes onmeal_plans.user_id,meal_plan_entries.meal_plan_id,pantry_items.user_id; UNIQUE(user_id, week_start)on meal_plans; UNIQUE(meal_plan_id, day, meal_type)on meal_plan_entries (verified intentional: entries route delete-then-insert + meal-planner.tsx.find()= one entry per slot).packages/db/src/schema/social.ts—comments.parentIdnow self-FK withonDelete: "cascade"(AnyPgColumn pattern).- Migration generated:
packages/db/src/migrations/0023_medical_dark_beast.sql(1 FK + 5 indexes + 2 unique indexes). - ⚠️ Before
pnpm db:migrate, clean existing data or migration fails:- Dangling parents:
UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments); - Duplicate
(user_id, week_start)meal_plans → merge/delete dupes. - Duplicate
(meal_plan_id, day, meal_type)entries → keep newest, delete rest.
- Dangling parents:
Recipe route transactions
apps/web/app/api/v1/recipes/[id]/route.tsPUT — snapshot/version-bump moved after Zod validation, inside the update transaction (invalid body no longer creates phantom snapshots).apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts— pre-restore snapshot moved inside the restore transaction.
⚠️ PARTIAL / UNVERIFIED — agent died mid-work, verify diffs first
recipes/[id]/route.tsDELETE — S3 object cleanup. Agent died MID-EDIT here ("Now bug B — DELETE with S3 cleanup"). File is modified — check whether DELETE now collects photo storage keys and callsdeleteObjectfromapps/web/lib/storage.ts(which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (checkpackages/db/src/schema/recipes.tsfor key vs full-URL columns),deleteObjectper key in try/catch — storage failure must NOT fail the API response.- Optimistic buttons — all 4 files edited, agent died before reporting. Review each diff:
apps/web/components/social/favorite-button.tsx— should be: optimistic flip, rollback + error toast (was: await-then-flip, silent fail).apps/web/components/collections/collection-star-button.tsx— same pattern.apps/web/components/social/follow-button.tsx— optimistic flip, keep existing error toast.apps/web/components/meal-plan/shopping-list-view.tsx— toggleItem: keep optimistic update, ADD rollback on!res.ok/throw with functional setState (rollback must not clobber other items' newer toggles).- If toasts reference new i18n keys: neither
apps/web/messages/en.jsonnorfr.jsonwas modified — add keys or the UI breaks.
apps/web/components/shared/skeletons.tsx— created, but ZERO loading.tsx/error.tsx/not-found.tsx files exist yet. Agent died after the shared building blocks. Verify the file, then do item 5 below.
❌ NOT STARTED — Wave 1 remainder
- Upload presign size + storage quota (
apps/web/app/api/v1/upload/presign/route.ts,apps/web/lib/storage.ts): validates content-type only, no size limit;"storage"LimitKey inlib/tiers.tsis dead code. Fix: requirefileSize(bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and addfileSizethere. - Route-level UI states (reuse
components/shared/skeletons.tsx):app/(app)/error.tsx("use client", reset() button),app/(app)/not-found.tsx,app/(app)/loading.tsx- Per-route loading.tsx mirroring real layouts: recipes, feed, collections, meal-plan, recipes/[id], u/[username], search, explore, shopping-lists, pantry
app/admin/error.tsx+app/admin/loading.tsx- Root
app/error.tsx+app/not-found.tsx(dependency-light; may render outside providers)
❌ NOT STARTED — Wave 2 (planned batches, disjoint file sets)
- Auth hardening:
- No
middleware.tsexists (CLAUDE.md claims otherwise). Add root middleware guarding(app)+admin(Better Auth session cookie). Unguarded pages today:/recipes/new,/explore,/search,/settings/webhooks/docs. lib/api-auth.ts:76— rate limit only on API-key branch ofrequireSessionOrApiKey; cookie-session branch (~103) skips it. Apply to both.lib/tiers.ts:41—if (!tierDef) return;= unknown/custom tier gets NO limits. Deny instead (or fall back to free limits).- Admin role staleness:
lib/api-auth.ts:19trustssession.user.role(5-min cookieCache,auth/server.ts:78-81). MakerequireAdminquery DB role. app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118— self-leave unreachable: plan lookup scoped to owner, members get 404,isSelfbranch dead. Lookup must also match membership.app/api/v1/conversations/[id]/messages/route.ts:26-31—asc + limit(200): conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); updatecomponents/social/message-thread.tsx. Also addaria-labelto its icon-only send button (~112).lib/rate-limit.ts:23— off-by-one: allows limit+1 requests.app/api/webhooks/stripe/route.ts— no event-ID dedup (replay within tolerance re-runs tier update).
- No
- Pagination:
app/(app)/recipes/page.tsx:47— findMany with NO limit, loads every recipe.app/(app)/feed/page.tsx:46— cap 40, no pagination; trending/for-you single batch. Alsocomponents/feed/feed-page-content.tsx:86,112—.catch(() => {})renders network failure as empty state; add error branch + retry.app/(app)/u/[username]/page.tsx:55— 24 recipes then dead end.app/api/v1/recipes/[id]/comments/route.ts:28-43— unbounded; paginate + updatecomponents/social/comments-section.tsx(also no error branch ~218; comment delete ~134 has no confirm).app/api/v1/pantry/route.ts:17-22— unbounded.app/admin/users/page.tsx:28,36— limit(100) no next-page; table lacksoverflow-x-auto. Same for other admin tables.- Reuse
total/limit/offsetpattern fromcomponents/search/search-page-content.tsx:29-34.
- Nav/theme/confirms:
components/layout/nav.tsx:110—<AvatarImage src="" alt="" />hardcoded; render session user image. Add "View profile" link to dropdown.- Dark mode wired (
components/providers.tsx:20,.darkpalette) but NO toggle. Add Sun/Moon in nav dropdown (next-themessetTheme). - Confirm consistency: comment delete (
comments-section.tsx:134) + pantry delete (pantry-manager.tsx:55) fire instantly;recipes-grid.tsx:296,block-button.tsx:21,invites-manager.tsx:49usewindow.confirm. Standardize on AlertDialog (pattern:delete-recipe-button.tsx:62).
- Forms/a11y/images/URLs:
components/recipe/recipe-form.tsx— no unsaved-changes guard; only title validated, empty ingredients/steps silently dropped (~:132,149,158).- Canonical URL split: cards →
/recipes/[id](recipe-card.tsx:42,recipes-grid.tsx:218) vs/r/[id](feed-page-content.tsx:60,u/[username]/page.tsx:151,search-page-content.tsx:50). Use/recipes/[id]in-app, keep/r/[id]for public share. - 4+ recipe-card implementations (recipe-card.tsx; GridCard/ListRow/CompactRow in recipes-grid.tsx; inline in feed-page-content.tsx:33 + search-page-content.tsx:42; tiles in u/[username]/page.tsx:149) — consolidate where cheap.
- a11y:
aria-labelon 28size="icon"buttons; meaningfulalton recipe images (recipes/[id]/page.tsx:378,can-cook-content.tsx:39,expiring-soon-suggestions.tsx:39,cooked-it-review.tsx:149,209);alton AvatarImage everywhere; color-only signalling on difficulty badges + pantry expiry (pantry-manager.tsx:96) — add icon/text. - Zero
next/image(12 raw<img>) — migrate grids/heroes, width/height set, remotePatterns for MinIO/S3 host in next.config.
- api-types/OpenAPI:
packages/api-types100% orphaned — zero imports; every route inlines Zod. Wire recipes routes to it (align drift first) or delete the package + update CLAUDE.md.apps/web/lib/openapi.ts— ~20 of 90 routes documented; recipes list documentedpage/limitbut real islimit/offsetreturning{data, limit, offset}(recipes/route.ts:48-64).- Error shape:
ai-keys/route.ts:35returns{error: <flatten object>}vs{error: string}elsewhere.
- Misc low:
app/api/v1/search/route.ts:72— escape%/_in ilike;feed/trending/route.ts:6— parseInt NaN reaches.limit().app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts:17— body cast without Zod.app/api/v1/meal-plans/[weekStart]/entries/route.ts:48—recipeIdunvalidated → FK violation 500 (sibling shared route validates; copy it).Recipe usage counter monthly + never decremented on delete— decided: keep as-is,maxRecipesmeans "creations per month," deleting a recipe does not free up quota.lib/ai/resolve-user-key.ts:18-23,44-62— BYOK decrypt failures silently fall back to platform key; surface error.- API keys: no expiry/scopes; all of a user's keys share one rate bucket (
api-auth.ts:79).
Final phase
- Clean dev-DB dupes (see migration warning) →
pnpm db:migrate. pnpm typecheck && pnpm lint && pnpm build, fix fallout.- Smoke: favorite/star/follow, shopping toggle rollback (network off in devtools), AI 403 at quota, webhook to private IP rejected, recipe delete removes MinIO objects.
New features backlog (NOT fixes — not yet greenlit)
"S" items mostly wire existing orphaned infra:
- Push+email for ALL notification types (S) —
createNotificationwrites rows; only comments route callssendPushNotification(lib/push.ts). - Personal recipe notes UI (S) —
recipeNotestable exists (recipes.ts:94), zero API/UI. - Cooking history page (S/M) —
cookingHistorywritten by/cooked, never read. Profile tab: counts, last-cooked, streaks. - Notifications inbox page (S) — API + bell exist.
- Pantry-aware shopping lists (S).
- Unit conversion from
users.unitPref(M). - Nutrition daily diary (M).
- Weekly digest email (M, needs cron).
- Recipe fork/clone via
recipeVariations(S). - GDPR data export (S/M). 11. Pantry barcode/photo scan (M).
- "Cooked it" photo gallery (M).
- Meal-plan generation targeting nutrition goals (M).
Gotchas (from project memory)
- Import drizzle operators (
eq, and, or, desc, sql, count…) from@epicure/db, NEVERdrizzle-ormdirectly in apps/web (dual-instance bug). - shadcn Button: NO
asChildprop — usebuttonVariants()+<Link>. session.user.tierisstring— castas "free" | "pro"at call sites.requireSessionOrApiKeytakesNextRequest;requireSessiondoesn't.- Better Auth
changeEmail/changePassword: call onauthClientobject, don't destructure. - New user-facing strings go in BOTH
apps/web/messages/en.jsonandfr.json.