# Epicure Feature Audit

Code-verified inventory of what exists in the app today, by domain. Written 2026-07-21, re-audited in full 2026-07-24 (5 parallel domain sweeps re-reading actual code against the prior version, not trusting it). **Keep this file updated whenever a feature is added or changes what it does** (see standing rule below).

Status legend: **Exists** (fully working) · **Partial** (works but with a real limitation, noted) · **Missing** (confirmed absent by grep/read, not just "didn't find it").

---

## 1. Recipes & AI

**AI stack**: Vercel AI SDK, provider-agnostic (`apps/web/lib/ai/factory.ts`) — OpenAI, Anthropic, OpenRouter, Ollama (local). Key resolution order: user's own BYOK key → admin default per use-case → admin's raw provider key → env var. Structured outputs always `generateObject` + Zod; chat features use `generateText` with optional tool-calling.

**Model Prefs gating (closed 2026-07-24):** `Settings → AI`'s Model Prefs section previously had zero gate — every user could pick a raw provider + model ID with no fencing. Now behind `isByokEnabled` (same gate as BYOK itself, since picking a specific provider/model only makes sense once you have your own key to route it to) — hidden entirely for non-BYOK users, not just locked-and-teased. `GET`/`PUT /api/v1/users/me/model-prefs` gated server-side via `requireByok()` too.

**Marketing/vitrine locale (closed 2026-07-24):** `getMarketingLocale()` (`apps/web/lib/marketing-locale.ts`) previously hard-defaulted anonymous visitors to English until they clicked the language switcher, even though the switcher's `marketing_locale` cookie mechanism and a fully French-translated marketing site both already existed. Now falls back to parsing the `Accept-Language` header (first supported tag in the browser's stated preference order) when no cookie is set; an explicit switcher choice still wins on every later visit, and logged-in visitors still get their saved `users.locale` first, unchanged.

