Files
Epicure/FEATURE_AUDIT.md
T
Arnaud 4ae0bd580e feat: admin Ingredients CRUD UI; fix "page not found" after deleting shopping list/recipe/collection (v0.85.0)
Admin > Ingredients lets admins add/edit/delete canonical ingredients and their aliases (e.g. "sel"/"sel fin"/"table salt") without touching code — previously only settable by hand in packages/db/src/seed.ts. Deleting just unlinks referencing pantry items/recipe ingredients (onDelete: set null), nothing else changes.

Fixed: deleting a shopping list, recipe, or collection from its own detail page used router.push to navigate away, leaving the deleted resource's URL in browser history — one back-button press landed on a real "Page not found" screen. All three now use router.replace so the dead URL never sits in history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 18:42:42 +02:00

41 KiB
Raw Blame History

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 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; 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) Exists The ingredients table (canonical name + aliases[]) existed but was never populated or queried anywhere. Seeded with ~10 bilingual EN/FR staples (salt, sugar, pepper, flour, butter, milk, egg, onion, garlic, olive oil — 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. Resolution happens at compare-time from free text (resolveIngredientKey), not from a stored FK on both sides — recipe ingredients still don't carry ingredientId. apps/web/lib/ingredient-match.ts, packages/db/src/seed.ts
Admin: Ingredients CRUD (new 2026-07-24) 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. 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.