# 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` — `generateObject` wrapped in `withAiQuota`.
- `apps/web/app/api/v1/ai/recipe-chat/route.ts` — `generateText` wrapped in `withAiQuota` (also maps provider errors).
- `apps/web/app/api/v1/ai/meal-plan/generate/route.ts` — `withAiQuota` 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.ts` — `comments.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

4. **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.
5. **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)

6. **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: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: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-31` — `asc + 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).
7. **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`.
8. **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`).
9. **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.
10. **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.
11. **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` — `recipeId` unvalidated → FK violation 500 (sibling shared route validates; copy it).
    - ~~Recipe usage counter monthly + never decremented on delete~~ — **decided**: 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).
12. "Cooked it" photo gallery (M).
13. 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`.