| Feature | Status | Description | Key files |
|---|---|---|---|
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" as a click-to-open popover (list stays out of the page flow instead of a potentially long inline row) — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx`, `apps/web/components/recipe/forked-by-popover.tsx` |
| Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` |
| **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` |
| Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` |
| Generate from idea/title | Exists | Text-only generation from just a title | `apps/web/app/api/v1/ai/generate-from-idea` |
| Recipe ideas (browsing) | Exists | 6 idea cards from an optional prompt, not persisted | `apps/web/app/api/v1/ai/recipe-ideas` |
| Adapt (exclude ingredients / constraints) | Exists | Persists as new recipe + variation link, unless AI reports unchanged | `apps/web/app/api/v1/ai/adapt/[id]` |
| Suggest variations | Exists | Ideas only, not persisted; gated by tier feature flag `recipe_variations` | `apps/web/app/api/v1/ai/variations/[id]` |
| Meal / drink pairings | Exists | Gated by tier feature flags `meal_pairing` / `drink_pairing` | `apps/web/app/api/v1/ai/{pairings,drinks}/[id]` |
| Translate recipe | Exists | Saves as a new private recipe, keeps original quantities/units/timers | `apps/web/app/api/v1/ai/translate/[id]` |
| Batch-cook generator | Exists | One recipe, multiple dishes, per-dish fridge/freezer guidance | `apps/web/app/api/v1/ai/batch-cook/generate` |
| Nutrition estimation | Exists | Hybrid AI+USDA (see section 2's nutrition row), cached on the recipe row (`nutritionData`), author-triggered. A separate `nutritionManual` flag lets the author hand-enter facts at create/edit time instead, skipping estimation entirely — undocumented until this pass. | `apps/web/app/api/v1/recipes/[id]/nutrition`, `packages/db/src/schema/recipes.ts` (`nutritionManual`) |
| Recipe notes | Exists | Private, per-user free-text note attached to any recipe (not shared with other viewers) | `apps/web/app/api/v1/recipes/[id]/notes` |
| AI conversation management | Exists | List/create/rename/delete threads for recipe-chat/cooking-chat, plus a separate full-text search + bulk-delete over past chat messages — a full thread-management layer around the chat feature, not just the chat itself | `apps/web/app/api/v1/ai/conversations/**`, `apps/web/app/api/v1/ai/chat-history` |
| Draft-recipe AI helpers | Exists | Two additional, non-persisting endpoints: `generate` (freeform prompt → recipe, used by the "describe" tab in the AI-generate dialog) and `regenerate` (revise an in-progress unsaved draft with a free-text instruction) — distinct from the persisting `generate-from-idea` | `apps/web/app/api/v1/ai/{generate,regenerate}` |
| Scale servings | Exists | Model-driven, not naive linear math (handles "1 egg" etc.) | `apps/web/app/api/v1/ai/scale` |
| Substitute ingredient | Exists | | `apps/web/app/api/v1/ai/substitute` |
| Recipe chat / cooking chat | Exists | Recipe-grounded Q&A, and a general assistant with tool-calling (`createRecipe`, `addToShoppingList`) | `apps/web/app/api/v1/ai/{recipe-chat,cooking-chat}` |
| Themed meal generator | Exists | Multi-course meal into a collection | `apps/web/app/api/v1/ai/generate-meal` |
| Recipe media: photos | Exists | S3-compatible storage, counts against tier storage limit | `packages/db/src/schema/recipes.ts` (`recipePhotos`) |
| **Recipe video** | **Missing** | No video field/upload path anywhere in schema or components | — |
| Collections | Exists | Shared/collaborative (`collection_members`, viewer/editor roles), fork-a-collection, explore page, reorder | `apps/web/app/api/v1/collections/**` |
| Tags, difficulty, dietary labels | Exists | Dietary schema has 7 tags; search only exposes 4 of them as filters (`vegan/vegetarian/glutenFree/dairyFree`) | `packages/db/src/schema/recipes.ts` |
| Visibility (private/unlisted/public/followers) | Exists | `/r/[id]`'s followers-only gap (previously flagged here) is closed — it now explicitly checks `viewerId === authorId \|\| isFollowing(...)` before allowing a `followers`-only recipe through. **New inconsistency found this pass:** a followers-only recipe shows up in a follower's following-feed, but never on the author's own profile grid (`u/[username]/page.tsx` only queries `visibility = "public"` for the grid) — see "Real gaps" below. | `apps/web/app/r/[id]/page.tsx`, `apps/web/app/(app)/u/[username]/page.tsx` |
| Print views | Exists | Recipe, collection, meal plan, shopping list, pantry all have print routes | `apps/web/app/print/**` |
| Markdown export | Exists | Single recipe, bulk (up to 100), meal plan | `apps/web/lib/markdown/**` |
| Cooking mode | Exists | Step timers (multiple parallel), **voice control** (Web Speech API, EN/FR commands), wake-lock, batch-cook step grouping | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Search | Exists | Title/description/ingredient/tag substring match + filters (difficulty, type, max time, dietary, exact tags) | `apps/web/app/api/v1/search` |
| "What can I cook" (pantry-matched) | Exists | Deterministic ingredient-overlap scorer against the user's own recipes, not AI | `apps/web/lib/pantry-match.ts`, `/recipes/can-cook` |
| Explore / trending | Exists | Purely favorite-count-driven (7-day window) + recency — no editorial curation | `apps/web/app/(app)/explore` |

---

## 2. Meal planning, pantry, shopping, nutrition

| Feature | Status | Description | Key files |
|---|---|---|---|
| Weekly calendar meal plan | Exists | Mon–Sun grid, manual CRUD; the "bulk" entries endpoint is bulk-*clear* only (a day or the whole week) — there's no bulk-create, entries are always added one at a time via `POST /entries` | `apps/web/app/(app)/meal-plan`, `apps/web/app/api/v1/meal-plans/[weekStart]/**` |
| AI weekly meal-plan generator | Exists | Pantry-aware, dietary-aware, nudged by nutrition goals; one real recipe per entry; entries can also link to a specific `batchDish` (reflected in ICS export and the calendar page) | `apps/web/app/api/v1/ai/meal-plan/generate` |
| Meal plan sharing | Exists | Two separate paths: (1) authenticated member collaboration — `meal_plan_members` viewer/editor roles, day-to-day editing UI at `/meal-plan/shared/[mealPlanId]` (this is where collaborators actually work, not the public link); (2) anonymous read-only public link (`isPublic` flag, view-only — no public-editable mode) at `/m/[id]`, with a client-generated QR code. Entry changes fire a `meal_plan.updated` webhook event. | `apps/web/lib/meal-plan-access.ts`, `apps/web/app/(app)/meal-plan/shared/[mealPlanId]/page.tsx`, `apps/web/app/m/[id]/page.tsx` |
| **Calendar export (.ics)** | **Exists** | Real ICS builder with per-meal-type wall-clock times | `apps/web/app/api/v1/meal-plans/[weekStart]/export/ics` |
| Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` |
| Meal plan print + Markdown export | Exists | Undocumented until this pass despite section 1 mentioning print/markdown generally | `/print/meal-plan`, `apps/web/lib/markdown/meal-plan.ts` |
| Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
| "Page not found" after deleting (closed 2026-07-24) | Fixed | Deleting a shopping list, recipe, or collection from its own detail page navigated away with `router.push`, leaving the just-deleted URL in browser history — hitting the back button landed straight on a real 404 (`(app)/not-found.tsx`, literally "Page not found"). Switched all three to `router.replace` so the dead URL is never in history to go back to. | `apps/web/components/shopping-lists/shopping-list-actions-menu.tsx`, `apps/web/components/recipe/delete-recipe-button.tsx`, `apps/web/components/collections/delete-collection-dialog.tsx` |
| Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` |
| Add to list from a single recipe | Exists | Separate path from meal-plan-based generation; also reachable via the cooking-chat AI tool ("add to shopping list") | `apps/web/components/recipe/add-to-shopping-list-button.tsx`, `apps/web/lib/ai/tools/add-to-shopping-list-tool.ts` |
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link that can grant full anonymous edit rights (Google-Docs-style), not just read — `isPublic` + `publicEditable`; disabling `isPublic` force-revokes `publicEditable`. Also has its own print view with a QR code, and a public page distinct from the meal-plan one. | `apps/web/lib/shopping-list-access.ts`, `apps/web/app/s/[id]/page.tsx`, `/print/shopping-list/[id]` |
| List collaboration | Partial, upgraded | Still short-polling (~4s interval) for state sync, but item-add and item-checked events now also fire real Web Push notifications (VAPID) as a supplement — not purely polling anymore. Uncheck/rename/reorder still don't push. | `apps/web/components/meal-plan/shopping-list-view.tsx`, `apps/web/lib/shopping-list-notify.ts` |
| Move checked items to pantry | Exists | Undocumented until this pass — button on the shopping-list view moves checked-off items into pantry stock | `shopping-list-view.tsx` → `/api/v1/pantry/bulk` |
| Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize, plus inline custom-category creation and full drag-and-drop reordering (richer UI than previously documented) | `apps/web/lib/grocery-categories.ts` |
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists (extended 2026-07-24, twice) | Full edit dialog added (previously add+delete only, despite the API already supporting `PUT`) — name/quantity/unit/expiry plus **notes** (free text) and **category** (same `GROCERY_CATEGORIES` slugs shopping lists use; edit dialog's category dropdown initially displayed the raw slug/`__other__` instead of the translated label — fixed by passing a value→label render function to `SelectValue`, same pattern already used in `shopping-list-view.tsx`'s sort dropdown). List always renders all 9 category sections (8 `GROCERY_CATEGORIES` + Other) as collapsible groups, even empty ones — not just categories that currently have items — and items can be dragged between sections (dnd-kit `useDraggable`/`useDroppable`, cross-category drop only; no within-category manual reorder since pantry items have no `sortOrder` column to persist one). An "Auto-categorize" action (same `guessAisle` heuristic as the shopping list) fills in categories for uncategorized items in one click. Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow). | `apps/web/app/api/v1/pantry/**`, `apps/web/components/meal-plan/pantry-manager.tsx`, `apps/web/components/pantry/pantry-item-dialog.tsx` |
| Ingredient alias matching (new 2026-07-24, expanded same day) | Exists | The `ingredients` table (canonical name + `aliases[]`) existed but was never populated or queried anywhere. Seeded with ~137 bilingual EN/FR entries across all 8 grocery categories (produce, dairy/eggs, meat/seafood, bakery, frozen, pantry, spices/condiments, beverages — `packages/db/src/seed.ts`) and wired into every place that compares ingredient names by raw text: pantry add/edit (sets `ingredientId` when a name/alias matches), the can-cook / "use it up soon" scorer, auto-deduct-on-cook, and shopping-list pantry-awareness on generation. Comparison is case- **and accent-insensitive** (`café`/`Café`/`cafe` all equal — NFD-normalize + strip combining marks) in both the alias resolver and the older plain-name fallback matcher (`pantry-shopping-match.ts`'s `normalizeName`, used for the reliable-ingredientId-or-name-fallback path). Resolution happens at compare-time from free text (`resolveIngredientKey`), not from a stored FK on both sides — recipe ingredients still don't carry `ingredientId`. Growing the list: Admin → Ingredients (UI) or editing `packages/db/src/seed.ts` + `pnpm db:seed` (idempotent). | `apps/web/lib/ingredient-match.ts`, `packages/db/src/seed.ts` |
| Admin: Ingredients CRUD (new 2026-07-24, UI polished same day) | Exists | Admin-only page to manage canonical ingredients + aliases without a code change/redeploy — previously the seed file was the only place to add them. With ~137 seeded entries a flat unfiltered list stopped being usable, so it also got: a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases shown as chips instead of a truncated comma string, and the category field is a `GROCERY_CATEGORIES` `Select` (was free-text, so a typo'd category silently created an unmatched-anywhere bucket) with aliases entered via a tag input (type + Enter, mirrors `edit-collection-dialog.tsx`'s tag UX) instead of one error-prone comma-separated field. Create/edit/delete; name is unique (409 on collision); deleting an ingredient just unlinks any pantry item/recipe ingredient that pointed at it (`ingredientId` is `onDelete: "set null"` on both), nothing else is deleted. | `apps/web/app/admin/ingredients/page.tsx`, `apps/web/app/api/v1/admin/ingredients/**`, `apps/web/components/admin/ingredients-manager.tsx` |
| Merge duplicate pantry items (new 2026-07-24, relaxed same day) | Exists | One-shot cleanup for pre-existing pantry rows that are the same ingredient under different names (added before alias matching existed) — groups by resolved ingredient key **alone** (unit is deliberately not part of the grouping key, so two rows merge even with mismatched, missing, or differently-unit'd quantities); sums quantities only when every row in a group has a parseable quantity **and** the same unit, otherwise keeps the first known (quantity, unit) pair rather than guessing. Concatenates notes, keeps the soonest expiry. Manual trigger (a button in the pantry toolbar), not automatic — manual single-item add still always inserts a new row rather than silently merging, since two batches of the same ingredient can have different expiry dates worth tracking separately. | `apps/web/app/api/v1/pantry/merge-duplicates/route.ts` |
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. Matching now goes through the ingredient-alias resolver, not a raw name string compare. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
| Cook log edit/delete (new 2026-07-24) | Exists | Plain (non-batch) cook-log entries can now be listed, edited (date/servings/notes), and removed — previously log-once, no way to fix a mistake or remove a duplicate entry. The recipe page's "Cooked N times" text is a hover tooltip (up to 8 dates, "+N more" beyond) that also opens a full manage sheet on click. Editing/deleting never touches pantry quantities — a deduction from when the entry was created isn't reversed or reapplied. | `apps/web/app/api/v1/recipes/[id]/cooked/[logId]/route.ts`, `apps/web/components/recipe/{mark-cooked-section,edit-cook-log-dialog}.tsx` |
| Quantity display precision (closed 2026-07-24) | Exists | Pantry and shopping-list quantities are stored as `decimal(10,4)` and were displayed as the raw string (e.g. "0.3333 kg", "2.0000 kg") everywhere: in-app lists, print views, and Markdown exports. All 6 spots now go through `formatQuantity` (fraction-aware rounding, already used for recipe ingredient scaling) instead of the raw column value. | `apps/web/lib/fractions.ts`, `apps/web/components/meal-plan/{pantry-manager,shopping-list-view}.tsx`, `apps/web/app/print/{pantry,shopping-list/[id]}/page.tsx`, `apps/web/lib/markdown/{pantry,shopping-list}.ts` |
| Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) |
| Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` |
| Post-signup onboarding wizard | Exists (new, 2026-07-24) | 3-step wizard (welcome → dietary/allergen prefs → notification opt-in) shown once per account, right after the app shell layout loads for a user with no `onboardingCompletedAt`. Skippable at every step; existing accounts were backfilled as already-onboarded so the wizard only appears for new signups. Also closes a real pre-existing gap: `userAllergens` had a table and a GDPR-export read, but **no write path at all** — this ships the first one. Dietary tags are a new `users.dietaryTags` jsonb column (mirrors `recipes.dietaryTags`'s 7-tag shape); neither is yet consumed by AI generation or recipe matching. | `apps/web/app/onboarding/page.tsx`, `apps/web/components/onboarding/onboarding-wizard.tsx`, `apps/web/app/api/v1/users/me/onboarding/route.ts`, `apps/web/app/(app)/layout.tsx` (redirect gate) |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
| Nutrition goals | Exists | | `apps/web/app/api/v1/users/me/nutrition-goals` |
| Nutrition diary | Exists | Single-day diary view, plus a Trend tab (7/30/90-day calorie line chart + daily macro averages) — the same endpoint switches modes via a `range` query param | `apps/web/app/api/v1/users/me/nutrition-diary`, `apps/web/components/nutrition/nutrition-trend.tsx` |
| USDA nutrition-facts database | Exists | `estimateNutrition` now looks up each ingredient in USDA FoodData Central (SR Legacy + Survey (FNDDS) datasets) after converting its quantity/unit to grams, sums the matches, and only falls back to AI estimation for ingredients that couldn't be converted (count-based units like "2 cloves") or had no USDA match — including every ingredient, transparently, when `USDA_API_KEY` isn't configured. Barcode scan (pantry) still only IDs product names via Open Food Facts, unrelated to this. | `apps/web/lib/usda.ts`, `apps/web/lib/ingredient-grams.ts`, `apps/web/lib/ai/features/estimate-nutrition.ts` |
| Batch cooking | Exists | Shared prep steps grouped by which dish they serve, per-dish fridge/freezer countdown independent of the whole-batch pantry deduction | `apps/web/components/recipe/batch-cook-*.tsx` |

---

## 3. Social, messaging, notifications

| Feature | Status | Description | Key files |
|---|---|---|---|
| Follow / unfollow | Exists | One-way, no approval step | `apps/web/app/api/v1/users/[username]/follow` |
| Block | Exists | Blocks messaging both ways. Comment-hiding is one-directional only — hides comments *from* someone you've blocked, but not your own comments from someone who blocked *you* (not necessarily wrong, just undocumented directionality). | `apps/web/lib/blocks.ts` |
| Private accounts | Exists | Hides recipe grid from non-followers; excluded from "for you" unless already followed | `apps/web/app/(app)/u/[username]/page.tsx` |
| Profile page | Exists | Cooking-history streak stats, "cooked it" photo gallery (owner-visible) | `apps/web/app/(app)/u/[username]` |
| Ratings/reviews, comments (1-level threaded), reactions, @mentions | Exists | | `packages/db/src/schema/social.ts` |
| Favorites | Exists | Drives "for you" ranking and trending | `apps/web/app/api/v1/recipes/[id]/favorite` |
| Direct messaging | Exists, 1:1 by design | No group messaging (deliberate — unique-pair constraint at schema level) | `packages/db/src/schema/messaging.ts` |
| Read receipts | Partial | Thread-level `lastReadAt` only — no per-message delivery/seen state | `packages/db/src/schema/messaging.ts` |
| Following feed | Exists | | `apps/web/app/api/v1/feed` |
| "For You" personalized feed | Exists | Tag/dietary preference profile from favorites + high ratings | `apps/web/lib/for-you-ranking.ts` |
| Trending feed / explore | Exists | Favorite-count driven, no editorial curation, **no separate "featured" surface**. Trending/explore/for-you all additionally exclude private-account authors even when the specific recipe is `public` — a non-obvious interaction worth knowing about when debugging "why isn't my public recipe showing up." | `apps/web/app/api/v1/feed/trending` |
| Notification categories | Exists | 8 categories, independent push + email toggle each, plus email-only weekly digest | `packages/db/src/schema/users.ts` (`userNotificationPrefs`) |
| Push notifications | Exists | Web Push + VAPID, end-to-end, real trigger call sites (not just plumbing) | `apps/web/lib/push.ts` |
| Push display + click-through | Exists | `sw.js` previously had no `push` listener at all — push messages never displayed. Now has both `push` (calls `showNotification`) and `notificationclick` (focuses an existing tab on the payload's `url`, or opens one) | `apps/web/public/sw.js` |
| Email notifications + weekly digest cron | Exists | | `apps/web/lib/notifications.ts`, `apps/web/app/api/internal/cron/weekly-digest` |
| Reports/moderation | Exists | Report → admin webhook → admin queue → resolve (reviewed/dismissed) + audit log. Supports three target types — recipe, comment, **and user** (reporting a person directly, not previously called out) | `apps/web/app/api/v1/reports`, `apps/web/app/api/v1/admin/reports` |
| Moderator role | Exists | Moderators get `/admin/reports` (view/resolve) and `/admin/recipes` (view + unpublish takedown); every other `/admin/*` page redirects them out via `requireFullAdminPage()`. Comment deletion already allowed moderator before this. | `apps/web/lib/require-admin-page.ts`, `apps/web/app/admin/layout.tsx`, `apps/web/app/api/v1/admin/recipes/[id]/route.ts` |

---

## 4. Admin, billing, ops, integrations

| Feature | Status | Description | Key files |
|---|---|---|---|
| Tiers (free/pro/family) + limits | Exists | Recipe count, AI calls/month, storage, public-recipe count; TOCTOU-safe via advisory lock + transaction | `apps/web/lib/tiers.ts` |
| Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` |
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 10 capabilities by tier: recipe_variations/drink_pairing/meal_pairing/version_history (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 10 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, recipe version history, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). `UpgradeDialog` itself shows a per-feature tagline + bullet list (`FEATURE_COPY` in the dialog component) instead of one generic sentence for every feature. | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/{pro-badge,upgrade-dialog}.tsx` |
| Upgrade-interest tracking (new 2026-07-24) | Exists | `UpgradeDialog`'s CTA ("See Pro plans") now reroutes to `Settings → Billing?upgrade=<key>` (which shows a feature-specific banner) instead of a generic support-ticket prefill, and fires a best-effort `POST /api/v1/users/me/upgrade-interest` beacon (`keepalive: true`, survives the immediate navigation) recording which feature triggered it. Admin → Insights has a new "Most-requested locked features" chart, grouped by feature key, last 30 days. | `packages/db/src/schema/feature-flags.ts` (`featureUpgradeClicks`), `apps/web/app/api/v1/users/me/upgrade-interest/route.ts`, `apps/web/app/admin/insights/page.tsx` |
| AI-generate dialog's Photo tab bypassed the photo-import gate (closed 2026-07-24) | Exists | A second, unguarded front door into `recipe_import_photo` — the tab in `AiGenerateDialog` had no tier check at all, while the dedicated `PhotoImportButton` on `/recipes/new` did. Server-side `requireFeatureEnabledResponse` on the API route meant a fully-disabled feature still 403'd, but a viewer without the tier could reach the tab, fill it in, and hit a dead-end error instead of an upgrade prompt. Now the tab hides/locks in step with `PhotoImportButton`, threaded from `RecipesHeader`. | `apps/web/components/recipe/ai-generate-dialog.tsx`, `apps/web/components/recipe/recipes-header.tsx` |
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest, requires developer access | `apps/web/app/api/v1/api-keys` |
| Public OpenAPI spec + docs page | Exists | Scalar-rendered at `/docs`, spec at `/api/v1/openapi.json` | `apps/web/lib/openapi.ts` |
| i18n | Partial | Only `en` and `fr` — no other locales | `apps/web/messages/*.json` |
| 2FA | Exists | TOTP + backup codes + OTP, passwordless-account-compatible | Better Auth `twoFactor` plugin |
| Social login | Exists | Google always, GitHub/Discord/generic OIDC if configured | `apps/web/lib/auth/server.ts` |
| Rate limiting | Exists | Two layers: Better Auth's Redis-backed built-in limiter (tighter on 2FA verify endpoints) + a general sliding-window limiter for AI/API routes | `apps/web/lib/rate-limit.ts` |
| Invite-gated signup | Exists | Signups can be globally disabled, invite-only fallback; first-ever user auto-promoted to admin | — |

---

## 5. PWA, offline, mobile

| Feature | Status | Description | Key files |
|---|---|---|---|
| Installability | Exists | Manifest + SVG icons (no raster/PNG fallback), Chromium `beforeinstallprompt` button, **iOS Safari gets a static "tap Share" instructional banner** (no programmatic install API exists on Safari — Apple restriction, not fixable) | `apps/web/app/manifest.ts`, `apps/web/components/pwa/install-prompt.tsx` |
| Service worker | Exists | Cache-first for `/cook` pages, network-first-with-cache-populate elsewhere, `/api/*` bypassed | `apps/web/public/sw.js` |
| Background Sync | Exists | Chromium via native Background Sync API; Safari/Firefox (no support at all) via an `online`-event fallback reading the same IndexedDB queue | `apps/web/lib/offline-queue.ts`, `apps/web/public/sw.js` |
| Offline save-for-later | Exists | Per-recipe explicit pin + IndexedDB-tracked list shown on the `/offline` fallback page | `apps/web/lib/offline-db.ts`, `apps/web/components/recipe/save-offline-button.tsx` |
| Offline scope | Partial | Recipe viewing + one write path (mark-cooked). **Shopping lists and meal plan have no offline/queue coverage** | — |
| Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` |
| Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` |
| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target` → `/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` |
| Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — |
| Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — |

---

## Real gaps (confirmed absent, worth considering)

Ranked roughly by likely value:

1. ~~Stripe checkout + billing portal~~ — closed 2026-07-23 (solo Pro/Family subscribe/cancel/manage; family multi-user sharing still deferred, see the note in the table above).
2. **Recipe video** — no support at all.
3. ~~Nutrition trend/history view~~ — closed 2026-07-22 (7/30/90-day calorie chart + macro averages).
4. ~~USDA/nutrition-database lookup~~ — closed 2026-07-22 (per-ingredient, gram-convertible ingredients only; AI fills the rest).
5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
7. ~~Anonymous public link for meal plans~~ — closed 2026-07-21 (read-only, with a QR code).
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live).
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
11. **Followers-only recipes invisible on the author's own profile grid** (2026-07-24) — `u/[username]/page.tsx` only queries `visibility = "public"` for the grid, so a followers-only recipe shows up in a follower's feed but never on the profile it belongs to. Real bug, not a design choice.
12. ~~Auto-deduct pantry on cook is dead in the UI~~ — closed 2026-07-24 (both UI callers now pass `true`; the new Mark Cooked dialog exposes it as a checkbox).
13. ~~Model Prefs picker is ungated~~ — closed 2026-07-24 (now behind BYOK access, hidden entirely for everyone else).

## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24)

Asked directly this pass: has Epicure accumulated too many technical/developer-oriented features for its actual audience (home cooks)? Verdict from all 5 domain audits: **mostly no, one real exception.**

**The one real offender:** `Settings → AI`'s **Model Prefs** picker (`model-prefs-form.tsx`) — every user, gated by nothing, can select a raw provider + model ID (`gpt-4o-mini`, `o3-mini`, `claude-opus-4-8`...) per AI use-case. A home cook has no reason to know what "o3-mini (reasoning)" means or why they'd pick it over the default. This is the single clearest case of a developer-tool control bolted onto a consumer screen with zero fencing. Worth fixing: hide it behind developer/BYOK access (or just remove the picker for non-technical users and let the admin's configured default silently apply), since right now it's reachable by literally everyone.

**Everything else technical is already properly fenced, and mostly invisible by default:**
- **API keys, both webhook systems (user + admin-ops), BYOK, public OpenAPI/Scalar docs, self-serve developer-access toggle** — real engineering surface (auth gating, rate limiting, encryption-at-rest, SSRF validation, delivery/redelivery, replay protection), but every one of them is hidden from the Settings nav by default and only appears once a user has developer access (paid tier + opt-in) or BYOK (admin-only). A casual user never sees these nav entries at all. The audience is third-party integrators / automation hobbyists / self-hosters with their own AI keys — genuinely orthogonal to "save and share recipes," but it doesn't clutter the product because it's opt-in-gated, not default-visible.
- **Admin billing complexity, feature-flag matrix, Gitea sync, admin ops webhooks** — pure back-office/staff tooling. A home cook never has any path to these screens at all (admin-only). Legitimate SaaS-business infrastructure, zero consumer-facing weight.
- **Moderator console** — same story, staff-only.
- **Every consumer-facing domain checked clean**: recipe/meal-plan/pantry/shopping/social UI copy uses plain language throughout (checked directly — no "service worker," "IndexedDB," "webhook," "API," or similar jargon leaks into any user-facing string, including the newer offline/PWA surfaces). Cooking mode, batch cooking, collections, search, notifications — all read as ordinary consumer app features, not power-user tooling.

**Net take:** the *codebase* has a lot of technical depth relative to "recipe app," but almost all of it is correctly fenced off from the people it's not for. The fencing itself (developer-access self-serve, BYOK admin-gate, admin-only sections) is well-designed. The Model Prefs picker was the one place that fencing was skipped — closed 2026-07-24 (now gated behind BYOK access, hidden entirely rather than locked-and-teased for everyone else). The "too tech-savvy" concern is resolved as of this fix.

## Deliberate design choices (not gaps)
- Direct messaging is 1:1 only — enforced at the schema level, not a missing feature.
- Feature toggles (`userFeaturePrefs`) are cosmetic nav-hiding only, never access control — separate from tier-based feature flags.
- No native app — PWA-only is the stated architecture, covered well by the offline/install/push work already shipped.

---

## Standing rule

**Every time a feature ships, update this file in the same change** — add/update its row, move it out of "Real gaps" if it closes one. Bug fixes to existing features don't need an entry unless they change what the feature does.
