With ~137 seeded entries the flat unfiltered list stopped being usable. Added a search box (name + alias, case/accent-insensitive), a category filter dropdown with live per-category counts, aliases entered as removable tag chips instead of one comma-separated field, and the category field is now a GROCERY_CATEGORIES dropdown instead of free text (was a typo away from creating an unmatched-anywhere category). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
42 KiB
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:
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).- Recipe video — no support at all.
Nutrition trend/history view— closed 2026-07-22 (7/30/90-day calorie chart + macro averages).USDA/nutrition-database lookup— closed 2026-07-22 (per-ingredient, gram-convertible ingredients only; AI fills the rest).OS Share Target— closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).Push click-through handling— closed 2026-07-21 (push/notificationclicklisteners added tosw.js).Anonymous public link for meal plans— closed 2026-07-21 (read-only, with a QR code).- Grocery delivery/live pricing beyond the Instacart stub (which needs a partnership agreement to go live).
- Offline coverage for shopping lists / meal plan — currently recipe-viewing + mark-cooked only.
Moderator-scoped admin routes— closed 2026-07-21 (reports + recipe-unpublish).- Followers-only recipes invisible on the author's own profile grid (2026-07-24) —
u/[username]/page.tsxonly queriesvisibility = "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. Auto-deduct pantry on cook is dead in the UI— closed 2026-07-24 (both UI callers now passtrue; the new Mark Cooked dialog exposes it as a checkbox).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.