docs: full re-audit of FEATURE_AUDIT.md, answer the "too tech-savvy" question

5 parallel domain re-sweeps against current code (last full audit was
2026-07-21/22, three days of heavy shipping since -- billing,
developer/BYOK permission split, moderator scoping). Corrections and
additions:

- Fixed stale admin nav count (13 -> 15 sections, was missing Billing
  entirely).
- Closed the /r/[id] followers-visibility gap that was flagged open --
  it's fixed in code, found a new inconsistency in its place: a
  followers-only recipe never shows on the author's own profile grid.
- Corrected "auto-deduct pantry on cook" -- the API logic is real and
  correct but both UI callers hardcode it off, so it never fires in
  practice. Was overstated as "Exists, one nuance."
- Fixed "bulk-set-week" mislabel (it's bulk-clear only, no bulk-create).
- Added several undocumented features: recipe notes, manual nutrition
  entry, AI conversation thread management + chat-history search,
  two more draft-recipe AI endpoints, the authenticated meal-plan
  collaboration UI (distinct from the public link), move-to-pantry
  button, pantry bulk-merge, public shopping-list page + print/QR,
  add-to-shopping-list AI tool, leftover-cron scope limitation.
- Noted shopping-list collaboration now supplements polling with real
  push notifications on add/check events.

New "Consumer-friendliness assessment" section directly answers
whether Epicure has too many technical/developer-oriented features
for a home-cook audience: mostly no -- API keys, webhooks, BYOK,
OpenAPI docs, and self-serve developer access are all real surface
but properly gated and hidden from nav by default. One genuine
exception found and flagged: Settings -> AI's Model Prefs picker lets
every user pick raw model IDs with zero gating -- the one place a
developer-tool control was left unfenced on a consumer screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 08:49:54 +02:00
parent f871f4f588
commit d230098b1e
+42 -17
View File
@@ -1,6 +1,6 @@
# Epicure Feature Audit # Epicure Feature Audit
Code-verified inventory of what exists in the app today, by domain. Written 2026-07-21 after a full read of the routes/schema/components listed under "key files" for each entry — not guessed from names. **Keep this file updated whenever a feature is added or changes what it does** (see standing rule below). 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"). 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").
@@ -10,6 +10,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
**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. **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.
**⚠️ Found this pass — ungated technical UI:** `Settings → AI`'s **Model Prefs** section (`apps/web/components/settings/model-prefs-form.tsx`) lets *every* user, with no developer/BYOK gate at all, pick a raw provider + specific model ID (`gpt-4o-mini`, `o3-mini`, `claude-opus-4-8`, etc.) per use-case, spending the admin's own API budget. This is the one place in the app where a home cook can stumble into an LLM-playground-style control panel with zero fencing. See the "Consumer-friendliness" section at the end of this doc.
| Feature | Status | Description | Key files | | 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/**` | | Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
@@ -24,7 +26,10 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Meal / drink pairings | Exists | Gated by tier feature flags `meal_pairing` / `drink_pairing` | `apps/web/app/api/v1/ai/{pairings,drinks}/[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]` | | 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` | | 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 | AI-estimated, cached on the recipe row; author-triggered | `apps/web/app/api/v1/recipes/[id]/nutrition` | | 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` | | 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` | | 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}` | | 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}` |
@@ -33,7 +38,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| **Recipe video** | **Missing** | No video field/upload path anywhere in schema or components | — | | **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/**` | | 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` | | 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, one flagged gap | Enforced almost everywhere as public-vs-not-private; the `/r/[id]` anonymous share route's own code comment flags that `followers`-only recipes may be reachable without a follow-relationship check | `apps/web/app/r/[id]/page.tsx` | | 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/**` | | 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/**` | | 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` | | 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` |
@@ -47,21 +52,24 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Feature | Status | Description | Key files | | Feature | Status | Description | Key files |
|---|---|---|---| |---|---|---|---|
| Weekly calendar meal plan | Exists | MonSun grid, manual CRUD + bulk-set-week | `apps/web/app/(app)/meal-plan`, `apps/web/app/api/v1/meal-plans/[weekStart]/**` | | Weekly calendar meal plan | Exists | MonSun 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 | `apps/web/app/api/v1/ai/meal-plan/generate` | | 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 | `meal_plan_members` viewer/editor roles for collaborators, plus an anonymous read-only public link (`isPublic` flag, mirrors shopping lists' but view-only — no public-editable mode) at `/m/[id]`, with a QR code generated client-side for the link | `apps/web/lib/meal-plan-access.ts`, `apps/web/app/m/[id]/page.tsx`, `apps/web/components/meal-plan/share-meal-plan-button.tsx` | | 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` | | **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` | | 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/**` | | Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
| 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` | | 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` |
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link mode (`isPublic`/`publicEditable`) | `packages/db/src/schema/meal-planning.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` |
| List collaboration | Partial | Short-polling, not websocket/push — explicitly documented as such in code | `apps/web/components/meal-plan/shopping-list-view.tsx` | | 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]` |
| Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize | `apps/web/lib/grocery-categories.ts` | | 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` |
| Grocery delivery integration | **Partial / stub** | Generic export payload works; 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` | | 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 | — | | Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists | | `apps/web/app/api/v1/pantry/**` | | Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
| Auto-deduct pantry on cook | Exists, one nuance | Name+unit matching; for batch-cook recipes, deduction fires once (not re-scaled) on the first dish marked cooked | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` | | Auto-deduct pantry on cook | **API-only, dead in the UI** | The API logic is correct (name+unit matching, defaults `deductFromPantry: true`) — but both real UI callers (`batch-cook-dishes.tsx`, `meal-planner.tsx`'s mark-cooked flow) hardcode `deductFromPantry: false`. A home cook using the app today never triggers auto-deduction; it's only reachable via a direct API/API-key client. Previously documented as "Exists, one nuance," which overstated real-world behavior. | `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` |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions | `apps/web/components/pantry/expiring-soon-suggestions.tsx` | | 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` | | **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` | | 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 goals | Exists | | `apps/web/app/api/v1/users/me/nutrition-goals` |
@@ -76,7 +84,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Feature | Status | Description | Key files | | Feature | Status | Description | Key files |
|---|---|---|---| |---|---|---|---|
| Follow / unfollow | Exists | One-way, no approval step | `apps/web/app/api/v1/users/[username]/follow` | | Follow / unfollow | Exists | One-way, no approval step | `apps/web/app/api/v1/users/[username]/follow` |
| Block | Exists | Hides comments, blocks messaging both ways | `apps/web/lib/blocks.ts` | | 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` | | 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]` | | 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` | | Ratings/reviews, comments (1-level threaded), reactions, @mentions | Exists | | `packages/db/src/schema/social.ts` |
@@ -85,12 +93,12 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Read receipts | Partial | Thread-level `lastReadAt` only — no per-message delivery/seen state | `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` | | 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` | | "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** | `apps/web/app/api/v1/feed/trending` | | 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`) | | 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 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` | | 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` | | 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 | `apps/web/app/api/v1/reports`, `apps/web/app/api/v1/admin/reports` | | 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` | | 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` |
--- ---
@@ -102,7 +110,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| 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` | | 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 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` | | 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 | 13 sections: overview, insights/analytics, users, invites, recipe moderation, reports, support, tiers, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/**` | | 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 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` | | Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
| **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`) | | **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` | | User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
@@ -150,6 +158,23 @@ Ranked roughly by likely value:
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live). 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. 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). 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** (2026-07-24) — correct, tested API logic that literally nothing in the product surfaces; both UI callers hardcode it off. Either wire it up (a real feature a home cook would want) or stop pretending it's a shipped feature.
13. **Model Prefs picker is ungated** (2026-07-24) — see "Consumer-friendliness" below; the one clear case of a technical control exposed with no fencing at all.
## 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 is the one place that fencing was skipped — fix that one thing and the "too tech-savvy" concern is essentially resolved.
## Deliberate design choices (not gaps) ## Deliberate design choices (not gaps)
- Direct messaging is 1:1 only — enforced at the schema level, not a missing feature. - Direct messaging is 1:1 only — enforced at the schema level, not a missing feature.