Files
Epicure/HANDOFF.md
T
Arnaud 36e7698096 docs: resolve recipe-quota semantics decision in HANDOFF.md
Keep maxRecipes as a creations-per-month counter; deleting a recipe does
not free up quota. No code change needed, current behavior already matches.
2026-07-10 08:25:35 +02:00

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.tsgenerateObject wrapped in withAiQuota.
  • apps/web/app/api/v1/ai/recipe-chat/route.tsgenerateText wrapped in withAiQuota (also maps provider errors).
  • apps/web/app/api/v1/ai/meal-plan/generate/route.tswithAiQuota added; recipe tier limit now charged per inserted draft recipe via checkAndIncrementTierLimit(..., "recipe") loop with refund (incrementUsage(userId, "recipe", -charged)) on TierLimitError; returns same 403 shape as recipes/route.ts POST.
  • 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 in withAiQuota. GET unchanged.

Webhooks (security + drift)

  • apps/web/lib/webhooks.ts — SSRF fix: dispatchWebhook re-runs validateWebhookUrl per 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). Exported WEBHOOK_EVENTS as const array; WebhookEvent type derived from it.
  • apps/web/app/api/v1/webhooks/route.ts + [id]/route.ts — drifted local VALID_EVENTS (4 events) removed; both use z.enum(WEBHOOK_EVENTS) (7 events). Users can now subscribe to meal_plan.updated, shopping_list.completed, comment.added.
  • Leftover: recipe.published subscribable but never dispatched. Either dispatch on private→public transition in recipes/[id]/route.ts PUT, or drop the event.

DB schema + migration (generated, NOT applied)

  • packages/db/src/schema/recipes.ts — indexes on recipe_ingredients.recipe_id, recipe_steps.recipe_id.
  • packages/db/src/schema/meal-planning.ts — indexes on meal_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.tscomments.parentId now self-FK with onDelete: "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:
    1. Dangling parents: UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments);
    2. Duplicate (user_id, week_start) meal_plans → merge/delete dupes.
    3. Duplicate (meal_plan_id, day, meal_type) entries → keep newest, delete rest.

Recipe route transactions

  • apps/web/app/api/v1/recipes/[id]/route.ts PUT — 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

  1. recipes/[id]/route.ts DELETE — 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 calls deleteObject from apps/web/lib/storage.ts (which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (check packages/db/src/schema/recipes.ts for key vs full-URL columns), deleteObject per key in try/catch — storage failure must NOT fail the API response.
  2. 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.json nor fr.json was modified — add keys or the UI breaks.
  3. 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

  1. 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 in lib/tiers.ts is dead code. Fix: require fileSize (bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and add fileSize there.
  2. 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)

  1. Auth hardening:
    • No middleware.ts exists (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 of requireSessionOrApiKey; cookie-session branch (~103) skips it. Apply to both.
    • lib/tiers.ts:41if (!tierDef) return; = unknown/custom tier gets NO limits. Deny instead (or fall back to free limits).
    • Admin role staleness: lib/api-auth.ts:19 trusts session.user.role (5-min cookieCache, auth/server.ts:78-81). Make requireAdmin query DB role.
    • app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118 — self-leave unreachable: plan lookup scoped to owner, members get 404, isSelf branch dead. Lookup must also match membership.
    • app/api/v1/conversations/[id]/messages/route.ts:26-31asc + limit(200): conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); update components/social/message-thread.tsx. Also add aria-label to 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).
  2. 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. Also components/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 + update components/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 lacks overflow-x-auto. Same for other admin tables.
    • Reuse total/limit/offset pattern from components/search/search-page-content.tsx:29-34.
  3. 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, .dark palette) but NO toggle. Add Sun/Moon in nav dropdown (next-themes setTheme).
    • 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:49 use window.confirm. Standardize on AlertDialog (pattern: delete-recipe-button.tsx:62).
  4. 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-label on 28 size="icon" buttons; meaningful alt on recipe images (recipes/[id]/page.tsx:378, can-cook-content.tsx:39, expiring-soon-suggestions.tsx:39, cooked-it-review.tsx:149,209); alt on 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.
  5. api-types/OpenAPI:
    • packages/api-types 100% 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 documented page/limit but real is limit/offset returning {data, limit, offset} (recipes/route.ts:48-64).
    • Error shape: ai-keys/route.ts:35 returns {error: <flatten object>} vs {error: string} elsewhere.
  6. 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:48recipeId unvalidated → FK violation 500 (sibling shared route validates; copy it).
    • Recipe usage counter monthly + never decremented on deletedecided: keep as-is, maxRecipes means "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:

  1. Push+email for ALL notification types (S) — createNotification writes rows; only comments route calls sendPushNotification (lib/push.ts).
  2. Personal recipe notes UI (S) — recipeNotes table exists (recipes.ts:94), zero API/UI.
  3. Cooking history page (S/M) — cookingHistory written by /cooked, never read. Profile tab: counts, last-cooked, streaks.
  4. Notifications inbox page (S) — API + bell exist.
  5. Pantry-aware shopping lists (S).
  6. Unit conversion from users.unitPref (M).
  7. Nutrition daily diary (M).
  8. Weekly digest email (M, needs cron).
  9. Recipe fork/clone via recipeVariations (S).
  10. GDPR data export (S/M). 11. Pantry barcode/photo scan (M).
  11. "Cooked it" photo gallery (M).
  12. Meal-plan generation targeting nutrition goals (M).

Gotchas (from project memory)

  • Import drizzle operators (eq, and, or, desc, sql, count…) from @epicure/db, NEVER drizzle-orm directly in apps/web (dual-instance bug).
  • shadcn Button: NO asChild prop — use buttonVariants() + <Link>.
  • session.user.tier is string — cast as "free" | "pro" at call sites.
  • requireSessionOrApiKey takes NextRequest; requireSession doesn't.
  • Better Auth changeEmail/changePassword: call on authClient object, don't destructure.
  • New user-facing strings go in BOTH apps/web/messages/en.json and fr.json.