Compare commits

...

134 Commits

Author SHA1 Message Date
Arnaud eb99faf655 feat: developer access permission gates webhooks/API keys/BYOK (v0.71.0)
Webhooks, self-serve API keys, and BYOK AI provider keys had zero
access gating -- any logged-in user, any tier. Adds users.isDeveloper
(boolean, admin-toggled in admin/users/[id] alongside role/tier),
checked via a single hasDeveloperAccess() (lib/permissions.ts) so a
future subscription-tier auto-grant is a one-line change there, not
a redesign across call sites.

requireDeveloper() (lib/api-auth.ts) wraps requireSession() with a
fresh isDeveloper check (same reasoning as requireAdmin re-querying
role: session.user's cookieCache can be up to 5 minutes stale) and
replaces requireSession in all 8 gated routes: webhooks CRUD +
deliveries + redeliver, api-keys CRUD, ai-keys CRUD.

Settings UI: the sidebar hides API Keys/Webhooks nav entries for
non-developers; those pages and the BYOK section of Settings -> AI
show a locked notice instead of the manager component when accessed
directly.

Migration grandfathers in anyone who already has a webhook, API key,
or BYOK key row -- ships as a new gate on existing features, not a
silent lockout of active integrations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 09:49:07 +02:00
Arnaud f0632cce95 feat: USDA FoodData Central per-ingredient nutrition lookup (v0.70.0)
estimateNutrition() (lib/ai/features/estimate-nutrition.ts) is now a
hybrid: for each ingredient, estimateGrams() (lib/ingredient-grams.ts)
converts its quantity+unit to grams where possible (weight units
exactly; volume units -- cup/tbsp/tsp/ml/l/etc -- via a 1ml~=1g water-
density approximation, documented as a real simplification but far
better than skipping them). Convertible ingredients get looked up in
USDA FoodData Central (lib/usda.ts, SR Legacy + Survey (FNDDS)
datasets -- the two suited to generic/raw ingredients, not Branded
packaged products or narrower Foundation) and summed. Whatever's left
(count-based units like "2 cloves", no USDA match, or USDA_API_KEY
unset entirely) goes through one AI call asking for just that
subset's total contribution, which sums directly with the USDA
totals -- no whole-recipe AI estimate to reconcile against.

Same exported signature and return shape as before
(NutritionEstimate / {perServing: {...}}), so every existing caller
(the nutrition route, meal-plan generation) gets more accurate
results automatically once USDA_API_KEY is set in Admin -> Settings,
with zero behavior change when it isn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 00:41:12 +02:00
Arnaud 592c86f8d4 feat: nutrition trend/history view (v0.69.0)
Extends GET /api/v1/users/me/nutrition-diary with a `range` query
param (7/30/90) that switches it into trend mode -- daily
calorie/macro totals bucketed from the same cooking-history rows the
single-day diary already reads, with zero-filled days so the chart
has a continuous x-axis. New NutritionTrend component reuses the
existing hand-rolled TimeSeriesChart (previously admin-only, now
imported from user-facing code too) for the calorie line, plus
simple average-macro stat tiles below it.

Nutrition page now has Diary/Trend tabs instead of just the diary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 00:36:10 +02:00
Arnaud 35d4f3d055 feat: anonymous read-only public link + QR code for meal plans (v0.68.0)
Mirrors shopping lists' isPublic pattern but read-only only -- no
publicEditable equivalent, since anonymous editing of someone's
weekly meal schedule isn't a real use case the way collaborative
shopping-list editing is. New PATCH /api/v1/meal-plans/{weekStart}
toggles it (lazily creates the week's plan row if missing, same
pattern as the existing POST). Public page at /m/[id] renders the
week grouped by day, linking through to public/unlisted recipes and
just showing the title otherwise.

ShareMealPlanButton gained the same public-link toggle UI as its
shopping-list sibling, plus a QR code (generated client-side via the
already-installed `qrcode` package) for the link once enabled --
handing someone a printed or screenshotted plan doesn't require
typing a URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 23:47:31 +02:00
Arnaud c8ee743458 feat: OS Share Target for recipe URLs (v0.67.0)
Declares share_target in manifest.ts (action /recipes, GET,
title/text/url params). recipes/page.tsx extracts the shared link --
preferring the url param, falling back to the first http(s) URL
found inside text since senders vary on where they put it -- and
passes it to RecipesHeader as sharedUrl. UrlImportDialog gained
initialUrl/autoImport props so the existing import-from-URL flow
opens pre-filled and fires immediately instead of requiring the user
to paste the link back in.

Chromium/Android only -- Safari has no Share Target API at all,
same restriction already documented for the install-prompt banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 23:36:53 +02:00
Arnaud 4c3880e07f feat: moderator-scoped admin access + fix push notifications not displaying (v0.66.0)
Moderator role existed in the schema and was already respected by
comment deletion, but every admin page/route treated moderator
identically to a regular user (403/redirect). Wires it up narrowly:
admin/layout.tsx now lets admin+moderator through and filters the
nav by role, while every admin-only page (users, tiers, settings,
webhooks, insights, etc.) explicitly redirects moderators away via a
new requireFullAdminPage() helper -- the nav filter is UX, this is
the actual gate. Moderators land on Reports and Recipes: reports
GET/PATCH now accept requireAdmin({allowModerator: true}), and a new
PATCH /api/v1/admin/recipes/[id] lets admin+moderator unpublish a
public recipe (flip to private) as a takedown action, audit-logged.

Also found and fixed a real bug while auditing the PWA push pipeline
for a "push click-through" gap: public/sw.js had no `push` event
listener at all, so incoming push messages never displayed anything
-- push was silently non-functional end-to-end despite the
subscribe/send plumbing all working. Added the push listener
(showNotification) and a notificationclick listener that focuses an
existing tab or opens one at the payload's url.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 23:31:47 +02:00
Arnaud e3f2cf6834 docs: add code-verified feature audit (FEATURE_AUDIT.md)
Full read of routes/schema/components across recipes+AI, meal
planning/pantry/shopping/nutrition, social/messaging/notifications,
admin/billing/ops, and PWA/offline/mobile -- corrects several wrong
assumptions from earlier speculation (recipe-import-from-URL,
pantry barcode scan, and cooking-mode voice control all already
exist; they were guessed as missing before this audit).

Real confirmed gaps: no Stripe checkout/billing-portal (only the
webhook consumer exists), no recipe video, no nutrition trend view,
no OS share-target, no push click-through handling, no anonymous
meal-plan link, no offline coverage beyond recipes+mark-cooked.

Per user request, this file should be kept current going forward --
every shipped feature gets an entry here alongside the usual
version-bump/changelog/OpenAPI updates.
2026-07-21 22:32:38 +02:00
Arnaud 513e4a904a fix: remove leftover create-next-app default favicon.ico (v0.65.3)
app/favicon.ico was still the stock Next.js/Vercel triangle logo from
the original scaffold, never swapped for Epicure's own icon. Next's
file-based routing serves whatever's at app/favicon.ico as
/favicon.ico independently of the icon.svg configured via metadata,
so browsers preferring the .ico kept showing the placeholder no
matter the cache state -- explains "clean computer, still see the
default icon". No .ico regenerated (no image rasterizer available in
this environment) -- the existing icon.svg is now the only favicon
source, which every modern browser supports directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 20:44:58 +02:00
Arnaud 83a1381052 chore: version bump + changelog for save-offline button fix (v0.65.2)
Follow-up to e78e24e -- version bump/changelog got dropped from that
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 00:33:45 +02:00
Arnaud e78e24e40d fix: save-offline button icon+tooltip only, matches other recipe actions
Was a labeled outline button, inconsistent with PrintButton/
ShareRecipeButton's icon-only ghost + tooltip convention on the same
row.
2026-07-21 00:33:21 +02:00
Arnaud 3aaa12910a feat: iOS Safari install instructions (v0.65.1)
beforeinstallprompt is Chromium-only by spec -- Safari (iOS and
macOS) never fires it and has no programmatic install API at all, so
the existing install banner silently did nothing there. Detects iOS
Safari specifically and shows a static "tap Share, then Add to Home
Screen" banner instead, dismissible and tracked separately from the
Chromium banner's own dismissal state. No-ops once already installed
(display-mode: standalone / navigator.standalone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 00:08:04 +02:00
Arnaud 9ba2996022 feat: offline save-for-later + background sync for mark-cooked (v0.65.0)
Adds a "Save offline" button on the recipe page that force-refetches
the current page so the service worker's cache picks up a fresh copy
right now, plus a small IndexedDB-backed list (lib/offline-db.ts) of
what's been saved. The /offline fallback page now reads that list and
renders it instead of being a dead end with just a "go back" link.

Also fixes the service worker's network-first fetch handler, which
never wrote successful responses into its cache -- meaning the
existing offline page's claim that "recently visited recipes are
available" was never actually true. It populates the cache on every
successful GET now.

Background sync: marking a batch-cook dish as cooked while offline
(the only existing mark-cooked call site in the app) now queues the
request in IndexedDB instead of just failing, and registers a
Background Sync (public/sw.js's "sync" listener replays the queue)
for Chromium; lib/offline-queue.ts's online-event fallback covers
Safari/Firefox, which never fire that event at all. Both replay paths
read/write the same IndexedDB store so either one drains it.

Also removes apps/web/public/manifest.json, a stale static manifest
that layout.tsx used to link to before the previous commit pointed it
at the real generated route (app/manifest.ts) -- it had gone
unnoticed and unused since.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 00:04:17 +02:00
Arnaud 5f270dee08 fix: PWA manifest was never linked, icons 404'd; add install prompt (v0.64.0)
layout.tsx pointed <link rel="manifest"> at /manifest.json, but the
actual generated route (app/manifest.ts) serves at
/manifest.webmanifest -- the manifest was silently never picked up.
Its icons also referenced icon-192.png/icon-512.png, which don't
exist (only the .svg versions do). Both together meant Chrome/Android
install eligibility was broken with no visible error.

Also adds apple-mobile-web-app meta tags + viewport-fit=cover for
iOS home-screen installs, and a dismissible install banner driven by
the standard beforeinstallprompt event (Chromium-only by spec).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:55:22 +02:00
Arnaud b17984fbef feat: two-way sync between support tickets and Gitea issues (v0.63.0)
Outbound (already existed one-way: ticket create -> Gitea issue) now
also mirrors status changes: closing/reopening a ticket in the admin
UI closes/reopens the linked Gitea issue, and replies posted from
Epicure (by the ticket owner or an admin) post as a comment on the
issue. Captures and stores the Gitea issue number at creation time
to address these follow-up calls without re-parsing the issue URL.

Inbound: new webhook receiver at /api/webhooks/gitea, verified via
HMAC-SHA256 signature (X-Gitea-Signature) with a configurable
GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same
way the existing Stripe receiver dedupes events. Handles "issues"
(closed/reopened -> ticket status) and "issue_comment" (created ->
appends to the ticket's comment thread), skipping comments Epicura
already posted itself (matched by Gitea comment id) to avoid loops.

New support_ticket_comments table backs a lightweight conversation
thread on both the user-facing support page and the admin support
manager, each comment tagged by author (user/admin/gitea).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:34:39 +02:00
Arnaud 274c50c2f6 fix: desktop nav ignored feature toggles; add chatbots toggle (v0.62.0)
Feature toggles (Settings -> Features) were filtered into
visibleNavItems but the desktop horizontal nav still mapped over the
raw NAV_ITEMS list, so hiding a feature only worked on mobile. Both
nav renders now read the same filtered list.

Also adds a Chatbots toggle covering the recipe cooking-chat panel
and the recipe-list cooking assistant, wired end-to-end (schema,
migration, feature-prefs lib, API route, settings form, i18n,
openapi).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:23:01 +02:00
Arnaud c5e1643d39 feat: per-category email prefs, admin ops webhooks, per-user feature toggles (v0.61.0)
- Notification email preferences: every push category (follow, comment,
  reply, reaction, rating, mention, leftoverExpiring, shoppingList) now has
  an independent email toggle, plus a Weekly Digest toggle. Previously email
  sent unconditionally whenever the recipient had one; now gated the same
  way push already was. The weekly-digest cron route excludes opted-out
  users.

- Admin-only site-wide webhooks (Admin → Webhooks): new signups, support
  tickets, and reports filed can now fire an HMAC-signed HTTP webhook
  (Slack/Discord/ops alerting), independent of the existing per-user
  webhooks (which stay scoped to a user's own recipe/meal-plan/shopping-list
  events). Signing/delivery logic factored into lib/webhook-delivery.ts and
  shared by both dispatchers instead of duplicated.

- Settings → Features: users can hide Nutrition, Pantry, Meal Plan,
  Shopping Lists, Collections, or Messages from their own nav. Purely
  cosmetic — hidden pages stay reachable by direct link, nothing is
  access-restricted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:07:28 +02:00
Arnaud cced962bff chore: reorganize planning docs into plans/, drop stale security-audit docs
Moves PLAN.md, STRIPE_PLAN.md, VITRINE_PLAN.md, WHATS_NEW_PLAN.md into a
gitignored plans/ directory instead of tracking them individually. Drops
SECURITY_AUDIT.md and its follow-up — session-scoped audit notes, already
captured in CHANGELOG.md and the actual code fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 22:30:55 +02:00
Arnaud 7626f1b496 fix: close TOCTOU race + missing checks in tier limits, add AI rate limits, patch CVEs (v0.60.0)
Security audit fixes (see SECURITY_AUDIT.md):
- lib/tiers.ts: checkAndIncrementTierLimit's recipe/storage branches did a
  live count/sum check with no lock tying it to the later write, so two
  concurrent requests near the cap could both pass and both write past the
  limit. Added checkTierLimitInTransaction — holds a per-user Postgres
  advisory lock for the duration of the caller's transaction, so the check
  and the write are atomic together. Applied to every recipe/storage write
  path (recipes create/update, fork, rate, avatar, and 7 AI recipe-creation
  routes).
- Adversarial re-verification of that fix caught a bigger gap in the same
  area: four AI routes (photo import, idea generation, batch-cook,
  translate-to-new-draft) had no recipe-limit check at all. Fixed.
- Added missing per-user rate limits to 5 AI endpoints (adapt, drinks,
  pairings, translate, variations) that had none, unlike their siblings.
- search page now checks for a session server-side, matching every other
  page under (app)/ — defense in depth; the underlying API was already
  public by design and proxy.ts already blocked unauthenticated requests.
- admin/settings route now uses the shared requireAdmin instead of a local
  duplicate.
- Documented two admin support-ticket endpoints missing from the OpenAPI
  spec; verified full route/OpenAPI parity otherwise.
- Bumped drizzle-orm to fix a SQL-identifier-escaping CVE, and overrode two
  transitive deps (esbuild, postcss) with known CVEs. pnpm audit is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 22:21:51 +02:00
Arnaud 623e5bcd34 feat: chatbot model setting + tool-calling toggle, simplify admin AI config (v0.59.0)
Chatbot (general assistant + per-recipe Q&A) now resolves its default model
from its own site setting (DEFAULT_CHAT_PROVIDER/MODEL) instead of sharing
the generic "text" use case with recipe generation, so admins can point it
at a different model.

Added AI_TOOL_CALLING_ENABLED: turns off the chatbot's createRecipe/
addToShoppingList tools (and the forced-retry pass) for models that don't
reliably support tool calling — it falls back to plain text answers.

Simplified the admin AI Configuration page: it showed provider keys and
routing settings twice (a read-only card, then an identical edit form).
Merged into one editable section; the resolved fallback provider is now a
one-line note instead of its own card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 21:14:04 +02:00
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00
Arnaud f6214e60df fix: catch the more common tool-skip case — short reply, no tool call (v0.57.2)
The previous fix's looksLikeUnstructuredRecipe only fires when the model
spells the whole recipe out as a numbered/bulleted list — it does nothing
for a short reply that just *claims* a draft exists ("Voici une recette
de Bœuf Bourguignon... brouillon à vérifier ci-dessous") without ever
calling the tool, which is what the user actually hit.

Detecting the model's claimed compliance is fragile (varies by phrasing/
model), so instead detect intent from the user's own message — the same
noun+verb pattern ("recipe"+create/make/... or "recette"+créer/fais/...)
the system prompt's own createRecipe examples already describe. Forces
the retry whenever there's no tool call AND either signal fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 19:46:52 +02:00
Arnaud 6ec9b65c24 fix: self-heal chatbot recipe requests answered as prose, not a tool call (v0.57.1)
The system prompt already told the model to MUST call createRecipe
instead of writing the recipe out — that's a soft rule some models
(especially free-tier ones) still ignore. There was no fallback: if the
model answered in prose, that's just what got returned, no draft card.

Added a detector (looksLikeUnstructuredRecipe: 3+ numbered/bulleted
lines with no tool call) and a forced-tool retry when it fires — same
system+prompt, but toolChoice forced to createRecipe so the model can't
opt out on the second pass. Falls back to a short localized line if the
provider returns no text alongside a forced tool call (some do this by
design). Retry failure just keeps the original prose answer rather than
erroring the whole request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 19:33:49 +02:00
Arnaud 1ee3ec70ba feat: ChatGPT/Claude-style persistent sidebar for desktop fullscreen chat (v0.57.0)
Cooking assistant's fullscreen mode gets a real left sidebar (always-
visible conversation list, hover-reveal rename/delete) on desktop
(md+), replacing the small dropdown menu that made sense in the narrow
420px drawer but not in a full-viewport layout. Mobile fullscreen and
the normal drawer keep the dropdown (ConversationMenu) — no room for a
persistent sidebar there.

Extracted the shared fetch/rename/delete/create logic into
use-conversation-list.ts so the dropdown (ConversationMenu) and the new
sidebar (ConversationSidebar) can't silently diverge — both are thin
render layers over the same hook. ConversationMenu gained an optional
className prop so the panel can hide it with `md:hidden` specifically
when the sidebar is already covering that job.

Per-recipe chat is untouched — it's single-threaded by design and never
used either of these components.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 09:04:07 +02:00
Arnaud 2f18462548 feat: timer unit selector + fix ingredient list alignment (v0.56.0)
Step timer input was seconds-only, no unit — a 90-minute braise meant
typing 5400. Added a seconds/minutes/hours <select> next to the input;
StepRow gets a timerUnit field, converted to seconds at submit. Editing
an existing recipe (and the AI-regenerate flow) picks the largest unit
that divides evenly into the stored seconds so it displays naturally
instead of always falling back to raw seconds.

Ingredient list (serving-scaler.tsx): the quantity column used
min-w-[3rem] on a flex child, which is only a *minimum* — any row whose
formatted quantity text (e.g. an appended "(~2 tbsp)" conversion) exceeded
that width pushed just that row's ingredient name further right,
breaking alignment across the list. Switched the list to a CSS grid with
`display: contents` on each <li>, so the quantity column's width is
shared across every row instead of sized per-row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 08:58:18 +02:00
Arnaud 37739699f9 fix: Admin Insights actual crash — function props across server/client boundary (v0.55.3)
Server logs (thanks to the user pulling them) showed the real error:
"Functions cannot be passed directly to Client Components" — the server
component page was passing formatShortDate/formatMonth as a `formatDate`
prop into TimeSeriesChart ("use client"). Functions aren't serializable
across the RSC boundary; the two previous fixes (query hardening,
Promise.allSettled) were real improvements but not the actual cause of
the reported crash.

TimeSeriesChart now takes a plain `dateFormat: "day" | "month"` string
and formats internally — BarChart was never affected (its formatValue
prop is only ever used via its own default, never passed from the page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:01:13 +02:00
Arnaud 6eb759dd43 fix: Admin Insights page could crash on a single query failure (v0.55.2)
Two changes:
1. Replaced positional `GROUP BY 1` (not used anywhere else in this
   codebase) with the conventional pattern of repeating the actual
   grouped expression — matches every other groupBy call site in the app.
2. Switched Promise.all -> Promise.allSettled across the six independent
   aggregate queries feeding the six charts, defaulting a failed one to
   an empty array (that chart just renders "No data yet") instead of
   taking the whole page down. Logs the failure server-side either way.

Couldn't reproduce locally (no DB in this sandbox), but confirmed the
allSettled path works: the production build itself hit a real connection
failure against the (absent) local DB and degraded cleanly instead of
crashing, which is exactly the resilience this fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:43:11 +02:00
Arnaud a6e3dc999b fix: chatbot conversation rename/delete unreachable on mobile (v0.55.1)
Both buttons were opacity-0 group-hover:opacity-100 — no touch equivalent
to :hover, so they never appeared on mobile at all. Made them always
visible, matching the app's existing convention for per-item actions in
narrow lists (e.g. shopping-list-actions-menu.tsx) rather than hover-reveal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:32:31 +02:00
Arnaud cf7cc6c885 feat: Admin Insights page — charts for signups, recipes, tiers, usage (v0.55.0)
New Admin > Insights: signups/day and recipes-created/day (manual vs AI)
over the last 30 days, users by tier, recipes by visibility, monthly AI
call totals (6 months), and support tickets by status.

Two hand-rolled SVG chart components (bar-chart.tsx, time-series-chart.tsx)
instead of pulling in a charting library — avoids a new dependency and
any React 19 peer-dep risk. Both ship a hover tooltip (crosshair for the
time series, per-bar for the bar chart) and a "show as table" toggle per
the dataviz method's accessibility requirement.

Repurposed the app's existing (previously unused, grayscale-only)
shadcn --chart-1..5 CSS variables with a validated categorical palette —
run through the dataviz skill's CVD/contrast validator for both light and
dark surfaces (both pass; three light-mode slots fall under 3:1 contrast
by design, mitigated by the charts' direct value/axis labels).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:02:58 +02:00
Arnaud 44df734f1c feat: public collections viewable anonymously at /c/{id} (v0.54.1)
New /c/[id] route mirrors /r/[id]'s pattern for recipes: no session
required, gated on collections.visibility != 'private', with an explicit
followers-only check (viewer must be signed in and follow the owner)
since this route has no other access control. Added to proxy.ts's
PUBLIC_PATHS.

Only shows recipes inside the collection that the anonymous/signed-in
viewer could actually see on their own (public/unlisted always, followers
recipes only if following that recipe's author) — the owner's own private
recipes stay hidden from everyone else even inside their own public
collection.

Collection PDF export's QR code and the collection page's new "view
publicly" icon (public visibility only, matching the recipe page's same
rule) both now point here instead of the auth-gated /collections/{id}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:35:41 +02:00
Arnaud 9da57dd1d0 feat: collections visibility enum (matches recipes) + QR on collection PDF (v0.54.0)
Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.

New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.

Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.

Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:26:34 +02:00
Arnaud b1f745da66 fix: batch of collections/i18n/translate-button fixes (v0.53.1)
- Drag-reorder used verticalListSortingStrategy on a multi-column grid,
  which computes wrong transforms for grid reflow — swapped to
  rectSortingStrategy so cards actually animate live while dragging.
- Grip handle was rendered as a sibling of (not a descendant of) the
  `group` element its `group-hover:opacity-100` depended on, so it was
  permanently invisible. Fixed the DOM nesting and made it always
  partially visible instead of hover-only.
- common.edit was missing from both locales (not just French) —
  edit-collection-dialog.tsx was the first caller to hit it.
- Root cause of "generated in my language but Translate still shows":
  generate-meal, meal-plan/generate, and adapt never set recipes.language
  on the row they inserted, so the button's `!recipe.language || ...`
  check always fell back to "show it". Fixed at all three insert sites.
- Translate dialog was entirely hardcoded English (title, description,
  language names, buttons) despite i18n keys already existing for most of
  it — now uses them, plus new translated language-name keys.
- Recipe tags now render on the recipe detail page (previously grid-card
  only).
- Collection header actions converted to icon-only + tooltip, matching
  the recipe page's pattern instead of icon+label buttons.
- Collections list search now also matches recipe titles inside each
  collection, not just the collection's own name/description.
- Explore page links to /collections/explore next to its tabs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:16:03 +02:00
Arnaud e8c687e53a feat: collections overhaul — reorder, search, edit/delete, tags (v0.53.0)
Seven related improvements to collections:

- Drag-and-drop reorder (dnd-kit, same pattern as the shopping list) — new
  collection_recipes.position column (migration 0049, backfilled from
  existing added_at order so nothing jumps around on upgrade).
- Search collections by name/description (server-side, list page) and
  search recipes within a collection (client-side filter, already loaded).
- Edit collection: name/description/tags/private notes via a new dialog;
  new collections.notes + collections.tags columns.
- Delete collection with a choice to also delete its recipes — only ones
  the deleting user actually owns, never recipes shared in by others.
- Collection detail (both owner and public view) now renders the same
  RecipeGridCard used on /recipes, instead of the older, plainer RecipeCard.
- Collection list cards redesigned — photo-collage preview (first 4 recipe
  covers/placeholders), tag badges, cleaner layout.
- Fixed the recipe count shown on a collection card: the query capped the
  `recipes` relation at 1 for thumbnail purposes and then read `.length`
  off that same capped array, so it never showed more than 1. Now a
  proper grouped count query, separate from the thumbnail fetch.

New/changed endpoints documented in OpenAPI: PATCH /collections/{id}/reorder,
DELETE /collections/{id}?deleteRecipes, PUT /collections/{id}'s new
notes/tags fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 18:44:08 +02:00
Arnaud 5403a06348 fix: stray "0" shown for steps with no timer (v0.52.1)
`{step.timerSeconds && (...)}` renders the literal 0 when timerSeconds is
the number 0 rather than null/undefined — only false/null/undefined skip
rendering in JSX, a bare falsy number doesn't. Some steps ended up with
timerSeconds stored as 0 instead of null (e.g. AI generation filling in a
"default" value for an optional field rather than omitting it), which
this guard then rendered as a bare "0" instead of hiding the timer.

Fixed at all 4 render sites (recipe page, cook mode, both print views) by
coercing to a real boolean (`!!step.timerSeconds`) — makes a stored 0
behave identically to null everywhere it's displayed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 18:29:18 +02:00
Arnaud 8f83a1eaae feat: generate a complete themed meal from a collection (v0.52.0)
New "Generate meal" button on any owned collection — pick a theme (free
text) and 2-6 courses (starter/main/side/dessert/drink), one generateObject
call produces a coherent recipe per course (shared cuisine/style, matching
flavors across courses) and all of them get saved as real recipes and
added to that collection in one action.

Follows the meal-plan generation route's established pattern: single
withAiQuota charge for the whole generation, then a pre-flight loop
charging the tier's recipe limit once per generated recipe (rolled back
on a partial breach) before the insert transaction runs. Recipes are
tagged with their course name for later filtering; visibility defaults to
private like every other AI-generated recipe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 12:16:28 +02:00
Arnaud 1b72135958 fix: consistent icons in account dropdown menu (v0.51.7)
"View profile" and "Settings" had no icon while Support/Admin did, so the
menu didn't read as one consistent list. Added User/Settings/LogOut icons
to match, plus a separator grouping account+support links apart from the
theme switcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 12:08:26 +02:00
Arnaud 43088759cf fix: embed images inline in Gitea issues, fix retry dropping attachments (v0.51.6)
Two related bugs in the Gitea issue body:

1. Attachments were always plain bullet links (`- url`), even for images —
   so they never rendered as a preview in the Gitea issue, just a clickable
   URL. Image content-types now use markdown image embed (`![](url)`).
2. The admin "Create Gitea issue" retry action built its own body from
   scratch (`ticket.description` only) and never queried attachments at
   all — a retry always lost them, regardless of point 1's fix.

Extracted buildGiteaIssueBody() to lib/gitea.ts so both the initial
ticket-creation path and the retry path share one body-building
implementation instead of two independent (and inevitably diverging) ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:58:50 +02:00
Arnaud 6502307340 fix: resolve Gitea label names to IDs before creating an issue (v0.51.5)
Gitea's POST /repos/{repo}/issues expects `labels` as an array of numeric
label IDs, not name strings — every issue creation was failing with a 422
("cannot unmarshal JSON string into Go int64"). Now fetches the repo's
label list and resolves "bug"/"enhancement"/"question" to IDs by name
first; a repo with no matching labels (or a failed lookup) just creates
the issue unlabeled instead of failing the whole request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:40:52 +02:00
Arnaud e6a5e1e6ab fix: surface real network cause on Gitea issue creation failure (v0.51.4)
A thrown fetch error (DNS failure, connection refused, timeout) never
reached the res.ok branch, so its actual cause was lost — err.message
alone is often just "fetch failed" with the real reason nested in
err.cause. Now logs the full cause server-side and folds it into the
error returned to the admin support view's tooltip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:20:11 +02:00
Arnaud b4dd29893d docs: spell out Gitea integration setup in admin Settings UI (v0.51.3)
GITEA_URL/GITEA_REPO format and the exact token scope needed (issue
read+write only) were only in the commit message and CLAUDE session —
now visible right above the fields admins are filling in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:05:41 +02:00
Arnaud 1bfd03c42e fix: recipe cover placeholder icons use solid color, not opacity (v0.51.2)
text-foreground/25 alpha compounded wherever an icon's own stroke paths
overlap (chef-hat's brim/band, croissant's curves), reading as a dark
blotch instead of an even tint. Swapped to text-muted-foreground — solid,
themed, no overlap artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:54:51 +02:00
Arnaud 520d992de4 feat: more icon choices for custom recipe covers (v0.51.1)
Dish pool 16 -> 29 (banana, grape, citrus, hamburger, popcorn, bean,
vegan, utensils-crossed, leaf, shrimp, cooking-pot, candy, nut), drink
pool +milk. Existing recipes' auto-picked icon may shift since the
deterministic hash is modulo pool size — expected, cosmetic only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:44:20 +02:00
Arnaud 51e6722f4c feat: custom recipe cover color/icon + mute auto placeholder palette (v0.51.0)
Two changes to the no-photo cover placeholder shipped last version:

1. Muted the gradient palette (100/40-opacity tints instead of solid -200/
   -950 stops) — the original was too saturated next to real cover photos
   in the same grid, per feedback.
2. New coverIcon/coverColor columns on recipes (nullable text, migration
   0048, additive-only) let the author pin a specific color+icon from the
   recipe editor instead of the automatic per-id pick. getRecipePlaceholder
   now checks these first, falling back to the deterministic hash pick when
   unset — existing recipes are unaffected until edited.

Wired coverIcon/coverColor through every explicit-column recipe select
that feeds a grid card (explore trending/recent, search, feed, for-you) —
the relational-query call sites (recipes page, collections, profile pages)
already return all columns and needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:32:31 +02:00
Arnaud 955c4bd9dd fix: varied gradient+icon placeholders for recipes without a cover photo (v0.50.2)
Replaces the single flat emoji-on-muted-bg fallback (same 🍽️/🍹 everywhere)
with a deterministic gradient + food/drink icon per recipe id — pulled from
a small curated palette/icon pool via a string hash, so it's stable across
re-renders but visually varied across a grid of photo-less recipes.

New shared lib/recipe-placeholder.ts + components/recipe/recipe-cover-
placeholder.tsx, applied everywhere the old inline emoji fallback lived:
recipe-grid-card.tsx, recipe-card.tsx, recipes-grid.tsx, and the public
profile page's recipe grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 09:47:22 +02:00
Arnaud edcde8b34a fix: password sign-in for 2FA accounts could silently bounce to /login (v0.50.1)
signIn.email() resolves with error: null and data.twoFactorRedirect: true
for 2FA-enabled accounts — no full session exists yet. The login page's
handleSubmit treated any non-error response as fully signed in and called
router.push("/recipes"), racing better-auth's own window.location.href
redirect to /verify-2fa (twoFactorClient's onSuccess hook). When the app's
push won that race, middleware bounced the unauthenticated /recipes
request straight back to /login with no error surfaced — looked like the
page just reloaded.

Now explicitly skips the /recipes push when twoFactorRedirect is set,
leaving the SDK's own redirect to run uncontested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 09:26:26 +02:00
Arnaud 2f3ba14093 feat: per-tier feature toggles for recipe variations/pairings (v0.50.0)
Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).

Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).

The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 23:35:12 +02:00
Arnaud 12c2ec213a feat: file/screenshot attachments on support tickets (v0.49.1)
Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.

New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 12:09:40 +02:00
Arnaud 811d4cad42 feat: in-app support form with email + Gitea issue integration (v0.49.0)
Users can now report bugs, suggestions, or questions from a new /support
page. Each submission sends a confirmation email and, when GITEA_URL/
GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled
issue on that repo automatically (best-effort — failure doesn't block the
ticket). Admins get a Support section to triage status and retry failed
Gitea issue creation.

New support_tickets table, gitea.ts client, site-settings entries for the
three new secrets, and OpenAPI docs for the two user-facing endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:23:02 +02:00
Arnaud 9ea1f90ec4 fix: replace emoji flags with SVG dropdown, complete Home's third row
Language switcher was two inline emoji-flag buttons -- emoji flag
rendering is inconsistent across OS/browsers (some render as two-letter
country codes instead of a flag). Replaced with hand-rolled inline SVG
flags (no new dependency for two locales) inside a proper dropdown
menu, trigger showing the current language's flag.

Also added a 9th Home highlight (personalized recommendations, same
copy already written for the Features page) so the lg:grid-cols-3
feature grid's last row has 3 items instead of 2.

v0.48.2
2026-07-18 11:04:37 +02:00
Arnaud 8dccd8898b feat: vitrine language/theme switching, missing features, disabled-signups handling
Language switcher (flag toggle) and dark-mode toggle in the marketing
header, both usable by logged-out visitors -- the marketing pages
previously hardcoded English (getMessages(undefined)) regardless of
any preference, and had no theme control since the authenticated
Nav's toggle isn't rendered there. New cookie-based locale resolution
(lib/marketing-locale.ts) separate from the authenticated app's
useLocale/setLocale, which persists via an authenticated PATCH that
would just 401 and revert for an anonymous visitor. Root layout now
falls back to that cookie for <html lang> when there's no session.

Features/Home now cover cook mode, recipe variations, personalized
recommendations, and ingredient substitution -- all real, shipped
features that were missing from the page copy.

Signup CTAs check isSignupsDisabled() and swap to "Signups closed"
instead of "Get started free" -- still linking to /signup, since that
page already handles the invite-token exception correctly.

Fixed along the way: importing the cookie-name constant from
marketing-locale.ts in the client-side LanguageSwitcher pulled that
file's server-only imports (lib/auth/server -> the DB client ->
`postgres`) into the client bundle, breaking the build on Node
built-ins. Split the constant into its own client-safe file
(marketing-locale-cookie.ts). Caught by `pnpm build`, not typecheck.

Verified with typecheck, lint, and a full production build (clean
compile) -- no DB in this sandbox to click through a real dev server.

v0.48.1
2026-07-18 10:55:06 +02:00
Arnaud e71c978e52 feat: public marketing site (vitrine)
Adds a (marketing) route group -- Home, Features, About, Privacy,
Terms, Contact -- reusing the app's design system/i18n/deploy
pipeline rather than a separate site. Root page.tsx now branches on
session instead of always redirecting to /recipes: logged-in visitors
still land in the app unchanged, logged-out visitors see the vitrine
home page.

The actual integration point: proxy.ts's PUBLIC_PATHS previously sent
every anonymous "/" request straight to /login before page.tsx's
redirect ever ran. Added the new marketing routes to PUBLIC_PATHS,
plus a separate exact-match list for "/" itself -- it can't go in the
startsWith-matched array, since every path starts with "/" and that
would make the whole app public.

Also adds app/sitemap.ts and app/robots.ts (neither existed before),
disallowing the authenticated app and the unlisted /r/ and /s/ share
routes from crawling while allowing /u/ public profiles.

Pricing page deliberately not included -- waiting on Stripe Checkout
(STRIPE_PLAN.md) so its CTA goes somewhere real. Privacy/Terms are
structural drafts flagged inline as not lawyer-reviewed, per
STRIPE_PLAN.md's own tax-advice caveat -- same spirit here.

Verified with a full production build (pnpm build) -- compiles clean,
all 7 new routes render, since there's no DB in this sandbox to run
the dev server against for a real click-through.

v0.48.0
2026-07-18 09:33:47 +02:00
Arnaud 2a256a8943 docs: scope the "What's New" in-app announcement feature
Extends ChangelogEntry with an optional highlights field (editorial,
per-version, plain language -- most versions won't have one) instead
of forking a second content source. One new users column
(lastSeenChangelogVersion, backfilled to current APP_VERSION on
migration so existing users aren't flooded with history), two routes,
one component mirroring NotificationBell's existing bell/badge/dropdown
shape.
2026-07-18 09:24:03 +02:00
Arnaud e4ad092751 docs: drop /changelog from vitrine plan, flag in-app "what's new" gap
CHANGELOG.ts is a dev log (migration reminders, bug-fix jargon), not
fit for public display. Removed the public changelog page from the
vitrine's page list/rollout/PUBLIC_PATHS example. Added §6 flagging
the real underlying need — a curated, in-app "What's New" surfaced to
existing users (bell/badge, keyed off last-seen version) — as a
separate, smaller feature to scope properly later, not folded into
this plan.
2026-07-18 01:35:35 +02:00
Arnaud 634f8245e1 docs: add vitrine (marketing site) plan
Page list (home/features/pricing/changelog/about/privacy/terms/contact),
same-app route-group approach reusing design system/i18n/deploy, and
the one integration detail that actually gates visibility: proxy.ts's
PUBLIC_PATHS currently sends every anonymous visitor of "/" straight
to /login before page.tsx's redirect-to-/recipes ever runs — the
marketing site is invisible until that changes, plus a startsWith-vs-
exact-match gotcha for adding "/" itself to that list. Also flags
missing sitemap.ts/robots.ts and legal-content review as open gaps.
2026-07-18 00:52:36 +02:00
Arnaud f2d423c8d7 docs: add detailed pricing recommendation to Stripe plan
§1c: Pro €4.99/mo (€39/yr), Family €8.99/mo (€69/yr), with reasoning —
Stripe's fixed €0.25 fee makes sub-€3 pricing fee-inefficient, prices
sized against the AI-call limits tierDefinitions already enforces
(500/2000 calls) rather than competitor-matching, Family priced below
2Ă— Pro so the household pitch actually holds up, and an explicit note
to adjust price (not AI-call limits) once real usage-cost data exists.
2026-07-18 00:46:27 +02:00
Arnaud ef18712ced docs: add France legal/tax notes to Stripe plan
Business structure (auto-entrepreneur recommended for solo launch),
2026 VAT thresholds for services (37,500€ / 41,250€), Stripe France
fee rates, Stripe Tax as an optional add-on, and an explicit open
question on cross-border EU VAT (OSS) that needs accountant
confirmation before billing non-French EU customers. Flagged as a
starting point for an accountant conversation, not a substitute.
2026-07-18 00:40:20 +02:00
Arnaud 4e9e23c080 docs: settle Family plan as Netflix-style, add promotions section to Stripe plan
Family confirmed as flat-price/capped-membership (not per-seat) — was
previously framed as a choice, now decided. Added §1b: Stripe's native
Coupons/Promotion Codes cover promotions entirely (one flag on the
Checkout Session), no custom discount logic needed; code creation
stays in Stripe's dashboard, an optional admin widget can list active
codes read-only.
2026-07-18 00:34:46 +02:00
Arnaud c8f4b50ef3 rename: "Team" billing tier to "Family"
All literal "team" tier-value references renamed to "family" across
API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum
value itself is renamed in place via ALTER TYPE ... RENAME VALUE
(migration 0044) rather than drizzle-kit's auto-generated
drop-and-recreate-the-enum migration, which would have failed against
any existing row still holding 'team' — RENAME VALUE preserves
existing data with no cast/backfill needed.

Also adds STRIPE_PLAN.md — a full Stripe billing integration plan
(Checkout+Portal, tier→Price mapping, admin billing dashboard, and a
multi-user Family-group design since Family is meant to cover several
accounts under one subscription, not one payer). Planning only, no
Stripe code yet.

v0.47.0
2026-07-18 00:25:51 +02:00
Arnaud 21a3622e6c fix: cramped ingredient/step rows on mobile in recipe editor
Both rows packed 4+ inline fields (grip handle, quantity, unit, name,
note for ingredients; step number, textarea, timer, delete for steps)
into a single flex row with no mobile-aware wrap plan — flex-wrap on
ingredients caused the delete button to float disconnected from its
row, and steps had no wrap at all, squeezing the textarea down to a
sliver. Both now split into two grouped rows below sm: breakpoint
(core fields, then secondary field + delete) and collapse back to one
row on larger screens.

Not visually verified in a browser — this sandbox has no DB/dev
server running (Docker unavailable), so this is typecheck+lint
verified only.

v0.46.2
2026-07-17 23:28:34 +02:00
Arnaud 458b5f2b07 fix: cooking assistant not reliably calling createRecipe tool
The system prompt only said "you can propose creating a recipe with
the tool" as a soft option — models were defaulting to just writing
the recipe out as prose in the reply instead of invoking the tool,
since that's the easier default behavior for a "write me a recipe"
request. Rewrote the instruction as a hard rule: full recipes must
go through createRecipe, plain-text ingredient lists/numbered steps
are disallowed in the reply for that case.

v0.46.1
2026-07-17 19:02:18 +02:00
Arnaud 9eecdbac3c feat: cooking assistant can propose adding items to a shopping list
Second tool alongside createRecipe, same safety shape: addToShoppingList's
execute only validates/echoes input, no DB write. The proposal card lets
the user pick an existing list (fetched lazily) or name a new one, then
confirms through the exact two-call flow AddToShoppingListButton already
uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new
persistence code path.

generateMealPlan intentionally not built as a tool: the existing
/api/v1/ai/meal-plan/generate endpoint generates and writes in one step
with a week/preferences input, not a specific plan payload, so it has no
"save this exact draft" entry point to confirm against without a larger
refactor. Documented as a known gap rather than forcing a weaker pattern.

v0.46.0
2026-07-17 18:37:07 +02:00
Arnaud 982d4e3264 feat: cooking assistant can propose creating a recipe
Gives the general chat a createRecipe tool (Vercel AI SDK, stepCountIs(3))
scoped so it can only ever produce a draft — the tool's execute is a pure
echo, no DB write. The route surfaces the tool call as proposedRecipe
alongside the normal text answer; the chat UI renders it as a card with
explicit Create/Discard buttons. Create POSTs to the existing
/api/v1/recipes endpoint — the same code path and tier/recipe-count limit
the manual editor already goes through — so there's exactly one place
that actually creates a recipe row, and nothing happens without the user
clicking Create.

Scoped to the general assistant only (not per-recipe chat), and to one
tool for now — addToShoppingList/generateMealPlan are follow-ups.

v0.45.0
2026-07-17 18:26:19 +02:00
Arnaud 7d290c5b1f fix: broken recipe pages from bad cross-table SQL in followers-visibility check
visibleToViewer() referenced userFollows as a Drizzle column proxy
inside db.query.recipes.findFirst's where — the relational query
builder rewrites embedded column refs to the outer query's own table
alias regardless of which table they actually belong to, same gotcha
already documented and worked around in recipes/page.tsx's search
filter. That silently produced SQL referencing a column that doesn't
exist on recipes, breaking every recipe page load since 0.41.0. Fixed
by using a literal user_follows identifier instead, matching the
established workaround.

v0.44.1
2026-07-17 18:09:59 +02:00
Arnaud c31ab8771a feat: add Team billing tier
Widens the tier enum from free/pro to free/pro/team and every
"free" | "pro" cast that assumed exactly two tiers (~30 call sites:
every AI route's withAiQuota/checkAndIncrementTierLimit call, admin
user/invite management, upload quota checks, OpenAPI schemas). Team
sits above Pro with genuinely unlimited recipes/public-recipes (the
-1 sentinel, which Pro doesn't actually use — Pro uses large finite
numbers instead) and a higher AI-call/storage cap. Seeded via
db:seed, editable afterward from Admin > Tiers.

role (user/moderator/admin — permissions) and tier (free/pro/team —
billing limits) stay separate concepts, as they already were; this
does not touch role-based permissions.

Requires migration 0043 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate` then `pnpm db:seed`.

v0.44.0
2026-07-17 17:34:13 +02:00
Arnaud c5a8f94b26 feat: multiple named conversations for the cooking assistant
The general assistant had exactly one conversation per user forever
(recipeId null on chat_messages) — no way to start fresh or organize
by topic. Adds an ai_conversations table (title, timestamps) and a
nullable conversationId FK on chat_messages; the assistant panel gets
a conversations menu to create/switch/rename/delete, auto-titling a
new conversation from its first question.

Per-recipe chat is untouched — each recipe already has one natural
thread, so multi-conversation support only applies to the homepage
assistant. Pre-existing general messages (no conversationId) aren't
migrated into the new model and won't appear in the conversation list.

Requires migration 0042 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate`.

v0.43.0
2026-07-17 17:18:49 +02:00
Arnaud 25e624f618 feat: regenerate recipe with modifications from the editor
Every existing AI entry point (generate, generate-from-idea, adapt,
variations) either creates a new recipe or a saved variation — none
let you revise the draft you're currently editing in place. Adds a
stateless /api/v1/ai/regenerate endpoint that takes the editor's
current in-progress fields (not a recipeId, so unsaved edits are
included) plus a free-text instruction, and returns a full revised
draft the editor merges into its own state. No DB write happens;
the user still saves normally.

v0.42.0
2026-07-17 17:11:37 +02:00
Arnaud 77c739960d feat: followers-only recipe visibility
Adds a fourth visibility tier alongside private/unlisted/public:
"followers" — visible to the author and anyone who follows them,
excluded from public search/explore/profile listings and from the
anonymous /r/[id] share route, included in the Following feed. The
in-app recipe detail gate and the public share route both check
follow status directly against the DB rather than the union type
alone, since neither previously had any per-viewer access logic for
a non-public/non-owner case.

Requires the generated migration (0041) to run against a live DB —
not applied in this sandbox (no Docker here); run `pnpm db:migrate`.

v0.41.0
2026-07-17 17:05:10 +02:00
Arnaud 23babd4ee9 feat: usage quota visualization in AI settings
No user-facing view of AI-call/recipe/storage usage existed — only an
admin-only per-user panel. Adds the same numbers, with progress bars
against the user's actual tier limits (read fresh from the DB, not
the cached session), to Settings > AI.

v0.40.0
2026-07-17 16:52:28 +02:00
Arnaud d8dc0aa465 fix: variations/adapt-recipe silent errors and no-op duplicates
AI adapt/variations flows had no catch on their fetch calls, so a
network failure surfaced as nothing happening rather than an error
toast. They also unconditionally persisted the AI's output even when
it was identical to the source recipe, and the adapt route never
recorded a recipeVariations row (only plain forks did) or enforced
the per-tier recipe-count limit other create paths already check.

v0.39.0
2026-07-17 16:48:38 +02:00
Arnaud 31ff7b9ac0 feat: fullscreen toggle for cooking assistant and recipe chat
Both chat panels were locked to a fixed 420px slide-over with no way
to get more room for longer conversations. Adds an expand/collapse
button in the header that grows the sheet to fill the viewport.

v0.38.0
2026-07-17 16:43:30 +02:00
Arnaud f0340859fa feat: split photo-import into vision recognition + text generation
The photo-import flow used one vision-capable model to both read the
photo and structure the full recipe (quantities, steps, timing) in a
single call. Split it into two: a vision model recognizes what's in
the picture, then a text model reconstructs the recipe from that
description — same pattern the pantry photo-scan already uses for
recognition, and lets structuring use whichever model is actually
configured for text generation. Still counts as one AI-quota unit.

v0.37.0
2026-07-17 16:12:39 +02:00
Arnaud 5763bd3318 feat: merge Explore and Activity Feed, unify recipe cards
Explore and Feed covered overlapping ground (recommendations, following,
trending) in two separate places with three different card designs.
Explore now hosts Discover/Following/For You tabs, and every recipe
card everywhere on the page matches the Recipes page's cover-photo
card instead of the old text-only search result.

v0.36.0
2026-07-17 16:06:17 +02:00
Arnaud 1577b8de01 feat: manual nutrition entry on recipe editor
Lets authors enter nutrition values by hand instead of relying only on
the AI estimate; the detail-page panel now shows whether data was
manually entered or AI-estimated and offers to switch between them.

v0.35.0
2026-07-14 15:55:42 +02:00
Arnaud bd3d8c88f0 feat: admin panel improvements (v0.34.0)
- Signups toggle was inverted (on = disabled) — flipped so on means
  open, matching how every other on/off toggle in the app reads.
- Moved AI provider keys and default-model settings from Site
  Settings to AI Config, so all AI setup lives in one place instead
  of split across two pages with a cross-link.
- Admin overview: added new users/recipes (7d), recipes cooked (7d),
  pending reports (linked, highlighted if > 0), storage used this
  month, active webhooks, and API keys issued — previously just 4
  lifetime totals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:40:32 +02:00
Arnaud b330583dd3 fix: detect imported recipe language for Translate button (v0.33.1)
URL import extracts content in the source page's own language (no
translation), but never recorded what that language was, so the
Translate button's "recipe.language !== my locale" check always saw
null and showed the button unconditionally. The extractor now returns
a detected ISO 639-1 language code alongside the recipe.

Photo import always writes in the caller's locale already (see its
langInstruction) but never recorded that either — now sets it
directly to locale since it's known, not detected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:21:15 +02:00
Arnaud af92b8cc71 feat: auto-classify AI recipes as dish/drink (v0.33.0)
generate, generate-from-idea, URL import, and photo import all now
have the AI set recipeType directly (defaulting to "dish" whenever
ambiguous, per instruction), with cookMins force-cleared for drinks
as a backstop in case the model doesn't follow the prompt guidance.
1-serving-by-default for unspecified drink quantities is left to the
model's own judgment of the request text, since there's no reliable
way to detect "was a serving count stated" without re-parsing intent.

Drink recipes get a distinct default icon (no cover photo case), and
Explore gains the same dish/drink Type filter My Recipes already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:17:33 +02:00
Arnaud 3042d289a0 security: fix full audit findings (v0.32.0)
Full list of the audit's confirmed findings and their fixes:

- Stored XSS via unescaped JSON-LD on the public recipe page
  (app/r/[id]/page.tsx) — escape < before injecting.
- CSP allowed unsafe-eval in production — now dev-only (Next prod
  never eval()s; only its HMR does).
- avatarUrl accepted any URL with no ownership check — now takes an
  avatarKey issued by avatar-presign, validated server-side, same
  pattern as recipe/review photos.
- No session revocation on password change/reset — both now revoke
  other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset).
- Rate-limit bypass via spoofable X-Forwarded-For — take the last
  (proxy-appended) hop instead of the first (client-supplied) one,
  matching the single-Traefik-hop topology.
- Webhook signing secrets stored plaintext — now AES-256-GCM
  encrypted like every other secret in this app, with a legacy-
  plaintext fallback for pre-existing rows (bare hex has no ":", our
  ciphertext format always does).
- Better Auth's own rate limiter defaulted to in-memory storage,
  ineffective across replicas — now backed by the same Redis as
  lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase
  explicit so session storage itself doesn't move as a side effect.
- Presigned upload URLs didn't bind the declared file size to the
  actual upload, letting a client under-declare size (and quota
  charge) then PUT an arbitrarily large object — switched to S3
  presigned POST with a signed content-length-range condition,
  enforced by the storage server itself.
- generateMetadata() on the recipe page skipped the visibility
  filter the page body uses, leaking a private recipe's title via
  <title> to any signed-in user with the id.
- Block/unblock had no rate limit, unlike follow/unfollow.
- AI quota was charged even when a user's own BYOK key was used
  (their own credentials/billing) — added an isByok flag through
  the config-resolution chain and skip the charge when set. Also
  wired BYOK into generate/generate-from-idea/translate/import-url,
  which never looked it up at all before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:05:05 +02:00
Arnaud 6f11dc07fd feat: recipe type (dish/drink) for better cocktail handling (v0.31.0)
Add recipes.recipeType enum (dish|drink, default dish). Recipe form
now shows a Type selector and hides food-only fields for drinks
(prep/cook time, difficulty, batch-cook toggle) instead of forcing
every cocktail through meal-oriented UI. Detail page skips the
meal/drink pairing suggestion buttons on drink recipes (pairing a
drink with a drink doesn't make sense). Recipes list gains a Type
filter facet alongside difficulty/batch-cook.

Scoped deliberately narrow per investigation: no ABV/glass-type/
garnish structured fields, no meal-plan schema changes, and the
existing AI drink-pairing-suggestion feature (ephemeral, never saved
as a recipe) is left as-is rather than wired into recipe creation —
those suggestions are wine/beer/spirit recommendations without
ingredients/steps, not really recipes to save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 14:16:43 +02:00
Arnaud 894784afda test: fix stale requireSession mocks after auth-widening pass (v0.30.1)
Four route test files still mocked requireSession after their routes
were converted to requireSessionOrApiKey in an earlier commit this
session — every test in them was failing at the auth call before
reaching the logic under test. Also adds the missing inArray export
to one file's @epicure/db mock, uncovered once its auth mock was
fixed and the test could actually run further.

No production code changed; two unrelated pre-existing failures
(lib/__tests__/tiers.test.ts, lib/__tests__/webhooks.test.ts) remain
and are confirmed present on main before this session's changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 14:15:12 +02:00
Arnaud affa6f5c3d feat: admin-configurable default AI providers/models (v0.30.0)
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.

Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:54:24 +02:00
Arnaud d8bc077f47 fix: photo import language + no-recipe-recognized handling (v0.29.0)
importFromPhoto() accepted a locale param but silently discarded it
(named _locale, never read) — now builds a language instruction the
same way generate-recipe.ts does for text generation.

The AI output schema had no way to signal "couldn't find a recipe in
this image" — generateObject would always fabricate a title/fields.
Added a `found` boolean the model sets to false in that case; the
route now returns 422 instead of creating a garbage recipe and
sending the user into the editor with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:45:52 +02:00
Arnaud 21c541e115 fix: unlisted-recipe share link wrongly warned it wouldn't work (v0.28.3)
The public /r/{id} route allows any visibility except "private", so
unlisted links always worked — the copy-link toast just checked for
visibility === "public" instead of !== "private", flagging unlisted
recipes as broken when they weren't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:40:30 +02:00
Arnaud b730dfac41 fix: recipe card title cramped by badge on Explore/Search (v0.28.2)
Title shared a flex row with the difficulty badge, shrinking its
available width — now full-width on its own line with badge below,
plus a md: grid-column step so cards don't stay stuck at 2 columns
until the lg breakpoint like the Recipes page grid does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:38:26 +02:00
Arnaud 6ad2731751 fix: cooking mode shortcuts panel overflow in French (v0.28.1)
Longer French labels pushed the absolutely-positioned help panel past
its content box with no height cap or wrap guard, overlapping the
surrounding cooking-mode chrome. Cap panel height with internal
scroll, stack columns on narrow viewports, and let rows wrap instead
of forcing horizontal overflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:33:44 +02:00
Arnaud 45adb023b9 feat: icon+tooltip buttons on meal-plan/shopping-list headers (v0.28.0)
Match the recipe detail page's action-row convention: Share, Generate
shopping list, Shopping lists link, Print, Export to calendar, and
Send to grocery delivery collapse to icon-only with a tooltip instead
of icon+text, freeing up header space. Week prev/next arrows moved
next to the week date instead of sitting at the end of the button row.

Note: verified via typecheck/lint only — this dev environment has no
authenticated session to visually confirm in-browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:29:22 +02:00
Arnaud 1237330a0c fix: translate dietary tags, recipe tags form, edit title, nutrition bar (v0.27.2)
These were hardcoded English strings ignoring app locale:
DietaryTagPicker, the recipe tag-input label/placeholder/help/aria-label,
the Edit recipe page heading, and WeeklyNutritionBar's labels/messages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:20:38 +02:00
Arnaud 8ef97da2c2 fix: correct openapi.ts schemas against actual route code (v0.27.1)
Audited every documented request/response schema against the real
Zod validators and NextResponse.json shapes across all route
families, then fixed drift: missing batch-cook/tags/photos fields on
recipes, wrong comment/rating shapes, meal-plan entry shape (day vs
dayOfWeek, nullable recipeId), shopping-list/pantry response fields,
a phantom GET /webhooks/{id} with no backing route, and numerous
missing 400/401/429 response codes.

Also fixes a real bug found during the audit: the webhook-redelivery
route returned {error: object} instead of the app-wide {error:
string} convention on validation failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:09:07 +02:00
Arnaud 3e71bd29a2 security: widen API-key auth to content/AI routes (v0.27.0)
Convert requireSession -> requireSessionOrApiKey across recipes,
collections, meal-plans, shopping-lists, pantry, feed, and ai/*
(52 routes) so API keys work end-to-end, not just for the handful of
endpoints that supported them before. Scope was explicitly confirmed
per-resource-family with the user before any file was touched.

Left session-cookie-only, deliberately: users/me*, ai-keys/*,
webhooks/*, conversations/*, notifications/*, push/subscribe, admin/*
— account/credential-adjacent surface that shouldn't widen without a
separate, explicit decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:20:26 +02:00
Arnaud 6c4fc93aab docs: document full API surface in OpenAPI spec (v0.26.0)
Went from 14 to ~100 documented endpoints in lib/openapi.ts — pantry,
meal-plans, shopping-lists, collections, users/social, ai/*, webhooks,
conversations, notifications, and admin routes now all appear in
/docs with accurate request/response schemas. No auth/route changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:54:57 +02:00
Arnaud 6096e49008 fix: API key "Last used" showed date only, not time
Also removes HANDOFF.md, SECURITY_AUDIT.md, TODO.md (stale docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 09:42:04 +02:00
Arnaud a08588cf85 feat: Gravatar opt-in (off by default), configurable in Settings
Previously every account without a custom avatar automatically got
its email MD5-hashed and sent to gravatar.com at signup, with no way
to turn it off. Adds users.useGravatar (default false): removed the
automatic signup-time lookup entirely, and "remove photo" now falls
back to the initials placeholder instead of silently re-deriving a
Gravatar URL. New toggle in Settings -> Profile, off by default,
description explains the MD5-hash-to-third-party tradeoff. Existing
accounts' current avatarUrl is left untouched either way — no
retroactive avatar changes for anyone already using one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 09:37:29 +02:00
Arnaud 38516bff63 fix: literal "{count}" shown in My Recipes search result count
t("resultPlural"/"resultSingular") never got the count param the
strings' own {count} placeholder needs, while the JSX separately
prepended a raw count value next to it — visible in every locale,
worse in French where "recette"/"recettes" made the duplication
obvious. Pass count into t() and drop the redundant prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 09:25:28 +02:00
Arnaud c16342a9b9 feat: ingredient/tag search on My Recipes, QR code on printed recipes
My Recipes search previously only matched title/description; now also
matches ingredient rawName and tags, mirroring the public search
improvement from earlier. Hit and fixed a real bug along the way:
embedding drizzle column proxies from a foreign table inside a raw
sql`` fragment passed to db.query.recipes.findMany's `where` gets
rewritten to the wrong table alias by the relational query builder,
producing broken SQL — worked fine in the plain query builder used by
/api/v1/search, but not here. Fixed by using literal SQL identifiers
for the ingredients subquery instead of column proxies.

Recipe print pages get the same QR-code treatment shopping lists got
earlier, gated on visibility !== "private" (both public and unlisted
resolve at /r/[id]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:09:52 +02:00
Arnaud acc93de708 feat: two-factor authentication (TOTP + backup codes)
Uses better-auth's built-in twoFactor plugin rather than hand-rolling
TOTP — adds the two_factors table and users.twoFactorEnabled, wires
the server/client plugins (allowPasswordless: true so OAuth-only
accounts aren't locked out of managing 2FA), and adds a custom rate
limit for the verify endpoints (5/min — far stricter than the default
100/10s, since a 6-digit code has a much smaller keyspace than a
password).

New Settings → Security section walks through enable (password
confirm -> QR + backup codes -> confirm a live code before it's
actually turned on, per the plugin's default flow) and disable. New
/verify-2fa page handles the post-password mid-login step, with a
backup-code fallback. Verified live end-to-end: enable, confirm,
forced re-auth on next sign-in, TOTP accepted, backup code accepted
(single-use), disable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:08:15 +02:00
Arnaud 4e5f45a7e5 feat: QR code on printed shopping lists
Added the qrcode package (server-side data-URL generation, no client
JS) rather than hand-rolling QR encoding. Only rendered when the list
is public — a private list's /s/ link 404s for anyone who isn't the
owner, so a QR code there would just be a dead scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:50:38 +02:00
Arnaud c21157fc93 feat: rating stars on recipe cards (Search, Explore)
New lib/recipe-ratings.ts helper batches avg score + review count for
a set of recipe ids in one grouped query, kept separate from the main
paginated/sorted search query rather than joining+grouping it directly
(would've forced grouping by every selected column). Wired into
/api/v1/search and the Explore page's trending/recent queries;
SearchResultCard renders the star only when a recipe has at least one
rating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:43:58 +02:00
Arnaud 95a4dccce8 feat: duplicate your own recipe
The fork endpoint already had no "isn't yours" check, so this is a
UI-only change: ForkRecipeButton takes a variant prop ("fork" for
others' recipes, "duplicate" for your own) that swaps icon/label/toast
copy, and now renders unconditionally instead of only for non-owners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:27:14 +02:00
Arnaud 6513bfa6ee feat: export meal plan to a calendar (.ics) file
Hand-rolled a minimal RFC 5545 writer (lib/ics.ts) rather than adding
a dependency for what's a handful of VEVENTs. Meal slots have no
stored time-of-day, so each mealType maps to a conventional wall-clock
time (dinner 7pm, etc.) written as floating local time, not pinned to
a timezone. Authenticated-only download for now, not a subscribe URL
— that would need a new unguessable-link mechanism on meal plans,
which don't have one today (unlike shopping lists' isPublic).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:22:33 +02:00
Arnaud 00ca8b9d68 feat: live updates on shared meal plans
Two separate components (the owner's own week view and collaborators'
shared view) hit two different API surfaces for the same underlying
entries, so neither ever saw the other's edits without a manual
reload. Both now poll a new lean GET on their respective entries
routes every 4s, merged with the same dirty-until-ref guard used for
shopping lists so a poll can't stomp an in-flight local edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:17:15 +02:00
Arnaud aa67868b96 feat: clear a meal-plan day or the whole week in one action
New bulk-delete route for the owner's own weekStart-scoped plan
(mirrors recipes/bulk's DELETE), plus ?ids= support added to the
existing shared-plan DELETE route (kept ?entryId= working
unchanged). UI: hover-reveal trash icon per day header, "Clear week"
button in the toolbar, one shared confirm dialog for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:07:12 +02:00
Arnaud a144052e46 feat: @mention autocomplete in comment composer
Typing "@" + 2+ characters now opens a dropdown of matching users
(reusing the existing people-search endpoint), with arrow-key/Enter/
Tab selection and click-to-select. Consolidated the previously
duplicated MENTION_REGEX (client display vs. server extraction) into
a single export from lib/mentions.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:00:10 +02:00
Arnaud 6efabeab8a feat: search matches ingredients/tags, add tag filter chips to Explore
Search previously only matched title/description. Now also matches
ingredient rawName (EXISTS subquery) and tags (unnest+ILIKE) via
sequential scan — fine at current scale, flagged for a trigram/GIN
index if it gets slow. Explore also shows the 12 most-used public
tags as clickable chips that AND-filter results via a new `tags`
query param (array containment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:55:17 +02:00
Arnaud f0397740d7 feat: let users change their auto-generated username in Settings
Usernames are auto-assigned at signup (shipped earlier this session)
but there was still no way to change one. Adds a Username field to
Settings, validated client- and server-side against the same
3-20-char lowercase/digits/underscore pattern used for generation,
with a 409 on collision (excluding the user's own current username).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:48:13 +02:00
Arnaud 2ee99ea463 fix: no user ever had a username, so people search found nobody
Root cause of "still cannot find users": username was optional in
better-auth's schema and no signup form or settings page ever set it,
but search/profile/follow all key on username, not user id. Every
account now gets a unique one auto-generated (from name/email) in the
user.create.before hook — covers email/password and OAuth signups.
Added a one-off db:backfill-usernames script for accounts created
before this fix and ran it against the current database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 17:26:12 +02:00
Arnaud 79cdb8cd04 feat: merge people search into the recipe search bar on Explore
People search existed but was buried in its own tab with no entry
point from anywhere else — easy to conclude it "doesn't work" since
nobody would find it. Now one query on Explore hits both
/api/v1/search and /api/v1/users/search and renders People/Recipes
sections together; the standalone people tab and its now-dead
PeopleSearch component are gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 15:38:29 +02:00
Arnaud 6dd48b5c25 fix: changelog page rendered literal **bold** markup instead of bold text
Entries are plain strings with occasional **bold**/`code` spans, never
full markdown — added a tiny inline parser rather than pulling in
react-markdown just for this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 13:26:38 +02:00
Arnaud 2beb23b360 feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:53:52 +02:00
Arnaud b1f4bba6dd feat: live updates on shared shopping lists
Polls item state every 4s and merges it into the list view, guarding
any item with an in-flight local edit so a poll landing mid-edit can't
stomp on it. No websocket/SSE infra exists in this app yet, so this
follows the same polling convention already used for notifications and
message threads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:27:48 +02:00
Arnaud 70eb565eec feat: bulk tag editing, bulk export, and move recipes between collections
Extends the existing bulk-select infra: recipes list gets tag add/remove
and multi-recipe Markdown export; collection pages get a select mode to
move recipes to another collection or remove them from the current one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 11:57:07 +02:00
Arnaud 4960dfc7c6 feat: rate limit public routes; backfill v0.9.5 changelog for mobile audit
Sign-in/sign-up now throttle at 3 req/10s/IP via better-auth's built-in
rate limiter; /r/ and /s/ share links throttle at 60 req/min/IP via
proxy.ts using the existing Redis-backed limiter (Next 16's Proxy always
runs Node.js, no Edge-runtime blocker after all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:32:37 +02:00
Arnaud 6818f10fc4 fix: mobile audit — Explore's AI-ideas input unusable, chat FAB overlap
Swept meal-plan, shopping-lists, pantry, nutrition, explore, feed,
collections, recipe detail/edit, and settings sub-pages at a real 390px
viewport. Two real bugs found (rest checked out clean — the meal-plan
calendar and settings tabs' horizontal scroll are intentional, not bugs):

- Explore's "Recipe ideas" input had no flex-basis and both buttons kept
  full text labels, squeezing the input down to ~2 visible characters
  on mobile. Input now flex-1, buttons icon-only below sm: (same pattern
  as the recipes-page Sort/Filter fix).
- Recipe detail page had no bottom clearance, so the fixed per-recipe
  chat button could sit directly on top of the private-notes Save
  button on short pages. Added pb-20, matching recipe-form.tsx's
  existing floating-save-bar clearance.

Verified both live at 390px: input now shows real placeholder text with
tappable icon buttons alongside it; Save button now clears the chat FAB
with real spacing.
2026-07-13 09:25:20 +02:00
Arnaud 7c63388618 chore: bump version to 0.9.4, backfill changelog with real commit times
Every entry got a time component pulled from actual git commit
timestamps (git log --date=format), not guessed — precise enough to
disambiguate the many same-day releases from this session. 0.2.0 and
0.1.0 kept date-only/"earlier" since no reliable timestamp exists for
them. Also adds the 0.9.4 entry for the previous action-buttons/
view-toggle mobile fix, which shipped without its own version bump.
2026-07-13 09:09:55 +02:00
Arnaud 36e707b934 fix: recipes page action buttons and view-toggle row not mobile-optimized
"New recipe" kept its text label below sm:, so on mobile (especially
French, where "Importer une URL"/"Nouvelle recette" run long) it wrapped
to its own second line — icon-only now below sm:, matching the Sort/Filter
treatment from the previous mobile-spacing fix.

The view-toggle + Select row used justify-between with an empty filler
div outside select mode, pushing the controls flush right and leaving a
large dead gap on the left — same width as the row above it, but visually
disconnected. Now left-aligned outside select mode (justify-between still
applies inside select mode, where there's real content on both sides).

Verified at a real 390px mobile viewport in both English and French —
Generate/Import/New now share one row, and the view-toggle row starts
flush left instead of floating on the right with empty space before it.
2026-07-13 09:03:15 +02:00
Arnaud 13b8de0e0c chore: bump version to 0.9.3, changelog entry 2026-07-13 08:38:46 +02:00
Arnaud 257908da7f fix: recipes page wasted too much vertical space above content on mobile
Search bar forced its own full-width row (w-full), pushing Sort/Filter
to a second row below it — on mobile that's title + action buttons +
search row + sort/filter row + toolbar row before any recipe appears.

Sort/Filter now go icon-only below sm: (text labels hidden, matching the
convention already used for compact-view badges), letting Search share
one row with both instead of forcing its own. Also tightened the title
size and inter-section spacing on mobile (unchanged at sm: and up).

Verified at a real 390px mobile viewport: first recipe card now clears
with room to spare, versus barely visible at the same viewport height
before. Desktop screenshot confirms zero visual change there.
2026-07-13 08:37:40 +02:00
Arnaud e117533f6f chore: bump version to 0.9.2, changelog entry 2026-07-13 07:14:38 +02:00
Arnaud d107bce92e fix: recipe-ideas banner buttons clashed with the violet gradient background
"Get ideas" used variant="secondary" (flat grey) and "Surprise me" was
variant="ghost" (no background at all) — both sat awkwardly on the
violet/indigo gradient section. Restyled to match: solid violet for the
primary action, violet-outlined for the secondary.

(Also answering the "what's the difference" question inline: "Get ideas"
generates from whatever you typed in the box; "Surprise me" ignores the
box and picks a random prompt from a fixed list, filling the box and
generating immediately.)
2026-07-13 07:13:39 +02:00
Arnaud 5d351f88b0 chore: bump version to 0.9.1, changelog entry 2026-07-13 06:59:00 +02:00
Arnaud b1995522ee fix: destructive confirm buttons had unreadable black-on-red text
--destructive-foreground was never defined in globals.css (light or dark
theme) — text-destructive-foreground resolved to nothing, so both the
clear-chat-history confirm button and recipe-form's discard-changes
confirm (same bg-destructive text-destructive-foreground pattern) fell
back to the default dark text color on a solid red background.

Added the missing variable (white, matching --primary-foreground's
light value — both themes use a saturated enough red that white stays
legible), mapped through @theme inline alongside the existing
--color-destructive.

Verified locally: screenshotted the clear-history confirm dialog,
button now shows white text on red.
2026-07-13 06:57:52 +02:00
Arnaud da48d0ecef chore: bump version to 0.9.0, changelog entry 2026-07-12 23:28:18 +02:00
Arnaud adaa837564 feat: clear chat history manually, auto-expire old history after 90 days
Manual: DELETE /api/v1/ai/chat-history (scoped by recipeId or scope=general,
matching GET's scoping) plus a trash-icon button + confirm dialog in both
chat panels' headers.

Automatic: new internal cron endpoint (chat-cleanup), same shared-secret
pattern as the existing leftover-reminders/weekly-digest crons, deletes
any chat_messages older than 90 days. Wired into cron/crontab (daily,
03:00 UTC) and the Dockerfile's cron stage.

Verified locally: cleared a real conversation through the actual UI and
confirmed it didn't come back on reopen (not just cleared client-side);
inserted a 100-day-old and a 5-day-old message directly, called the cron
endpoint with the real shared-secret check, confirmed only the old one
was deleted and the recent one survived; confirmed the endpoint 401s with
no/wrong secret.
2026-07-12 23:27:05 +02:00
Arnaud 9549684254 chore: bump version to 0.8.0, changelog entry 2026-07-12 22:38:59 +02:00
Arnaud 96ef5b8379 feat: persist AI chat history, add search across it
Both chat panels (per-recipe and the general cooking assistant) previously
kept messages in component state only — closing the sheet or reloading
the page lost everything. Both POST routes now persist question+answer
pairs to a new chat_messages table (recipeId null for the general
assistant), and both panels load their own history back on open.

New GET /api/v1/ai/chat-history (scoped by recipeId, or scope=general for
the homepage assistant, or neither to search across everything) backs a
new search control in both panels' header — debounced, shows matched
messages with date and (when searching across everything) which recipe
they belonged to.

Verified locally: asked a real question, closed and reopened the panel
and confirmed the conversation reloaded, then searched a keyword from
that conversation and confirmed both the question and answer surfaced
in the results dropdown.
2026-07-12 22:37:39 +02:00
Arnaud 0062220d8e feat: read-only API key scoping
New keys can be created as "Full access" (default, unchanged) or
"Read-only" — read-only keys can only make GET/HEAD/OPTIONS requests,
enforced once in requireSessionOrApiKey (lib/api-auth.ts) rather than in
every route, since a route has no way to know a request came from a
scoped key without that check. Existing keys default to full access —
no behavior change for anyone who doesn't opt in.

Also included in this migration: the chat_messages table for the
next commit (chat history persistence) — generated together since both
touched packages/db/src/schema/users.ts in the same pass.

Verified locally: created both a read-only and a full-access key,
confirmed GET succeeds and POST 403s on the read-only key, confirmed
POST still works on the full-access key, and checked the scope badges
render correctly in the real Settings → API Keys UI.
2026-07-12 22:37:14 +02:00
Arnaud b2f2274673 chore: bump version to 0.7.1, changelog entry 2026-07-12 21:36:32 +02:00
Arnaud adc88e63b0 fix: cooking chatbot button too high; chatbots leaked underlying model name
Moved the homepage cooking-assistant button from bottom-24 to bottom-16 —
still clears the recipe list's floating bulk-action bar, just not as far.

Both chat system prompts (cooking-chat and the per-recipe chat) now
explicitly identify as "Epicure" and are told never to name the underlying
model/provider if asked — previously they'd just answer honestly with
whatever the model calls itself.

Verified locally: screenshotted the new button position, and asked "what
model or AI are you" through the real UI — response identified as Epicure
with no model name.
2026-07-12 21:35:32 +02:00
Arnaud 8ec11e9463 chore: bump version to 0.7.0, changelog entry 2026-07-12 19:36:37 +02:00
Arnaud 2ffa05e4cf feat: general cooking-question chatbot on the recipes homepage
The existing recipe chat (RecipeChatPanel) is tightly scoped to one
recipe — recipeId is required and its system prompt embeds that recipe's
full ingredient/step context. Added a sibling, not a modification: a new
floating assistant on the recipes list page for questions that aren't
about any specific recipe (technique, substitutions, timing, food safety).

New /api/v1/ai/cooking-chat route (same quota/rate-limit/model-resolution
pattern as recipe-chat, minus the recipe lookup) and CookingAssistantPanel
component (same floating-button + Sheet UI). Positioned at bottom-24
rather than bottom-6 so it doesn't sit in the same band as the recipe
list's floating bulk-action bar during select mode.

Verified locally: asked a real cooking question through the actual UI,
got a correctly-formatted markdown answer with no recipe context leaking
in (confirmed the request payload only carries the question, no recipeId).
2026-07-12 19:35:00 +02:00
Arnaud 9a448ef34d fix: add missing backlink from webhooks documentation page to webhooks settings 2026-07-12 19:34:48 +02:00
Arnaud eed57cd10b fix: API keys always showed "never used" — middleware blocked them entirely
proxy.ts required a session cookie for every non-public /api/v1/* request,
rejecting with 401 before the request ever reached requireSessionOrApiKey
(lib/api-auth.ts) — the only place that actually verifies a Bearer API key
and updates lastUsedAt. Pure API-key clients never send a session cookie,
so every single API-key request was blocked at the middleware layer; the
lastUsedAt update code was correct but unreachable.

Now lets requests with an `Authorization: Bearer ek_...` header through to
the route, which still does the real verification (and 401s itself on an
invalid/unknown key) — middleware just stops pre-emptively rejecting valid
ones. Also added error logging to the fire-and-forget lastUsedAt update,
previously silent on failure.

Verified locally: hashed a raw key, confirmed it matched the stored hash
(so the lookup itself was never the problem), reproduced the 401 against
the unpatched middleware, then confirmed both the 200 response and
lastUsedAt populating correctly after the fix — visible in the real
Settings → API Keys UI.
2026-07-12 19:34:25 +02:00
Arnaud b69f5845c3 chore: bump version to 0.6.0, changelog entry 2026-07-12 19:09:10 +02:00
Arnaud e678e21ee8 fix: API Reference page (/docs) blank — CSP blocked the Scalar bundle
script-src had no allowance for cdn.jsdelivr.net, which is where the
Scalar API-reference viewer's JS is loaded from — the script itself
never ran. Added it to script-src/style-src/font-src/connect-src.

(User-confirmed before applying — widens what's allowed to load, so
wanted explicit sign-off rather than just doing it in response to a
console error.)
2026-07-12 19:07:43 +02:00
Arnaud 4d5269aced feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
2026-07-12 19:07:32 +02:00
Arnaud e78c959c49 fix: push-notification toggle in settings always showed as off
Never checked the browser's actual push subscription on mount — state
initialized to false unconditionally, so the button looked reset to
"Enable notifications" on every page load even when notifications were
genuinely still active. Now checks navigator.serviceWorker + getSubscription()
on mount and initializes from that.
2026-07-12 19:06:54 +02:00
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:05:20 +02:00
413 changed files with 165138 additions and 3099 deletions
+12
View File
@@ -57,6 +57,18 @@ SMTP_FROM=Epicure <noreply@epicure.app>
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Gitea (optional — in-app support tickets open an issue here if all three are set)
GITEA_URL=
GITEA_TOKEN=
GITEA_REPO=owner/repo
# Set as the webhook secret on the Gitea repo's webhook config (issues + issue_comment
# events) to sync issue close/reopen/comments back into Epicure support tickets.
GITEA_WEBHOOK_SECRET=
# USDA FoodData Central (optional, free — get a key at https://fdc.nal.usda.gov/api-key-signup)
# Improves recipe nutrition estimates with real per-ingredient data instead of AI-only guesses.
USDA_API_KEY=
# Stripe (optional — webhook stub only)
STRIPE_WEBHOOK_SECRET=
+1 -1
View File
@@ -44,4 +44,4 @@ coverage/
# Misc
.claude/
project_epicure.md
PLAN.md
plans/
+649 -6
View File
@@ -2,7 +2,650 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.5.0 — 2026-07-12
## 0.71.0 — 2026-07-22 10:00
### Added
- New "developer access" permission, admin-toggled per user (Admin → Users), gates webhooks, self-serve API keys, and BYOK AI provider keys — previously available to every logged-in user with no gating at all. Anyone who already had a webhook, API key, or BYOK key configured keeps access automatically.
## 0.70.0 — 2026-07-22 09:30
### Added
- Recipe nutrition estimation now looks up real per-ingredient data from USDA FoodData Central (for ingredients whose quantity/unit converts to grams — e.g. "200g flour", "2 tbsp oil") and only asks AI to estimate whatever's left (count-based units like "2 cloves", or ingredients with no USDA match). Configure `USDA_API_KEY` in Admin → Settings (free, no partnership needed) — unset, it falls back to the previous fully-AI-estimated behavior exactly as before.
## 0.69.0 — 2026-07-22 09:00
### Added
- Nutrition page gets a Trend tab alongside the daily Diary: a 7/30/90-day calorie chart plus daily protein/carbs/fat averages, computed from the same cooking history the diary already tracks.
## 0.68.0 — 2026-07-21 10:00
### Added
- Meal plans can now be shared with a read-only anonymous public link (Share → Public link), same idea as shopping lists' public link but view-only, plus a QR code you can screenshot or print to hand someone the week's plan.
## 0.67.0 — 2026-07-21 09:30
### Added
- Epicure now registers as an OS share target (Chromium/Android, once installed): share a recipe link from another app's share sheet and it opens straight into the URL-import flow, pre-filled and auto-imported. Safari has no equivalent API — Chromium/Android only, same restriction as the install-prompt banner.
## 0.66.0 — 2026-07-21 09:00
### Added
- Moderator role is now wired up: moderators get access to Admin → Reports (view/resolve) and Admin → Recipes (view + unpublish a public recipe as a takedown action). Every other admin section stays admin-only, enforced server-side on each page, not just hidden from the nav.
- Push notifications now actually display. The service worker had no `push` event listener at all, so incoming push messages never showed anything — fixed, plus a `notificationclick` handler that focuses an existing tab or opens one at the notification's target URL.
## 0.65.3 — 2026-07-21 01:20
### Fixed
- Removed a stale `app/favicon.ico` left over from the original create-next-app scaffold (the default Next.js/Vercel triangle logo) — Next's file-based routing served it as `/favicon.ico` regardless of the app icon configured via `icon.svg`, so some browsers showed the placeholder logo instead of Epicure's icon no matter what cache state you were in. The SVG icon is now the only favicon source.
## 0.65.2 — 2026-07-21 01:10
### Fixed
- The "Save offline" button on the recipe page was a labeled outline button, inconsistent with the icon-only ghost + tooltip style every other recipe action button (print, share, export) uses. Now matches.
## 0.65.1 — 2026-07-21 01:00
### Added
- The install banner now shows an iOS Safari-specific instructional variant ("Tap Share, then Add to Home Screen") instead of silently doing nothing — Safari never fires the event the regular Install button depends on, so it had no install guidance at all before this.
## 0.65.0 — 2026-07-21 00:45
### Added
- Save recipes for offline use: a "Save offline" button on any recipe pins it in the service worker cache so it opens with no connection. The offline fallback page now lists your saved (and recently visited) recipes instead of being a dead end.
- Marking a batch-cook dish as cooked while offline no longer just fails — it's queued and synced automatically once you're back online (via Background Sync where supported, otherwise as soon as the app detects a connection).
### Fixed
- The service worker's network-first pages never actually populated its cache on success, so the offline page's claim that "recently visited recipes are available" wasn't true. It does now.
## 0.64.0 — 2026-07-21 00:30
### Fixed
- The web app manifest was never actually linked into the page (pointed at `/manifest.json`, but the real route is `/manifest.webmanifest`) and its icons referenced PNGs that don't exist in the repo — both silently broke install eligibility on Chrome/Android. Manifest now links correctly and icons point at the existing SVGs.
### Added
- Added apple-mobile-web-app meta tags and a `viewport-fit=cover` safe-area setting for iOS home-screen installs.
- Added an install-app banner (Chrome/Edge/Android only, via the standard `beforeinstallprompt` event) so the PWA install prompt is a first-class, dismissible UI element instead of relying on the browser's own icon.
## 0.63.0 — 2026-07-21 00:15
### Added
- Support tickets now sync two-way with their linked Gitea issue: closing/reopening the issue in Gitea updates the ticket's status, and comments on either side (Epicure or Gitea) show up in both places. Configure a webhook secret (`GITEA_WEBHOOK_SECRET`) on the Gitea repo's webhook, subscribed to Issues and Issue Comment events, pointed at `/api/webhooks/gitea`.
## 0.62.0 — 2026-07-20 23:45
### Added
- Settings → Features: new Chatbots toggle — hides both the recipe cooking-chat panel and the recipe-list cooking assistant when off.
### Fixed
- Feature toggles (Settings → Features) were only respected by the mobile hamburger menu — the desktop horizontal nav still showed every item regardless of your saved preferences. Both now read the same visibility list.
## 0.61.0 — 2026-07-20 23:15
### Added
- Settings → Notifications now has an email toggle for every category alongside the existing push toggle, plus a Weekly Digest toggle to opt out of the weekly email summary entirely.
- Admin → Webhooks: site-wide ops webhooks (new signups, support tickets, reports filed) — separate from the existing per-user webhooks, for Slack/Discord-style ops alerting.
- Settings → Features: hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from your own navigation. Cosmetic only — hidden pages stay reachable by direct link.
## 0.60.0 — 2026-07-20 22:15
### Security
- Closed a race in recipe/storage tier-limit checks — two concurrent requests near the cap could both pass a live count check and both write, exceeding the lifetime limit. Now locked per-user inside the same transaction as the write.
- Fixed four AI recipe-creation routes (photo import, idea generation, batch-cook, translate-to-new-draft) that had no recipe-limit check at all, letting any tier create unlimited recipes through them.
- Added missing per-user rate limits to 5 AI endpoints (adapt, drinks, pairings, translate, variations).
- Search page now checks for a session server-side, matching every other page (defense-in-depth — the underlying API was already public by design and the edge proxy already blocked unauthenticated access).
- Upgraded drizzle-orm to fix a SQL-identifier-escaping vulnerability, and overrode two transitive dependencies (esbuild, postcss) with known CVEs — `pnpm audit` is now clean.
### Fixed
- Admin settings endpoint now uses the shared admin-auth check instead of a local duplicate.
- Documented two admin support-ticket endpoints that were missing from the OpenAPI spec.
## 0.59.0 — 2026-07-20 21:15
### Added
- Admin: chatbot now has its own default-model setting, separate from generic text generation — the general assistant and per-recipe Q&A chat can be pointed at a different model than recipe generation.
- Admin: tool calling (the chatbot's inline recipe/shopping-list drafting) can be toggled off — useful for a local/Ollama model that doesn't reliably support it, so the bot falls back to plain text answers instead.
### Fixed
- Simplified the admin AI Configuration page — it showed the same provider keys and routing settings twice (once read-only, once as an edit form). Merged into one section.
## 0.58.0 — 2026-07-20 21:00
### Fixed
- Recipe count and storage usage were incorrectly reset every month, same as AI calls — a user near their recipe/storage cap got fresh room on the 1st even though nothing was deleted. Only AI calls are meant to be monthly; recipes and storage are now lifetime totals derived live from actual data (recipes, photos, avatar), so deleting something is itself what frees up quota.
- Removing your avatar from Settings silently did nothing — the client sent a field the API didn't recognize, so the update was a no-op.
## 0.57.2 — 2026-07-20 20:15
### Fixed
- The previous chatbot fix only caught the model spelling a recipe out as prose. The more common case: a short reply that claims a draft exists ("here's a draft, check below") without ever calling the tool, so nothing renders. Now also detects recipe-creation intent from the user's own message and forces the tool call in that case too.
## 0.57.1 — 2026-07-20 20:00
### Fixed
- Chatbot sometimes answered "create a recipe" requests with the recipe spelled out as prose instead of the actual draft card (weaker/free-tier models occasionally ignore the tool-calling instruction). Now detects that shape and forces one retry with the tool call required, so the draft card still appears.
## 0.57.0 — 2026-07-20 09:20
### Added
- Cooking assistant, fullscreen on desktop, now has a persistent conversation sidebar on the left (like ChatGPT/Claude) instead of a dropdown menu. Mobile and the normal drawer view keep the dropdown.
## 0.56.0 — 2026-07-20 09:10
### Added
- Step timers in the recipe editor now take a unit (seconds/minutes/hours) instead of forcing everyone to do the math into seconds. Editing an existing recipe shows the timer in whichever unit divides evenly into what's stored.
### Fixed
- Ingredient list on the recipe page didn't line up — the quantity column only had a minimum width, so a longer value pushed that row's ingredient name further right than the others. Switched to a shared grid column so every row's name starts at the same spot.
## 0.55.3 — 2026-07-19 19:00
### Fixed
- Admin Insights page's actual crash, found from server logs: the page (a server component) was passing plain formatter functions as props into its chart components (client components) — React can't serialize functions across that boundary. Replaced with a plain string prop ("day" vs "month") formatted inside the client component instead.
## 0.55.2 — 2026-07-19 18:20
### Fixed
- Admin Insights page could 500 the whole panel if any one of its six aggregate queries failed — replaced a non-standard positional GROUP BY with the expression-repeat pattern used everywhere else in the codebase, and made the six queries independent (Promise.allSettled) so one failing degrades that one chart instead of crashing the page.
## 0.55.1 — 2026-07-19 18:05
### Fixed
- Chatbot's rename/delete buttons for a conversation were hover-only, so they never appeared on mobile (no hover state on touch). Now always visible.
## 0.55.0 — 2026-07-19 17:50
### Added
- New Admin > Insights page — signups and recipes-created over the last 30 days, users by tier, recipes by visibility, monthly AI usage, and support tickets by status. Charts are hand-built (hover tooltips, table-view fallback, colorblind-safe palette), no new dependency.
## 0.54.1 — 2026-07-19 17:25
### Added
- Public collections are now viewable by logged-out visitors at /c/{id} (no account needed), same as public recipes at /r/{id} — the collection's QR code/print link and the new "view publicly" icon on the collection page both point here. Followers-only collections check the visitor's follow status the same way followers-only recipes do.
## 0.54.0 — 2026-07-19 17:05
### Added
- Collections now have the same visibility options as recipes — private, followers-only, unlisted, or public — replacing the old public/private toggle. Fork, favorite, and viewing a collection all respect the new followers-only option the same way recipes do.
- Collection PDF exports now include a QR code linking back to the collection, same as recipe PDFs.
## 0.53.1 — 2026-07-19 16:10
### Fixed
- Drag-reorder in a collection now animates live as you drag instead of only updating on drop, and the grip handle is now visible instead of an invisible hover target.
- "Edit" was untranslated everywhere it's used (missing from both languages, not just French).
- Recipes generated via "Generate meal", weekly meal plan, or "Adapt recipe" never had their language recorded — this made the Translate button appear even though the recipe was already in your language. Fixed at the source for all three.
- The Translate dialog itself was hardcoded in English regardless of app language — now fully translated.
### Added
- Recipe tags now show on the recipe detail page.
- Collection header actions are now icon-only with tooltips, matching the recipe page.
- Searching collections now also matches recipes inside them.
- Explore links to "Discover collections" next to its tabs.
## 0.53.0 — 2026-07-19 15:20
### Added
- Collections got a big upgrade: drag-and-drop reorder recipes, search collections and search within a collection, edit name/description/tags/private notes, delete with a choice to also delete the recipes (only ones you own), and tags/labels on collections themselves.
### Fixed
- Collection cards showed the wrong recipe count (capped at 1) — now a real count. Cards also got a visual refresh (photo collage preview) and collection detail pages now use the same recipe card as the main Recipes page.
## 0.52.1 — 2026-07-19 14:10
### Fixed
- Steps with no timer sometimes showed a stray "0" on the recipe page, cook mode, and print views — a step whose timer happened to be stored as 0 (rather than empty) hit a JS truthiness quirk where `0 && ...` renders the literal 0 instead of nothing.
## 0.52.0 — 2026-07-19 13:55
### Added
- Generate a complete meal from a collection — pick a theme and 2+ courses (starter, main, side, dessert, drink), AI generates one coherent, matching recipe per course and adds them all to that collection in one go.
## 0.51.7 — 2026-07-19 13:35
### Fixed
- Account dropdown menu: "View profile" and "Settings" now have icons like every other item in the menu, and there's a new separator grouping the account/support links apart from the theme switcher.
## 0.51.6 — 2026-07-19 13:20
### Fixed
- Image attachments on support tickets now embed inline in the Gitea issue (rendered preview) instead of just a plain link. Also fixed the admin "Create Gitea issue" retry action, which was dropping attachments entirely and only ever sent the ticket description.
## 0.51.5 — 2026-07-19 13:05
### Fixed
- Gitea issue creation was sending label names ("bug", "enhancement") where Gitea's API expects numeric label IDs, failing with a 422 on every attempt. Now looks up the repo's actual label IDs by name first — issues still get created (just unlabeled) if the repo doesn't have matching labels yet.
## 0.51.4 — 2026-07-19 12:50
### Fixed
- Gitea issue creation failures now log the real network-level cause server-side (DNS/connection/timeout, not just "fetch failed") and surface a more specific message in the admin support view's tooltip.
## 0.51.3 — 2026-07-19 12:35
### Fixed
- Admin Settings' Gitea Integration section now spells out exactly what each field expects and what token scope to grant, instead of a one-line hint.
## 0.51.2 — 2026-07-19 12:20
### Fixed
- Recipe cover placeholder icons now use a solid color instead of a transparent one — the transparency darkened wherever an icon's own strokes overlapped, showing up as an uneven blotch on icons like the chef's hat or croissant.
## 0.51.1 — 2026-07-19 12:05
### Added
- More icon choices for custom recipe covers — dish icons went from 16 to 29 (banana, grape, citrus, hamburger, popcorn, bean, vegan, utensils, leaf, shrimp, cooking pot, candy, nut), drinks got milk.
## 0.51.0 — 2026-07-19 11:40
### Added
- Pick your own recipe cover color + icon in the recipe editor (below Photos) — used whenever that recipe has no photo. Also toned down the auto-generated placeholder colors from the previous release, which were too saturated next to real cover photos.
## 0.50.2 — 2026-07-19 10:15
### Fixed
- Recipes without a cover photo now get a varied gradient + food/drink icon instead of the same flat emoji everywhere — deterministic per recipe, so it stays stable across reloads. Applied to the Recipes grid/list, Explore/collections cards, and public profile pages.
## 0.50.1 — 2026-07-19 09:30
### Fixed
- Password sign-in for 2FA-enabled accounts could silently bounce back to the login page with no error shown — the app was pushing straight to /recipes on any non-error sign-in response, racing the SDK's own redirect to the 2FA code screen and sometimes losing. Now waits for that case explicitly.
## 0.50.0 — 2026-07-18 14:20
### Added
- Per-tier feature toggles, managed from Admin > Tier Limits. Recipe variations, drink pairing, and meal pairing can each be disabled for a tier (e.g. Free) — the buttons stay visible but show an upgrade prompt instead of running, and the API rejects the call server-side either way.
## 0.49.1 — 2026-07-18 13:10
### Added
- Support form now accepts attachments — screenshots or files (images, PDF, text, JSON, zip; up to 5 files, 10MB each). They're linked in the confirmation email's Gitea issue and shown as thumbnails on your ticket history and in the admin support view.
## 0.49.0 — 2026-07-18 12:30
### Added
- New Support page — report a bug, share a suggestion, or ask a question right from the app. Submissions email you a confirmation and (if an admin has configured a Gitea repo in Settings) automatically open a tracked issue. Admins get a Support section in the admin panel to triage tickets and retry issue creation.
## 0.48.2 — 2026-07-18 11:15
### Fixed
- Marketing site's language switcher no longer uses emoji flags (inconsistent rendering across OS/browsers) — now inline SVG flags in a proper dropdown menu. Home page's feature grid also got a 9th highlight (personalized recommendations) so the last row isn't left half-empty.
## 0.48.1 — 2026-07-18 11:00
### Added
- Marketing site: language switcher (flag toggle, EN/FR) and a dark-mode toggle in the header — both work for logged-out visitors, no account needed. Features/Home now also cover cook mode, recipe variations, personalized recommendations, and ingredient substitution. Signup CTAs now say "Signups closed" instead of "Get started free" when signups are disabled.
## 0.48.0 — 2026-07-18 09:45
### Added
- Public marketing pages — Home, Features, About, Privacy, Terms, Contact — visible to logged-out visitors at the root domain, plus a sitemap and robots.txt. Logged-in users hitting "/" still land straight in the app, unchanged.
Note: Privacy and Terms are structural drafts, not lawyer-reviewed (see `VITRINE_PLAN.md` §4). Pricing page intentionally not included yet (waiting on Stripe Checkout per `STRIPE_PLAN.md`).
## 0.47.0 — 2026-07-18 00:30
Renamed the "Team" billing tier to "Family" (free/pro/family) — same limits, same admin editing, just the name. Existing Team users keep their tier/limits unaffected; the DB enum value itself was renamed in place (no data migration needed).
## 0.46.2 — 2026-07-17 19:20
### Fixed
- Recipe editor's ingredient and step rows were cramped on mobile — 4 inline fields plus a timer/delete button squeezed into one row with no real wrap plan. Both now stack sanely on narrow screens (name/quantity/unit on one line, note/timer + delete on the next) and stay a single row on larger screens.
## 0.46.1 — 2026-07-17 19:05
### Fixed
- Asking the cooking assistant to create a recipe would often just get you the recipe typed out in the chat instead of the createRecipe draft card — the instruction telling the model to use the tool was a soft suggestion, not a hard rule. Made it explicit: full recipes must go through the tool, never as plain-text ingredients/steps in the reply.
## 0.46.0 — 2026-07-17 18:00
### Added
- The cooking assistant can now also propose adding items to a shopping list ("add that to my shopping list") — pick an existing list or name a new one, then confirm. Same no-action-without-confirmation pattern as recipe creation, going through the same endpoints the manual "Add to shopping list" button already uses.
Note: generateMealPlan is still not built as a chat tool — the existing meal-plan-generation endpoint generates and saves in one step with its own input shape (week/preferences, not a specific plan to save), so it doesn't fit the draft-then-confirm pattern the other two tools use without a larger change to that endpoint.
## 0.45.0 — 2026-07-17 17:30
### Added
- The cooking assistant can now propose creating a recipe when you explicitly ask it to ("make me a recipe for X", "write that down") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.
Note: this is the first step of chatbot-driven actions — only recipe creation for now. Adding to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.
## 0.44.1 — 2026-07-17 17:00
### Fixed
- Every recipe page was broken (server error) since 0.41.0 — the new followers-visibility check referenced the user_follows table incorrectly inside a query the ORM rewrites in a way that silently produces invalid SQL for cross-table references. Fixed by using a literal table reference instead.
## 0.44.0 — 2026-07-17 16:15
### Added
- New "Team" billing tier, above Pro — higher AI-call, recipe, and storage limits, editable from Admin > Tiers like the existing tiers. (Moderator/admin remain separate account roles, unrelated to billing tier — unchanged by this.)
## 0.43.0 — 2026-07-17 15:45
### Added
- The cooking assistant now supports multiple named conversations — start a new one, rename, or delete from the conversations menu in the chat header. Auto-titled from your first question; older single-thread history stays as-is but isn't shown in the new list.
## 0.42.0 — 2026-07-17 15:15
### Added
- Recipe editor now has "Regenerate with AI" — describe what to change (e.g. "make it spicier", "swap in chicken") and the draft you're editing updates in place, no new recipe created. You still need to save.
## 0.41.0 — 2026-07-17 14:45
### Added
- New recipe visibility option: "Followers only" — visible to people who follow you, hidden from public browsing, search, and anonymous share links. Available in the recipe editor, bulk visibility actions, and the Recipes filter.
Note: public profile pages and search results don't yet surface a viewer's followers-only recipes from that author — those remain reachable only via the recipe's direct link or the Following feed.
## 0.40.0 — 2026-07-17 14:00
### Added
- AI Settings now shows a usage panel — AI calls, recipes created, and storage used this month against your tier's limits, with progress bars.
## 0.39.0 — 2026-07-17 13:30
### Fixed
- Adapting/varying a recipe with AI could fail silently (no error shown) on a network hiccup, and could save a byte-for-byte duplicate recipe when the AI's output didn't actually differ from the original — both flows now surface real errors and skip saving when nothing changed. AI-adapted recipes also now show up in recipe history (Forked from…) like forks always have.
## 0.38.0 — 2026-07-17 13:00
### Added
- The cooking assistant and per-recipe chat can now expand to fullscreen — a new toggle in the chat header.
## 0.37.0 — 2026-07-17 12:30
### Fixed
- Generating a recipe from a photo now uses two separate AI steps — a vision model recognizes what's in the picture, then a text model writes the recipe from that — instead of one model doing both, so each step can use the model actually suited to it.
## 0.36.0 — 2026-07-17 12:00
### Added
- Explore and the Activity Feed are now one page — Explore has Discover/Following/For You tabs, so browsing and following recipes no longer live in two separate places.
- Explore's recipe cards (search results, trending, recently added, following, for you) now use the same cover-photo card as the Recipes page, instead of the old text-only search card.
## 0.35.0 — 2026-07-14 18:20
### Added
- Recipe editor now lets you enter nutrition values by hand (calories, protein, carbs, fat, fiber, sodium per serving) instead of relying only on the AI estimate — the nutrition panel shows a "Manually entered" badge and swaps to "Estimate with AI instead" when manual data is present.
## 0.34.0 — 2026-07-14 18:05
### Added
- Admin overview now shows growth (new users/recipes this week), engagement (recipes cooked this week), a moderation queue count, storage used, and platform-integration counts (webhooks, API keys) — not just totals.
### Fixed
- The admin Signups toggle was inverted — switching it "on" used to disable signups. It now reads naturally: on means open.
- AI provider keys and default-model settings have moved from Site Settings to AI Config, alongside the rest of the AI setup.
## 0.33.1 — 2026-07-14 17:45
### Fixed
- URL-imported recipes never recorded what language they were actually written in, so the Translate button couldn't tell whether translation was needed — the importer now detects the source page's language, and photo import records the language it always writes in.
## 0.33.0 — 2026-07-14 17:35
### Added
- **AI recipe generation/import now auto-detects drinks vs dishes** — generate, generate-from-idea, URL import, and photo import all classify the result and skip cook time for drinks, defaulting to dish whenever it's ambiguous.
- **Drink recipes get their own default icon** (🍹 instead of 🍽️) wherever a recipe has no cover photo.
- **Explore now has a Type filter** (dish/drink), alongside My Recipes' existing one.
## 0.32.0 — 2026-07-14 17:10
### Security
- Fixed a stored-XSS hole in public recipe pages' embedded structured data, and tightened the production Content-Security-Policy so script injection like it can't execute even if a similar bug slips in again.
- Avatar photos now require proof you actually own the upload — previously any URL was accepted, including another user's uploaded photo key.
- Changing your password now signs out every other active session, and so does resetting a forgotten password.
- Fixed a rate-limit bypass on public share-link throttling caused by trusting a spoofable header.
- Webhook signing secrets are now encrypted at rest, matching how API keys and other secrets are already stored.
- Login/2FA/password-reset rate limiting now shares Redis instead of quietly resetting per server instance.
- Photo/avatar uploads are now size-capped by the storage server itself, not just a number the client could lie about.
- A private recipe's title could leak via its page's browser-tab title to any signed-in user who had the link — fixed to respect the same visibility rules as the page itself.
- Blocking/unblocking a user is now rate-limited, matching follow/unfollow.
- AI calls made with your own API key (Settings → AI Keys) no longer count against your monthly AI-call limit.
## 0.31.0 — 2026-07-14 16:35
### Added
- **Recipes can now be marked as a Drink/cocktail** instead of a dish — the recipe form hides irrelevant food-only fields (prep/cook time, difficulty, batch cooking) for drinks, and you can filter your recipe list by type. Drink recipes also no longer show the food/drink pairing suggestion buttons on their own page.
## 0.30.1 — 2026-07-14 16:20
Internal: fixed test mocks left stale by the earlier API-key auth-widening pass (recipes/meal-plans/shopping-lists route tests) — no user-facing change.
## 0.30.0 — 2026-07-14 16:05
### Added
- **Admins can now set the site-wide default AI provider/model** per use case (text generation, photo/vision, meal-plan generation) from Settings → Admin, applied whenever a user hasn't picked their own model preference or brought their own API key.
## 0.29.0 — 2026-07-14 15:20
### Added
- **Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features.
### Fixed
- Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead.
## 0.28.3 — 2026-07-14 14:55
### Fixed
- Copying an unlisted recipe's share link wrongly warned "this link won't work until the recipe is Public" — unlisted links work fine (that's the point of unlisted); the warning now only shows for actually-private recipes.
## 0.28.2 — 2026-07-14 14:45
### Fixed
- Recipe cards on Explore and Search cramped the title next to the difficulty badge, cutting titles off early — the title now gets its own full-width line, and the grid gains a medium-viewport column step matching the Recipes page.
## 0.28.1 — 2026-07-14 14:25
### Fixed
- Cooking mode's keyboard/voice shortcuts panel could overflow and overlap the surrounding screen in French (longer translated labels) — now scrolls internally and wraps instead of overflowing.
## 0.28.0 — 2026-07-14 14:05
### Added
- **Meal plan and shopping list header buttons now match the recipe page's icon+tooltip style** — Share, Generate shopping list, Shopping lists, Print, Export to calendar, Send to grocery delivery all collapsed to icon-only with a tooltip, instead of crowding the header with text labels.
- **Week-switch arrows on the meal plan now sit right next to the week date**, instead of at the far end of the button row.
## 0.27.2 — 2026-07-14 13:20
### Fixed
- Dietary tag chips, the recipe tag-adding form, the "Edit recipe" title, and the meal-plan nutrition-goals bar were all hardcoded in English regardless of app language — now translated.
## 0.27.1 — 2026-07-14 12:05
### Fixed
- API Reference (`/docs`) schemas corrected against the actual code across the whole API — recipes (batch-cook fields, tags, photos were undocumented), comments, ratings, meal plans, shopping lists, pantry, feed, and several missing error responses. A webhook-redelivery route also returned a malformed error body — now matches the rest of the API's `{ error: string }` convention.
## 0.27.0 — 2026-07-14 11:15
### Added
- **API keys now work on recipes, collections, meal plans, shopping lists, pantry, feed, and AI generation** — previously most of the API only accepted a session cookie, so keys couldn't actually be used for most endpoints. Account/credential-adjacent routes (profile, AI provider keys, webhooks, notifications, messages, admin) intentionally stay session-only.
## 0.26.0 — 2026-07-14 10:20
### Added
- **API Reference (`/docs`) now covers the full API**: went from 14 documented endpoints to all ~100 — pantry, meal plans, shopping lists, collections, users/social, AI generation, webhooks, conversations, notifications, and admin routes are now all documented with request/response schemas.
## 0.25.1 — 2026-07-14 09:40
### Fixed
- API key "Last used" only showed a date — now shows date and time.
## 0.25.0 — 2026-07-14 09:36
### Added
- **Gravatar is now opt-in, off by default** — previously every account without a custom photo automatically had its email hashed and sent to gravatar.com. Turn it on in Settings → Profile if you want it.
## 0.24.1 — 2026-07-14 09:24
### Fixed
- My Recipes search result count showed a literal "{count}" instead of the number, in every locale.
## 0.24.0 — 2026-07-14 08:08
### Added
- **My Recipes search now matches ingredients and tags too**, not just title/description — same improvement shipped for Explore/Search earlier.
- **QR code on printed recipes**: a public or unlisted recipe's printout now includes a scannable code linking back to the online version.
## 0.23.0 — 2026-07-13 23:06
### Added
- **Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.
## 0.22.0 — 2026-07-13 22:49
### Added
- **QR code on printed shopping lists**: a public list's printout now includes a scannable code linking back to the live version — pairs with public-editable links, so someone at the store can scan and check items off in real time.
## 0.21.0 — 2026-07-13 22:34
### Added
- **Rating stars on recipe cards** — average rating and review count now show on Search and Explore results, not just the recipe detail page.
## 0.20.0 — 2026-07-13 22:26
### Added
- **Duplicate your own recipe** — same one-click copy used for forking others' recipes, now also on your own, for making variations without touching version history.
## 0.19.0 — 2026-07-13 22:21
### Added
- **Export your meal plan to a calendar file** — download a week's meals as a .ics file to import into Google/Apple/Outlook calendar.
## 0.18.0 — 2026-07-13 22:16
### Added
- **Live updates on shared meal plans**: entries added, removed, or cleared by a collaborator now appear automatically, both on the owner's own week view and everyone else's shared view.
## 0.17.0 — 2026-07-13 22:06
### Added
- **Clear a day or the whole meal-plan week** in one click instead of removing each meal individually.
## 0.16.0 — 2026-07-13 21:59
### Added
- **@mention autocomplete in comments**: type "@" and a name to search and insert a mention, instead of typing the exact username blind.
## 0.15.0 — 2026-07-13 21:54
### Added
- **Search now matches ingredients and tags**, not just title/description — "zaatar" now finds a recipe that uses it even if the title doesn't mention it.
- **Tag filter chips on Explore**: click a popular tag to narrow results instead of typing it.
## 0.14.0 — 2026-07-13 21:47
### Added
- **Change your username** in Settings — previously auto-assigned at signup with no way to change it.
## 0.13.1 — 2026-07-13 17:25
### Fixed
- People search never found anyone, for anyone — there was no signup or settings flow that ever set a username, and search (along with profiles and follow) requires one. Every account now gets one automatically on signup; existing accounts were backfilled.
## 0.13.0 — 2026-07-13 15:37
### Added
- **Explore search now finds people and recipes together**: one search bar, one query — no more separate, hard-to-find people tab.
## 0.12.1 — 2026-07-13 13:25
### Fixed
- Changelog entries showed literal `**bold**` markup instead of rendering it.
## 0.12.0 — 2026-07-13 12:52
### Added
- **Public shopping list links can now allow editing**: turn on "Allow editing" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.
## 0.11.0 — 2026-07-13 12:27
### Added
- **Live updates on shared shopping lists**: items checked, added, recategorized, or reordered by a collaborator now appear automatically, without a manual refresh.
## 0.10.0 — 2026-07-13 11:55
### Added
- **Bulk tag editing**: add or remove tags across several selected recipes at once.
- **Bulk export**: export several selected recipes to a single Markdown file.
- **Move recipes between collections**: select recipes inside a collection to move them to another collection or remove them from this one.
## 0.9.6 — 2026-07-13 09:31
### Added
- **Rate limiting on public routes**: sign-in/sign-up now throttle after 3 attempts per 10s per IP; public recipe/shopping-list share links (`/r/`, `/s/`) throttle after 60 requests/min per IP.
## 0.9.5 — 2026-07-13 09:20
### Fixed
- Explore page's "Recipe ideas" input got squeezed to a couple of visible characters on mobile — buttons now stay icon-only below `sm:`.
- The per-recipe chat button could overlap the private-notes save button on short recipe pages.
## 0.9.4 — 2026-07-13 09:03
### Fixed
- "New recipe" wrapped to its own line on mobile (especially French) instead of sharing a row with Generate/Import — icon-only below that breakpoint now.
- The view-toggle/Select row floated flush right with a large dead gap on the left — now left-aligned outside select mode.
## 0.9.3 — 2026-07-13 08:38
### Fixed
- Recipes page wasted a lot of vertical space above the recipe list on mobile — Sort/Filter now sit alongside Search on one row instead of forcing an extra one.
## 0.9.2 — 2026-07-13 07:14
### Fixed
- Explore page's "Recipe ideas" banner buttons clashed with its violet gradient background — restyled to match.
## 0.9.1 — 2026-07-13 06:59
### Fixed
- Destructive confirm buttons (clear chat history, discard recipe changes) had unreadable black text on a red background — a missing CSS variable.
## 0.9.0 — 2026-07-12 23:28
### Added
- **Clear chat history**: a trash-icon button in both chatbots clears that conversation's saved history.
- Chat history now auto-expires after 90 days (daily cleanup job).
## 0.8.0 — 2026-07-12 22:38
### Added
- **Read-only API key scoping**: create keys that can only read (GET), not create/edit/delete anything.
- **AI chat history**: both chatbots now remember past conversations across sessions, and let you search across everything you've ever asked.
## 0.7.1 — 2026-07-12 21:36
### Fixed
- Cooking-assistant button on the homepage sat too high.
- The chatbots would answer honestly if asked what AI model they run on — now always identify as Epicure.
## 0.7.0 — 2026-07-12 19:36
### Added
- **General cooking-question chatbot** on the recipes homepage — ask technique/substitution/timing questions not tied to a specific recipe.
### Fixed
- API keys always showed "never used" — middleware was blocking every Bearer-key request before it ever reached the code that verifies the key and records usage.
- The webhooks documentation page had no way back to webhooks settings.
## 0.6.0 — 2026-07-12 19:09
### Added
- **Granular push-notification settings**: turn individual categories on/off (follows, comments, replies, reactions, ratings, mentions, leftover reminders, shared shopping list updates) instead of all-or-nothing.
### Fixed
- The push-notification toggle in Settings always showed as off, even when notifications were genuinely still enabled — it never checked the actual browser subscription state.
- The API Reference page (`/docs`) was blank — Content-Security-Policy blocked the script it needs to render.
## 0.5.1 — 2026-07-12 19:05
### Security
- Recipe/review photo uploads: a stolen storage key from another user's public recipe or review could be submitted as your own, causing that user's photo to be deleted from storage when the recipe was later edited or deleted. Both routes now check the key was actually issued to you for that recipe.
- AI URL-import could be redirected to internal/private network addresses via a crafted 3xx response, and was separately vulnerable to DNS-rebinding between validation and fetch. Both the import and outgoing webhook delivery now resolve the host once and pin the connection to that address, re-validating on every redirect hop.
- Comment moderation and monthly AI/recipe/storage quota checks trusted a session field that can lag up to 5 minutes behind a role or tier change — both now re-check the current value in the database.
## 0.5.0 — 2026-07-12 18:37
### Added
- **Push notifications for shared shopping lists** — get pinged when someone else checks off or adds items.
@@ -12,7 +655,7 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
- Weekly meal-plan nutrition coverage compared a week's total intake against a daily goal (reading ~7x too high) — now compares a proper daily average, with a per-day breakdown and a flag for meals missing nutrition data.
- Meal-plan week navigation could resolve the wrong Monday depending on server timezone.
## 0.4.0 — 2026-07-12
## 0.4.0 — 2026-07-12 16:13
### Added
- **Profile pictures**: upload a custom photo, or get a Gravatar-backed avatar automatically — no more generic initials by default.
@@ -22,7 +665,7 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
### Fixed
- Batch-cook recipes shifted left in compact view when they had no time set or a longer dish/time label.
## 0.3.1 — 2026-07-12
## 0.3.1 — 2026-07-12 15:08
### Added
- A free-text "describe what you're after" field for batch-cooking generation, matching the standard recipe generator.
@@ -32,17 +675,17 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
- The bulk-select action bar on the recipe list could show a horizontal scrollbar on desktop even though there was room to grow.
- Recipes imported from a URL never actually saved their source URL — the "Source: ..." link on the recipe page was dead code until now.
## 0.3.0 — 2026-07-12
## 0.3.0 — 2026-07-12 14:46
### Added
- **Manually create and edit batch-cook recipes**: the recipe form now has a batch-cook toggle, an editable dish list (storage/reheat guidance per dish), and per-step dish tagging — not just AI-generated ones anymore. Editing an existing batch-cook recipe no longer silently strips its dish data.
## 0.2.2 — 2026-07-12
## 0.2.2 — 2026-07-12 13:54
### Fixed
- Recipe photo thumbnails always tried to load from `localhost:9000` in production, tripping CSP/mixed-content blocks in every browser — the public storage URL wasn't reaching client-side code.
## 0.2.1 — 2026-07-12
## 0.2.1 — 2026-07-12 13:21
### Fixed
- Recipe photos were lost after saving, and multi-file uploads only kept the last file selected.
+1 -1
View File
@@ -44,7 +44,7 @@ pnpm db:studio # open Drizzle Studio
- `api/v1/` — REST API endpoints
- `api/auth/[...all]/` — Better Auth handler
**Auth**: Better Auth with Drizzle adapter. Server: `lib/auth/server.ts`. Client: `lib/auth/client.ts`. Middleware at `middleware.ts` guards all routes.
**Auth**: Better Auth with Drizzle adapter. Server: `lib/auth/server.ts`. Client: `lib/auth/client.ts`. Edge-level guard is `proxy.ts` (not `middleware.ts`, which was removed) — it checks for a session cookie and redirects to `/login` for non-public paths, and additionally gates `/admin` paths. Per-route/per-page checks (`auth.api.getSession` in server components, `requireSession`/`requireSessionOrApiKey`/`requireAdmin` in API routes) are still required as defense-in-depth — don't rely on `proxy.ts` alone when adding a page or route under `(app)/` or `admin/`.
**DB**: Drizzle ORM on Postgres. Schema in `packages/db/src/schema/` split by domain: `users`, `recipes`, `social`, `meal-planning`, `tiers`. Import from `@epicure/db`.
+2 -1
View File
@@ -76,5 +76,6 @@ RUN apk add --no-cache curl
COPY cron/crontab /etc/crontabs/root
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
COPY cron/run-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh
COPY cron/run-chat-cleanup.sh /usr/local/bin/run-chat-cleanup.sh
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh /usr/local/bin/run-chat-cleanup.sh
CMD ["crond", "-f", "-l", "2"]
+164
View File
@@ -0,0 +1,164 @@
# 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).
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.
| 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 | `apps/web/app/api/v1/recipes/[id]/fork/route.ts` |
| 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 | AI-estimated, cached on the recipe row; author-triggered | `apps/web/app/api/v1/recipes/[id]/nutrition` |
| 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, 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` |
| 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 + bulk-set-week | `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` |
| 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` |
| **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` |
| 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` |
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link mode (`isPublic`/`publicEditable`) | `packages/db/src/schema/meal-planning.ts` |
| List collaboration | Partial | Short-polling, not websocket/push — explicitly documented as such in code | `apps/web/components/meal-plan/shopping-list-view.tsx` |
| Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize | `apps/web/lib/grocery-categories.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` |
| 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/**` |
| 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` |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions | `apps/web/components/pantry/expiring-soon-suggestions.tsx` |
| **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 | Hides comments, blocks messaging both ways | `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** | `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 | `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 session creation** | **Missing** | No code anywhere creates a Checkout Session or exposes an "Upgrade" flow — the webhook consumer exists but nothing produces the event it consumes | — |
| **Self-serve billing portal** | **Missing** | No Stripe customer portal; tier changes today only happen via direct admin edit at `/admin/users/[id]` | — |
| 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/**` |
| 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** (new, 2026-07-22) | Exists | A fourth, orthogonal access dimension — `users.isDeveloper`, admin-toggled in `admin/users/[id]`, gates user webhooks + self-serve API keys + BYOK. Previously all three had zero gating (any logged-in user, any tier). Existing users with a webhook/API key/BYOK key were grandfathered in by the migration. Deliberately a single `hasDeveloperAccess()` function so a future subscription-tier auto-grant is a one-line change, not a redesign. | `apps/web/lib/permissions.ts`, `apps/web/lib/api-auth.ts` (`requireDeveloper`) |
| 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** — the webhook consumer is production-ready but nothing produces the checkout event or lets a user self-serve upgrade/cancel/view invoices. Biggest gap if monetizing seriously.
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).
## 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.
-127
View File
@@ -1,127 +0,0 @@
# Audit fixes handoff (2026-07-09) — for next session (Sonnet 5)
A full 4-agent audit (bugs / UI-UX / backend consistency / feature gaps) was run, then fixes were fanned out to parallel agents. Session token limit killed 3 fix agents mid-flight. This file records: what's DONE, what's PARTIAL/UNVERIFIED, what's NOT STARTED. Nothing is committed. (Prior resolved backlog lives in TODO.md — don't confuse the two.)
**First step for next session:** `pnpm typecheck && pnpm lint`, then verify the PARTIAL items below by reading `git diff` before doing anything else.
---
## âś… DONE (in working tree, uncommitted, reported complete by agents)
### AI tier-quota bypass (critical — money leak)
- `apps/web/app/api/v1/ai/recipe-ideas/route.ts` — `generateObject` wrapped in `withAiQuota`.
- `apps/web/app/api/v1/ai/recipe-chat/route.ts` — `generateText` wrapped in `withAiQuota` (also maps provider errors).
- `apps/web/app/api/v1/ai/meal-plan/generate/route.ts` — `withAiQuota` added; recipe tier limit now charged per inserted draft recipe via `checkAndIncrementTierLimit(..., "recipe")` loop with refund (`incrementUsage(userId, "recipe", -charged)`) on `TierLimitError`; returns same 403 shape as `recipes/route.ts` POST.
- `apps/web/app/api/v1/recipes/[id]/nutrition/route.ts` — POST now author-only (was: any authed user could burn paid AI calls on public recipes), rate-limited (`rl:ai:${userId}`, 10/60), wrapped in `withAiQuota`. GET unchanged.
### Webhooks (security + drift)
- `apps/web/lib/webhooks.ts` — SSRF fix: `dispatchWebhook` re-runs `validateWebhookUrl` per delivery (DNS re-resolution, private/link-local/loopback/metadata ranges rejected); `redirect: "manual"` on fetch (3xx = failed delivery); validation failure recorded as delivery failure (statusCode 0). Exported `WEBHOOK_EVENTS` as const array; `WebhookEvent` type derived from it.
- `apps/web/app/api/v1/webhooks/route.ts` + `[id]/route.ts` — drifted local `VALID_EVENTS` (4 events) removed; both use `z.enum(WEBHOOK_EVENTS)` (7 events). Users can now subscribe to `meal_plan.updated`, `shopping_list.completed`, `comment.added`.
- Leftover: `recipe.published` subscribable but never dispatched. Either dispatch on private→public transition in `recipes/[id]/route.ts` PUT, or drop the event.
### DB schema + migration (generated, NOT applied)
- `packages/db/src/schema/recipes.ts` — indexes on `recipe_ingredients.recipe_id`, `recipe_steps.recipe_id`.
- `packages/db/src/schema/meal-planning.ts` — indexes on `meal_plans.user_id`, `meal_plan_entries.meal_plan_id`, `pantry_items.user_id`; UNIQUE `(user_id, week_start)` on meal_plans; UNIQUE `(meal_plan_id, day, meal_type)` on meal_plan_entries (verified intentional: entries route delete-then-insert + meal-planner.tsx `.find()` = one entry per slot).
- `packages/db/src/schema/social.ts` — `comments.parentId` now self-FK with `onDelete: "cascade"` (AnyPgColumn pattern).
- Migration generated: `packages/db/src/migrations/0023_medical_dark_beast.sql` (1 FK + 5 indexes + 2 unique indexes).
- ⚠️ **Before `pnpm db:migrate`, clean existing data or migration fails:**
1. Dangling parents: `UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments);`
2. Duplicate `(user_id, week_start)` meal_plans → merge/delete dupes.
3. Duplicate `(meal_plan_id, day, meal_type)` entries → keep newest, delete rest.
### Recipe route transactions
- `apps/web/app/api/v1/recipes/[id]/route.ts` PUT — snapshot/version-bump moved after Zod validation, inside the update transaction (invalid body no longer creates phantom snapshots).
- `apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts` — pre-restore snapshot moved inside the restore transaction.
---
## ⚠️ PARTIAL / UNVERIFIED — agent died mid-work, verify diffs first
1. **`recipes/[id]/route.ts` DELETE — S3 object cleanup.** Agent died MID-EDIT here ("Now bug B — DELETE with S3 cleanup"). File is modified — check whether DELETE now collects photo storage keys and calls `deleteObject` from `apps/web/lib/storage.ts` (which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (check `packages/db/src/schema/recipes.ts` for key vs full-URL columns), `deleteObject` per key in try/catch — storage failure must NOT fail the API response.
2. **Optimistic buttons — all 4 files edited, agent died before reporting. Review each diff:**
- `apps/web/components/social/favorite-button.tsx` — should be: optimistic flip, rollback + error toast (was: await-then-flip, silent fail).
- `apps/web/components/collections/collection-star-button.tsx` — same pattern.
- `apps/web/components/social/follow-button.tsx` — optimistic flip, keep existing error toast.
- `apps/web/components/meal-plan/shopping-list-view.tsx` — toggleItem: keep optimistic update, ADD rollback on `!res.ok`/throw with functional setState (rollback must not clobber other items' newer toggles).
- If toasts reference new i18n keys: neither `apps/web/messages/en.json` nor `fr.json` was modified — add keys or the UI breaks.
3. **`apps/web/components/shared/skeletons.tsx` — created, but ZERO loading.tsx/error.tsx/not-found.tsx files exist yet.** Agent died after the shared building blocks. Verify the file, then do item 5 below.
---
## ❌ NOT STARTED — Wave 1 remainder
4. **Upload presign size + storage quota** (`apps/web/app/api/v1/upload/presign/route.ts`, `apps/web/lib/storage.ts`): validates content-type only, no size limit; `"storage"` LimitKey in `lib/tiers.ts` is dead code. Fix: require `fileSize` (bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and add `fileSize` there.
5. **Route-level UI states** (reuse `components/shared/skeletons.tsx`):
- `app/(app)/error.tsx` ("use client", reset() button), `app/(app)/not-found.tsx`, `app/(app)/loading.tsx`
- Per-route loading.tsx mirroring real layouts: recipes, feed, collections, meal-plan, recipes/[id], u/[username], search, explore, shopping-lists, pantry
- `app/admin/error.tsx` + `app/admin/loading.tsx`
- Root `app/error.tsx` + `app/not-found.tsx` (dependency-light; may render outside providers)
## ❌ NOT STARTED — Wave 2 (planned batches, disjoint file sets)
6. **Auth hardening:**
- No `middleware.ts` exists (CLAUDE.md claims otherwise). Add root middleware guarding `(app)` + `admin` (Better Auth session cookie). Unguarded pages today: `/recipes/new`, `/explore`, `/search`, `/settings/webhooks/docs`.
- `lib/api-auth.ts:76` — rate limit only on API-key branch of `requireSessionOrApiKey`; cookie-session branch (~103) skips it. Apply to both.
- `lib/tiers.ts:41` — `if (!tierDef) return;` = unknown/custom tier gets NO limits. Deny instead (or fall back to free limits).
- Admin role staleness: `lib/api-auth.ts:19` trusts `session.user.role` (5-min cookieCache, `auth/server.ts:78-81`). Make `requireAdmin` query DB role.
- `app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118` — self-leave unreachable: plan lookup scoped to owner, members get 404, `isSelf` branch dead. Lookup must also match membership.
- `app/api/v1/conversations/[id]/messages/route.ts:26-31` — `asc + limit(200)`: conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); update `components/social/message-thread.tsx`. Also add `aria-label` to its icon-only send button (~112).
- `lib/rate-limit.ts:23` — off-by-one: allows limit+1 requests.
- `app/api/webhooks/stripe/route.ts` — no event-ID dedup (replay within tolerance re-runs tier update).
7. **Pagination:**
- `app/(app)/recipes/page.tsx:47` — findMany with NO limit, loads every recipe.
- `app/(app)/feed/page.tsx:46` — cap 40, no pagination; trending/for-you single batch. Also `components/feed/feed-page-content.tsx:86,112` — `.catch(() => {})` renders network failure as empty state; add error branch + retry.
- `app/(app)/u/[username]/page.tsx:55` — 24 recipes then dead end.
- `app/api/v1/recipes/[id]/comments/route.ts:28-43` — unbounded; paginate + update `components/social/comments-section.tsx` (also no error branch ~218; comment delete ~134 has no confirm).
- `app/api/v1/pantry/route.ts:17-22` — unbounded.
- `app/admin/users/page.tsx:28,36` — limit(100) no next-page; table lacks `overflow-x-auto`. Same for other admin tables.
- Reuse `total/limit/offset` pattern from `components/search/search-page-content.tsx:29-34`.
8. **Nav/theme/confirms:**
- `components/layout/nav.tsx:110` — `<AvatarImage src="" alt="" />` hardcoded; render session user image. Add "View profile" link to dropdown.
- Dark mode wired (`components/providers.tsx:20`, `.dark` palette) but NO toggle. Add Sun/Moon in nav dropdown (`next-themes` setTheme).
- Confirm consistency: comment delete (`comments-section.tsx:134`) + pantry delete (`pantry-manager.tsx:55`) fire instantly; `recipes-grid.tsx:296`, `block-button.tsx:21`, `invites-manager.tsx:49` use `window.confirm`. Standardize on AlertDialog (pattern: `delete-recipe-button.tsx:62`).
9. **Forms/a11y/images/URLs:**
- `components/recipe/recipe-form.tsx` — no unsaved-changes guard; only title validated, empty ingredients/steps silently dropped (~:132,149,158).
- Canonical URL split: cards → `/recipes/[id]` (`recipe-card.tsx:42`, `recipes-grid.tsx:218`) vs `/r/[id]` (`feed-page-content.tsx:60`, `u/[username]/page.tsx:151`, `search-page-content.tsx:50`). Use `/recipes/[id]` in-app, keep `/r/[id]` for public share.
- 4+ recipe-card implementations (recipe-card.tsx; GridCard/ListRow/CompactRow in recipes-grid.tsx; inline in feed-page-content.tsx:33 + search-page-content.tsx:42; tiles in u/[username]/page.tsx:149) — consolidate where cheap.
- a11y: `aria-label` on 28 `size="icon"` buttons; meaningful `alt` on recipe images (`recipes/[id]/page.tsx:378`, `can-cook-content.tsx:39`, `expiring-soon-suggestions.tsx:39`, `cooked-it-review.tsx:149,209`); `alt` on AvatarImage everywhere; color-only signalling on difficulty badges + pantry expiry (`pantry-manager.tsx:96`) — add icon/text.
- Zero `next/image` (12 raw `<img>`) — migrate grids/heroes, width/height set, remotePatterns for MinIO/S3 host in next.config.
10. **api-types/OpenAPI:**
- `packages/api-types` 100% orphaned — zero imports; every route inlines Zod. Wire recipes routes to it (align drift first) or delete the package + update CLAUDE.md.
- `apps/web/lib/openapi.ts` — ~20 of 90 routes documented; recipes list documented `page/limit` but real is `limit/offset` returning `{data, limit, offset}` (`recipes/route.ts:48-64`).
- Error shape: `ai-keys/route.ts:35` returns `{error: <flatten object>}` vs `{error: string}` elsewhere.
11. **Misc low:**
- `app/api/v1/search/route.ts:72` — escape `%`/`_` in ilike; `feed/trending/route.ts:6` — parseInt NaN reaches `.limit()`.
- `app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts:17` — body cast without Zod.
- `app/api/v1/meal-plans/[weekStart]/entries/route.ts:48` — `recipeId` unvalidated → FK violation 500 (sibling shared route validates; copy it).
- ~~Recipe usage counter monthly + never decremented on delete~~ — **decided**: keep as-is, `maxRecipes` means "creations per month," deleting a recipe does not free up quota.
- `lib/ai/resolve-user-key.ts:18-23,44-62` — BYOK decrypt failures silently fall back to platform key; surface error.
- API keys: no expiry/scopes; all of a user's keys share one rate bucket (`api-auth.ts:79`).
## Final phase
- Clean dev-DB dupes (see migration warning) → `pnpm db:migrate`.
- `pnpm typecheck && pnpm lint && pnpm build`, fix fallout.
- Smoke: favorite/star/follow, shopping toggle rollback (network off in devtools), AI 403 at quota, webhook to private IP rejected, recipe delete removes MinIO objects.
## New features backlog (NOT fixes — not yet greenlit)
"S" items mostly wire existing orphaned infra:
1. Push+email for ALL notification types (S) — `createNotification` writes rows; only comments route calls `sendPushNotification` (`lib/push.ts`).
2. Personal recipe notes UI (S) — `recipeNotes` table exists (`recipes.ts:94`), zero API/UI.
3. Cooking history page (S/M) — `cookingHistory` written by `/cooked`, never read. Profile tab: counts, last-cooked, streaks.
4. Notifications inbox page (S) — API + bell exist.
5. Pantry-aware shopping lists (S).
6. Unit conversion from `users.unitPref` (M).
7. Nutrition daily diary (M).
8. Weekly digest email (M, needs cron).
9. Recipe fork/clone via `recipeVariations` (S).
10. GDPR data export (S/M). 11. Pantry barcode/photo scan (M).
12. "Cooked it" photo gallery (M).
13. Meal-plan generation targeting nutrition goals (M).
## Gotchas (from project memory)
- Import drizzle operators (`eq, and, or, desc, sql, count`…) from `@epicure/db`, NEVER `drizzle-orm` directly in apps/web (dual-instance bug).
- shadcn Button: NO `asChild` prop — use `buttonVariants()` + `<Link>`.
- `session.user.tier` is `string` — cast `as "free" | "pro"` at call sites.
- `requireSessionOrApiKey` takes `NextRequest`; `requireSession` doesn't.
- Better Auth `changeEmail`/`changePassword`: call on `authClient` object, don't destructure.
- New user-facing strings go in BOTH `apps/web/messages/en.json` and `fr.json`.
-37
View File
@@ -1,37 +0,0 @@
# Known issues / backlog
Findings from a codebase health scan (security, data integrity, tests, perf, cleanup). All items below are resolved as of this pass.
## Resolved
1. ~~**IDOR** — `collections/[id]/route.ts`~~ — investigated: the `PUT` handler's top-level ownership check (`existing = findFirst(id + userId)`) already gates the entire handler, including `removeRecipeId`/`addRecipeId`. Not actually vulnerable. No change made.
2. ~~**Missing transaction** — meal-plan generation~~ — wrapped the per-entry insert sequence in `db.transaction(...)`.
3. ~~**Missing indexes**~~ — added `userId` indexes to `collections`, `collectionMembers`, `cookingHistory`, `ratings`, `favorites`.
4. ~~**Zero test coverage** on `api/v1/admin/*` and `api/v1/webhooks/*`~~ — added Vitest coverage for all 7 route files (role checks, SSRF validation path, redelivery logic).
5. ~~SSRF gap — malformed IPv6~~ — replaced string-prefix heuristics with a proper IPv6 parser (handles `::` compression, IPv4-mapped addresses, fails closed on malformed input). Also found and fixed an identical duplicated bug in `lib/ai/features/import-url.ts`; consolidated both call sites onto the one fixed implementation.
6. ~~Race condition — `checkAndIncrementTierLimit`~~ — investigated: already atomic (single `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`). The old racy `checkTierLimit` was dead code (zero callers) — deleted.
7. ~~N+1 / no pagination — collections list~~ — added `limit`/`offset` pagination, matching the `search` route's pattern.
8. ~~Dietary-tag search — missing index~~ — added a GIN index on `recipes.dietaryTags`; also switched the search filter from `->>` text extraction to `@>` containment so the index is actually used.
9. ~~Duplicated Zod schemas across AI features~~ — extracted shared `dietaryTagsSchema`/`ingredientSchema`/`stepSchema` into `lib/ai/features/recipe-schema.ts`.
10. ~~Stripe webhook stubbed~~ — implemented tier upgrade/downgrade; added `users.stripeCustomerId` to map `customer.subscription.deleted` events back to a user.
11. ~~No rate limit on `ai-keys`~~ — added `applyRateLimit` to the POST handler.
12. ~~`pnpm typecheck` documented but missing~~ — added the script to all three workspace packages; also fixed the pre-existing type errors it exposed (`packages/db` missing `@types/node`, two stale test mocks).
13. ~~Hardcoded `localhost:3001` fallback~~ — now throws a 500 with a clear message if `BETTER_AUTH_URL` is unset, instead of silently generating a broken link.
# Feature ideas
Brainstormed extensions building on existing infra (pantry match, meal planning, cooking mode w/ voice, tiers, AI generation, social/collections, print, version history).
## Done
1. **Expiry-aware pantry** — done. Pantry schema/UI already had `expiresAt` fully wired (date input, sort, expiry badges); added the missing piece — the canCook page now surfaces a "Use it up" badge on recipes that use soon-expiring pantry items and sorts them to the top.
2. **Shared meal plans/shopping lists** — done. Added `shoppingListMembers`/`mealPlanMembers` tables (viewer/editor roles, mirrors `collectionMembers`), share dialogs, and membership-checked API routes. Meal plans keep their owner-side weekly routes; shared access goes through new `/api/v1/meal-plans/shared/[mealPlanId]` routes since plans are addressed by `(userId, weekStart)`.
3. **PDF cookbook export** — done. `/print/collection/[id]` renders every recipe in a collection with `page-break-after` between them, reusing the existing print-page CSS; browser print-to-PDF produces the file, matching the existing single-recipe/meal-plan print pattern (no new PDF-rendering dependency).
4. **Recipe diff/compare view** — done. Added the `diff` package + `VersionDiffView`; version-history-button now has a "Compare with current" action next to Restore.
5. **Grocery delivery handoff** — done as a stub, per confirmed scope: shopping lists get a "Send to grocery delivery" button that always offers copy-as-text, plus a documented (not live) Instacart adapter gated behind `INSTACART_API_KEY`/`NEXT_PUBLIC_GROCERY_PROVIDER` — real activation needs an actual partner agreement.
6. **Personalized "for you" feed** — done. New `/api/v1/feed/for-you` ranks public recipes by tag/dietary-tag overlap with the user's favorited/highly-rated history (falls back to recency when there's no history yet); third feed tab added.
7. **PWA/offline mode** — done. The service worker and offline fallback already existed; added `manifest.json` + icons and wired them into the root layout metadata so the app is installable. Cache-first on `/cook` already makes previously-visited cooking-mode pages available offline.
## Also fixed this pass
- **Mobile responsiveness** — Recipes/Collections/Pantry/Meal Plan/Shopping Lists page headers now stack and wrap instead of clipping buttons off-screen on narrow viewports (`flex-col sm:flex-row` + `flex-wrap`).
+88 -33
View File
@@ -2,13 +2,20 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Printer, UtensilsCrossed } from "lucide-react";
import { Printer, UtensilsCrossed, StickyNote, ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and, or } from "@epicure/db";
import { RecipeCard } from "@/components/recipe/recipe-card";
import { db, collections, eq, and } from "@epicure/db";
import { collectionVisibleToViewer } from "@/lib/visibility";
import { RecipeGridCard } from "@/components/recipe/recipe-grid-card";
import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
import { GenerateMealDialog } from "@/components/collections/generate-meal-dialog";
import { EditCollectionDialog } from "@/components/collections/edit-collection-dialog";
import { DeleteCollectionDialog } from "@/components/collections/delete-collection-dialog";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { EmptyState } from "@/components/shared/empty-state";
@@ -26,16 +33,19 @@ export default async function CollectionPage({ params }: Params) {
const m = getMessages((session.user as { locale?: string }).locale);
const col = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
),
with: { recipes: { with: { recipe: { with: { photos: true } } } } },
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
orderBy: (t, { asc }) => asc(t.position),
with: { recipe: { with: { photos: true } } },
},
},
});
if (!col) notFound();
const isOwner = col.userId === session.user.id;
const recipeList = col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : []));
return (
<div className="space-y-6">
@@ -43,40 +53,85 @@ export default async function CollectionPage({ params }: Params) {
<div>
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
{col.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{col.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
<p className="text-sm text-muted-foreground mt-1">
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
{recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {m.recipe.visibility[col.visibility]}
</p>
{isOwner && col.notes && (
<div className="mt-2 flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground max-w-2xl">
<StickyNote className="h-4 w-4 shrink-0 mt-0.5" />
<p className="whitespace-pre-wrap">{col.notes}</p>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{col.recipes.length > 0 && (
<>
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
{m.collections.exportPdf}
</Link>
<ExportMarkdownButton
markdown={collectionToMarkdown({
name: col.name,
description: col.description,
recipes: col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : [])),
})}
filename={col.name}
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1">
{recipeList.length > 0 && (
<>
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
</Tooltip>
<ExportMarkdownButton
markdown={collectionToMarkdown({
name: col.name,
description: col.description,
recipes: recipeList,
})}
filename={col.name}
/>
</>
)}
{isOwner && <GenerateMealDialog collectionId={id} />}
{isOwner && <ShareCollectionButton collectionId={id} />}
{isOwner && (
<EditCollectionDialog
collectionId={id}
initialName={col.name}
initialDescription={col.description}
initialNotes={col.notes}
initialTags={col.tags}
initialVisibility={col.visibility}
/>
</>
)}
{isOwner && <ShareCollectionButton collectionId={id} />}
{!isOwner && col.isPublic && (
<ForkCollectionButton collectionId={id} />
)}
</div>
)}
{isOwner && <DeleteCollectionDialog collectionId={id} />}
{col.visibility === "public" && (
<Tooltip>
<TooltipTrigger render={
<Link href={`/c/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.recipe.viewPublicly}</TooltipContent>
</Tooltip>
)}
{!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
<ForkCollectionButton collectionId={id} />
)}
</div>
</TooltipProvider>
</div>
{col.recipes.length === 0 ? (
{recipeList.length === 0 ? (
<EmptyState icon={UtensilsCrossed} title={m.collections.emptyCollection} compact />
) : isOwner ? (
<CollectionRecipesGrid collectionId={id} recipes={recipeList} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{col.recipes.map(({ recipe }) => (
recipe && <RecipeCard key={recipe.id} recipe={recipe} />
{recipeList.map((recipe) => (
<Link key={recipe.id} href={`/recipes/${recipe.id}`}>
<RecipeGridCard recipe={recipe} />
</Link>
))}
</div>
)}
@@ -49,7 +49,7 @@ export default async function ExploreCollectionsPage() {
collectionFavorites,
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
)
.where(eq(collections.isPublic, true))
.where(eq(collections.visibility, "public"))
.groupBy(collections.id, users.id)
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
.limit(12);
@@ -63,7 +63,7 @@ export default async function ExploreCollectionsPage() {
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.where(eq(collections.isPublic, true))
.where(eq(collections.visibility, "public"))
.orderBy(desc(collections.createdAt))
.limit(12);
+64 -9
View File
@@ -1,29 +1,84 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, desc } from "@epicure/db";
import { db, collections, collectionRecipes, eq, and, or, ilike, sql } from "@epicure/db";
import { CollectionsPageContent } from "@/components/collections/collections-page-content";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {};
export default async function CollectionsPage() {
export default async function CollectionsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const userCollections = await db.query.collections.findMany({
where: eq(collections.userId, session.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 1 } },
});
const { q } = await searchParams;
const query = q?.trim();
const where = query
? and(
eq(collections.userId, session.user.id),
or(
ilike(collections.name, `%${query}%`),
ilike(collections.description, `%${query}%`),
sql`exists (
select 1 from collection_recipes cr
inner join recipes r on r.id = cr.recipe_id
where cr.collection_id = ${collections.id} and r.title ilike ${`%${query}%`}
)`
)
)
: eq(collections.userId, session.user.id);
const [userCollections, countRows] = await Promise.all([
db.query.collections.findMany({
where,
orderBy: (t, { desc }) => desc(t.updatedAt),
with: {
recipes: {
limit: 4,
orderBy: (t, { asc }) => asc(t.position),
with: { recipe: { with: { photos: true } } },
},
},
}),
// Separate grouped count — the `with: { recipes: { limit: 4 } }` above is
// capped for thumbnail previews, so `.recipes.length` off that relation
// would only ever report up to 4, never the real total.
db
.select({ collectionId: collectionRecipes.collectionId, count: sql<number>`count(*)::int` })
.from(collectionRecipes)
.innerJoin(collections, eq(collectionRecipes.collectionId, collections.id))
.where(eq(collections.userId, session.user.id))
.groupBy(collectionRecipes.collectionId),
]);
const countByCollection = new Map(countRows.map((r) => [r.collectionId, r.count]));
return (
<CollectionsPageContent
query={query ?? ""}
collections={userCollections.map((col) => ({
id: col.id,
name: col.name,
description: col.description,
isPublic: col.isPublic,
recipeCount: col.recipes.length,
tags: col.tags,
visibility: col.visibility,
recipeCount: countByCollection.get(col.id) ?? 0,
thumbnails: col.recipes.flatMap((r) => {
if (!r.recipe) return [];
const cover = r.recipe.photos.find((p) => p.isCover) ?? r.recipe.photos[0];
return [{
recipeId: r.recipe.id,
recipeType: r.recipe.recipeType,
coverIcon: r.recipe.coverIcon,
coverColor: r.recipe.coverColor,
photoUrl: cover ? getPublicUrl(cover.storageKey) : null,
}];
}),
}))}
/>
);
+67 -23
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import {
db,
recipes,
users,
favorites,
userFollows,
eq,
desc,
sql,
@@ -11,38 +13,61 @@ import {
gte,
count,
} from "@epicure/db";
import { auth } from "@/lib/auth/server";
import { ExplorePageContent } from "@/components/search/explore-page-content";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export const metadata: Metadata = {};
export type RecipeResult = {
id: string;
title: string;
description: string | null;
authorName: string | null;
difficulty: string | null;
difficulty: "easy" | "medium" | "hard" | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
visibility: "private" | "unlisted" | "public" | "followers";
tags: string[];
isBatchCook: boolean;
sourceUrl: string | null;
recipeType: "dish" | "drink";
photos: { storageKey: string; isCover: boolean }[];
dishCount: number;
isFavorited: boolean;
};
export default async function ExplorePage({
searchParams,
}: {
searchParams: Promise<{ q?: string; tab?: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { q, tab } = await searchParams;
const { q } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const columns = {
id: recipes.id,
title: recipes.title,
description: recipes.description,
authorName: users.name,
difficulty: recipes.difficulty,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
coverIcon: recipes.coverIcon,
coverColor: recipes.coverColor,
};
// Trending: public recipes ordered by favorite count in last 7 days
const trendingRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
favoriteCount: count(favorites.recipeId),
})
.select({ ...columns, favoriteCount: count(favorites.recipeId) })
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
@@ -59,29 +84,48 @@ export default async function ExplorePage({
// Recent: public recipes ordered by createdAt desc
const recentRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
})
.select(columns)
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.orderBy(desc(recipes.createdAt))
.limit(12);
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
const recent: RecipeResult[] = recentRows;
const [trending, recent] = await Promise.all([
attachCardExtras(trendingRows.map(({ favoriteCount: _fc, ...r }) => r), session?.user.id),
attachCardExtras(recentRows, session?.user.id),
]);
// Most-used tags across public recipes — shown as filter chips on the
// search bar so browsing by tag doesn't require typing the exact word.
const popularTagsResult = await db.execute<{ tag: string }>(sql`
select tag, count(*) as usage_count
from recipes r
inner join users u on r.author_id = u.id
cross join lateral unnest(r.tags) as tag
where r.visibility = 'public' and u.is_private = false
group by tag
order by usage_count desc
limit 12
`);
const popularTags = popularTagsResult.map((r) => r.tag);
const followedCount = session
? (
await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id))
).length
: 0;
return (
<ExplorePageContent
trending={trending}
recent={recent}
initialQuery={q ?? ""}
initialTab={tab === "people" ? "people" : "recipes"}
popularTags={popularTags}
followedCount={followedCount}
/>
);
}
+3 -17
View File
@@ -1,19 +1,5 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userFollows, eq } from "@epicure/db";
import { FeedPageContent } from "@/components/feed/feed-page-content";
import { redirect } from "next/navigation";
export const metadata: Metadata = {};
export default async function FeedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const followedRows = await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id));
return <FeedPageContent followedCount={followedRows.length} />;
export default function FeedPage() {
redirect("/explore");
}
+48 -17
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer, CalendarDays } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
@@ -110,37 +111,67 @@ export default async function MealPlanPage({
return (
<div className="space-y-6">
<TooltipProvider>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1>
<p className="text-muted-foreground mt-1">{label}</p>
<div className="flex items-center gap-1 mt-1">
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.previousWeek}>
<ChevronLeft className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.previousWeek}</TooltipContent>
</Tooltip>
<p className="text-muted-foreground">{label}</p>
<Tooltip>
<TooltipTrigger render={
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "h-7 w-7")} aria-label={msgs.mealPlan.nextWeek}>
<ChevronRight className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.nextWeek}</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} />
<ShareMealPlanButton weekStart={weekStart} mealPlanId={plan?.id} initialIsPublic={plan?.isPublic ?? false} />
<NewShoppingListButton
defaultWeekStart={weekStart}
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
/>
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ShoppingCart className="h-4 w-4" />
{msgs.mealPlan.shoppingLists}
</Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
{msgs.common.print}
</Link>
<Tooltip>
<TooltipTrigger render={
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.shoppingLists}>
<ShoppingCart className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.mealPlan.shoppingLists}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.common.print}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{msgs.common.print}</TooltipContent>
</Tooltip>
<ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`}
/>
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronLeft className="h-4 w-4" />
</Link>
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronRight className="h-4 w-4" />
</Link>
<Tooltip>
<TooltipTrigger render={
<a href={`/api/v1/meal-plans/${weekStart}/export/ics`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.exportCalendar}>
<CalendarDays className="h-4 w-4" />
</a>
} />
<TooltipContent>{msgs.mealPlan.exportCalendar}</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
<WeeklyNutritionBar weekStart={weekStart} />
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
+14 -1
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { NutritionDiary } from "@/components/nutrition/nutrition-diary";
import { NutritionTrend } from "@/components/nutrition/nutrition-trend";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -17,7 +19,18 @@ export default async function NutritionDiaryPage() {
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
</div>
<NutritionDiary />
<Tabs defaultValue="diary" className="gap-6">
<TabsList>
<TabsTrigger value="diary">{m.nutritionDiary.tabDiary}</TabsTrigger>
<TabsTrigger value="trend">{m.nutritionDiary.tabTrend}</TabsTrigger>
</TabsList>
<TabsContent value="diary">
<NutritionDiary />
</TabsContent>
<TabsContent value="trend">
<NutritionTrend />
</TabsContent>
</Tabs>
</div>
);
}
+30 -7
View File
@@ -6,6 +6,7 @@ import { db, recipes } from "@epicure/db";
import { and, eq } from "@epicure/db";
import { RecipeForm } from "@/components/recipe/recipe-form";
import { getPublicUrl } from "@/lib/storage";
import { getMessages } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
@@ -28,10 +29,13 @@ export default async function EditRecipePage({ params }: Params) {
if (!recipe) notFound();
const m = getMessages((session.user as { locale?: string }).locale);
const defaultValues = {
title: recipe.title,
description: recipe.description ?? undefined,
baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: recipe.visibility,
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
@@ -45,18 +49,37 @@ export default async function EditRecipePage({ params }: Params) {
unit: ing.unit ?? "",
note: ing.note ?? "",
})),
steps: recipe.steps.map((step) => ({
id: step.id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
appliesTo: step.appliesTo ?? [],
})),
steps: recipe.steps.map((step) => {
// Show the largest unit that divides evenly into the stored seconds,
// so editing a 90-minute braise shows "90 min" rather than "5400 sec".
const seconds = step.timerSeconds ?? 0;
const timer = seconds > 0 && seconds % 3600 === 0
? { value: String(seconds / 3600), unit: "hours" as const }
: seconds > 0 && seconds % 60 === 0
? { value: String(seconds / 60), unit: "minutes" as const }
: { value: seconds > 0 ? String(seconds) : "", unit: "seconds" as const };
return {
id: step.id,
instruction: step.instruction,
timerSeconds: timer.value,
timerUnit: timer.unit,
appliesTo: step.appliesTo ?? [],
};
}),
photos: recipe.photos.map((photo) => ({
key: photo.storageKey,
isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey),
sizeMb: photo.sizeMb,
})),
coverIcon: recipe.coverIcon,
coverColor: recipe.coverColor,
isBatchCook: recipe.isBatchCook,
// Only pre-fill the manual-nutrition editor with existing values when
// they were actually manually entered — AI-estimated data shouldn't
// silently become "manual" just because the recipe happened to have
// an estimate on file when it was opened for editing.
nutrition: recipe.nutritionManual ? recipe.nutritionData?.perServing ?? null : null,
dishes: recipe.batchDishes.map((dish) => ({
id: dish.id,
name: dish.name,
@@ -71,7 +94,7 @@ export default async function EditRecipePage({ params }: Params) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit recipe</h1>
<h1 className="text-3xl font-bold tracking-tight">{m.recipes.editTitle}</h1>
<p className="text-muted-foreground mt-1">{recipe.title}</p>
</div>
<RecipeForm recipeId={id} defaultValues={defaultValues} />
+47 -14
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play } from "lucide-react";
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play, UserCheck } from "lucide-react";
import { VariationsButton } from "@/components/recipe/variations-button";
import { TranslateButton } from "@/components/recipe/translate-button";
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button";
@@ -12,6 +12,7 @@ import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button";
import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button";
import { PrintButton } from "@/components/recipe/print-button";
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
import { SaveOfflineButton } from "@/components/recipe/save-offline-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
@@ -19,7 +20,8 @@ import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
import { and, eq, or, count, inArray, desc } from "@epicure/db";
import { and, eq, count, desc } from "@epicure/db";
import { visibleToViewer } from "@/lib/visibility";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { buttonVariants } from "@/components/ui/button";
@@ -34,22 +36,31 @@ import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
import { cn, stripMarkdown } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
import { getFeaturePrefs } from "@/lib/feature-prefs";
import { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes";
import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { recipeToMarkdown } from "@/lib/markdown/recipe";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { title: "Recipe" };
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
visibleToViewer(session.user.id)
),
});
return { title: recipe?.title ?? "Recipe" };
}
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck };
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
export default async function RecipePage({ params }: Params) {
@@ -62,11 +73,11 @@ export default async function RecipePage({ params }: Params) {
const DIETARY_LABELS = m.recipe.dietary;
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags, featurePrefs] = await Promise.all([
db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
visibleToViewer(session.user.id)
),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
@@ -92,10 +103,19 @@ export default async function RecipePage({ params }: Params) {
orderBy: desc(cookingHistory.cookedAt),
columns: { batchDishId: true, cookedAt: true },
}),
getFeatureFlagMatrix(),
getFeaturePrefs(session.user.id),
]);
if (!recipe) notFound();
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
const locked = {
variations: !featureFlags.recipe_variations[viewerTier],
drinkPairing: !featureFlags.drink_pairing[viewerTier],
mealPairing: !featureFlags.meal_pairing[viewerTier],
};
const isOwner = recipe.authorId === session.user.id;
const dishCookedAtMap = new Map<string, string>();
@@ -120,7 +140,10 @@ export default async function RecipePage({ params }: Params) {
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="max-w-4xl mx-auto space-y-8 pb-20">
{/* pb-20 keeps the last section (private notes' Save button) clear of
the fixed recipe-chat button, which otherwise overlaps it on short
pages — same fix as recipe-form.tsx's floating save bar. */}
<KeepScreenAwake />
{/* Header */}
<div className="space-y-4">
@@ -163,10 +186,10 @@ export default async function RecipePage({ params }: Params) {
</Tooltip>
)}
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
{!recipe.isBatchCook && (
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
<>
<MealPairingButton recipeId={id} />
<DrinkPairingButton recipeId={id} />
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
</>
)}
{recipe.visibility === "public" && (
@@ -218,9 +241,11 @@ export default async function RecipePage({ params }: Params) {
timerSeconds: s.timerSeconds,
order: s.order,
}))}
locked={locked.variations}
/>
{!isOwner && <ForkRecipeButton recipeId={id} />}
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
<PrintButton recipeId={id} />
<ExportMarkdownButton
markdown={recipeToMarkdown({
@@ -332,6 +357,14 @@ export default async function RecipePage({ params }: Params) {
))}
</div>
)}
{recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{recipe.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
{/* Cover photo */}
@@ -386,7 +419,7 @@ export default async function RecipePage({ params }: Params) {
order: ing.order,
}))}
/>
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} />
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} />
</div>
)}
@@ -406,7 +439,7 @@ export default async function RecipePage({ params }: Params) {
</div>
<div className="flex-1 space-y-1 pt-1">
<p className="leading-relaxed">{step.instruction}</p>
{step.timerSeconds && (
{!!step.timerSeconds && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{step.timerSeconds >= 60
@@ -478,7 +511,7 @@ export default async function RecipePage({ params }: Params) {
<Separator />
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
{featurePrefs.chatbots && <RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />}
</div>
);
}
+49 -6
View File
@@ -7,7 +7,9 @@ import { eq, desc, asc, and, ilike, or, inArray } from "@epicure/db";
import { RecipesHeader } from "@/components/recipe/recipes-header";
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
import { RecipesGrid } from "@/components/recipe/recipes-grid";
import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel";
import { getMessages } from "@/lib/i18n/server";
import { getFeaturePrefs } from "@/lib/feature-prefs";
export const metadata: Metadata = {};
@@ -21,8 +23,26 @@ type SearchParams = Promise<{
tag?: string;
page?: string;
batchCook?: string;
recipeType?: string;
// OS Share Target params (manifest.ts's share_target) — a browser share
// sheet navigates here with these appended.
url?: string;
text?: string;
title?: string;
}>;
const URL_PATTERN = /https?:\/\/\S+/;
/** Web Share Target's GET params vary by sender: some apps put the shared
* link in `url`, others (notably many Android apps) put it inside `text`
* alongside other text. Prefer `url`, fall back to extracting the first
* http(s) URL out of `text`. */
function extractSharedUrl(params: { url?: string; text?: string }): string | undefined {
if (params.url && URL_PATTERN.test(params.url)) return params.url;
const match = params.text?.match(URL_PATTERN);
return match?.[0];
}
const SORT_MAP = {
updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt),
@@ -38,31 +58,49 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const featurePrefs = await getFeaturePrefs(session.user.id);
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = await searchParams;
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
const sharedUrl = extractSharedUrl({ url, text });
const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
? (visibility as "private" | "unlisted" | "public")
const visibilityFilter = visibility && ["private", "unlisted", "public", "followers"].includes(visibility)
? (visibility as "private" | "unlisted" | "public" | "followers")
: undefined;
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
? (difficulty as "easy" | "medium" | "hard")
: undefined;
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
const recipeTypeFilter = recipeType === "dish" || recipeType === "drink" ? recipeType : undefined;
// Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
const where = and(
eq(recipes.authorId, session.user.id),
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
query
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
? or(
ilike(recipes.title, `%${escapedQuery}%`),
ilike(recipes.description, `%${escapedQuery}%`),
// Plain SQL identifiers rather than drizzle's column proxies —
// inside db.query.recipes.findMany's `where`, the relational query
// builder rewrites embedded column refs to the outer query's own
// table alias regardless of which table they actually belong to,
// producing broken SQL (recipes.raw_name doesn't exist). Using
// literal identifiers for the ingredients subquery sidesteps that.
sql`exists (select 1 from recipe_ingredients ri where ri.recipe_id = ${recipes.id} and ri.raw_name ilike ${`%${escapedQuery}%`})`,
sql`exists (select 1 from unnest(${recipes.tags}) as tag where tag ilike ${`%${escapedQuery}%`})`
)
: undefined,
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
recipeTypeFilter ? eq(recipes.recipeType, recipeTypeFilter) : undefined,
);
const [userRecipes, totalRow] = await Promise.all([
@@ -97,13 +135,14 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
if (difficultyFilter) params.set("difficulty", difficultyFilter);
if (tagFilter) params.set("tag", tagFilter);
if (batchCookFilter) params.set("batchCook", batchCookFilter);
if (recipeTypeFilter) params.set("recipeType", recipeTypeFilter);
if (p > 1) params.set("page", String(p));
const qs = params.toString();
return qs ? `/recipes?${qs}` : "/recipes";
};
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
<div className="flex items-center gap-2 text-sm">
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabMine}</span>
<Link href="/recipes/favorites" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
@@ -118,9 +157,11 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""}
initialBatchCook={batchCookFilter ?? ""}
initialRecipeType={recipeTypeFilter ?? ""}
sharedUrl={sharedUrl}
/>
<RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${page}`} recipes={recipesWithFavorites} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
@@ -139,6 +180,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
)}
</div>
)}
{featurePrefs.chatbots && <CookingAssistantPanel />}
</div>
);
}
+5
View File
@@ -1,8 +1,13 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SearchPageContent } from "@/components/search/search-page-content";
type Params = { searchParams: Promise<{ q?: string; difficulty?: string; dietary?: string }> };
export default async function SearchPage({ searchParams }: Params) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams;
return <SearchPageContent initialQuery={q ?? ""} />;
}
+57 -4
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userAiKeys, userModelPrefs, eq } from "@epicure/db";
import { db, userAiKeys, userModelPrefs, users, tierDefinitions, userUsage, eq, and } from "@epicure/db";
import { UNLIMITED, getRecipeCount, getStorageUsedMb } from "@/lib/tiers";
import { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
import { getMessages } from "@/lib/i18n/server";
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -13,7 +16,9 @@ export default async function AiSettingsPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const [aiKeys, modelPrefs] = await Promise.all([
const currentMonth = new Date().toISOString().slice(0, 7);
const [aiKeys, modelPrefs, dbUser] = await Promise.all([
db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, session.user.id),
columns: { provider: true },
@@ -21,10 +26,54 @@ export default async function AiSettingsPage() {
db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session.user.id),
}),
// Tier comes from the DB, not the (up to 5-minute-stale) session cookie
// cache, so a just-changed tier's limits show up immediately here.
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true, isDeveloper: true } }),
]);
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
db.query.tierDefinitions.findFirst({ where: eq(tierDefinitions.tier, dbUser?.tier ?? "free") }),
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
getRecipeCount(session.user.id),
getStorageUsedMb(session.user.id),
]);
const asLimit = (n: number | undefined) => (n === undefined || n === UNLIMITED ? null : n);
const usageMetrics = [
{
label: m.settings.usage.aiCalls,
used: usage?.aiCallsUsed ?? 0,
limit: asLimit(tierDef?.aiCallsPerMonth),
unlimitedLabel: m.settings.usage.unlimited,
},
{
label: m.settings.usage.recipes,
used: recipeCount,
limit: asLimit(tierDef?.maxRecipes),
unlimitedLabel: m.settings.usage.unlimited,
},
{
label: m.settings.usage.storage,
used: storageUsedMb,
limit: asLimit(tierDef?.storageMb),
unit: " MB",
unlimitedLabel: m.settings.usage.unlimited,
},
];
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settings.usage.title}</h2>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage(m.settings.usage.description, { month: currentMonth })}
</p>
</div>
<UsageQuotaSection metrics={usageMetrics} />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settings.byok.title}</h2>
@@ -32,7 +81,11 @@ export default async function AiSettingsPage() {
{m.settings.byok.description}
</p>
</div>
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
{dbUser?.isDeveloper ? (
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
) : (
<DeveloperLockedNotice message={m.settings.developerLockedNotice} />
)}
</section>
<section className="rounded-xl border p-6 space-y-4">
+28 -18
View File
@@ -3,8 +3,9 @@ import { headers } from "next/headers";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, eq } from "@epicure/db";
import { db, apiKeys, users, eq } from "@epicure/db";
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -14,15 +15,20 @@ export default async function ApiKeysPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, session.user.id));
const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const keys = dbUser?.isDeveloper
? await db
.select({
id: apiKeys.id,
name: apiKeys.name,
scope: apiKeys.scope,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, session.user.id))
: [];
return (
<div className="space-y-8">
@@ -41,14 +47,18 @@ export default async function ApiKeysPage() {
{m.settings.apiKeysPage.docsLink}
</Link>
</div>
<ApiKeysManager
initialKeys={keys.map((k) => ({
id: k.id,
name: k.name,
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
createdAt: k.createdAt.toISOString(),
}))}
/>
{!dbUser?.isDeveloper && <DeveloperLockedNotice message={m.settings.developerLockedNotice} />}
{dbUser?.isDeveloper && (
<ApiKeysManager
initialKeys={keys.map((k) => ({
id: k.id,
name: k.name,
scope: k.scope,
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
createdAt: k.createdAt.toISOString(),
}))}
/>
)}
</section>
</div>
);
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { FeatureTogglesForm } from "@/components/settings/feature-toggles-form";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
export default async function FeaturesSettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string })?.locale);
return (
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settingsForm.featureToggles.title}</h2>
<p className="text-sm text-muted-foreground mt-1">
{m.settingsForm.featureToggles.description}
</p>
</div>
<FeatureTogglesForm />
</section>
);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
import { getMessages } from "@/lib/i18n/server";
@@ -7,6 +8,10 @@ export default async function SettingsLayout({ children }: { children: React.Rea
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string })?.locale);
const dbUser = session
? (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
: undefined;
return (
<div className="max-w-5xl mx-auto">
<div className="mb-8">
@@ -14,7 +19,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
<p className="text-muted-foreground mt-1">{m.settings.subtitle}</p>
</div>
<div className="flex flex-col md:flex-row gap-4 md:gap-8 md:items-start">
<SettingsSidebar />
<SettingsSidebar isDeveloper={dbUser?.isDeveloper ?? false} />
<main className="flex-1 min-w-0 space-y-6">{children}</main>
</div>
</div>
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
import { NotificationCategoriesForm } from "@/components/settings/notification-categories-form";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -21,6 +22,16 @@ export default async function NotificationsPage() {
</div>
<PushSubscribeButton />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settingsForm.notificationCategories.title}</h2>
<p className="text-sm text-muted-foreground mt-1">
{m.settingsForm.notificationCategories.description}
</p>
</div>
<NotificationCategoriesForm />
</section>
</div>
);
}
+3 -1
View File
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true },
columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true, useGravatar: true },
});
return (
@@ -26,6 +26,8 @@ export default async function SettingsPage() {
privateBio: dbUser?.privateBio ?? null,
isPrivate: dbUser?.isPrivate ?? false,
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
username: dbUser?.username ?? null,
useGravatar: dbUser?.useGravatar ?? false,
}}
/>
);
+13 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, accounts, users, eq, and } from "@epicure/db";
import { SecurityForm } from "@/components/settings/security-form";
export const metadata: Metadata = {};
@@ -9,5 +10,16 @@ export default async function SecurityPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return <SecurityForm currentEmail={session.user.email} />;
const [dbUser, credentialAccount] = await Promise.all([
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { twoFactorEnabled: true } }),
db.query.accounts.findFirst({ where: and(eq(accounts.userId, session.user.id), eq(accounts.providerId, "credential")) }),
]);
return (
<SecurityForm
currentEmail={session.user.email}
twoFactorEnabled={dbUser?.twoFactorEnabled ?? false}
hasPassword={!!credentialAccount}
/>
);
}
@@ -1,10 +1,17 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {};
export default function WebhookDocsPage() {
return (
<div className="max-w-3xl mx-auto space-y-10 py-8">
<Link href="/settings/webhooks" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-3.5 w-3.5" />
Back to Webhooks
</Link>
<div>
<h1 className="text-2xl font-bold mb-2">Webhook Documentation</h1>
<p className="text-muted-foreground">
+28 -20
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, webhooks, eq } from "@epicure/db";
import { db, webhooks, users, eq } from "@epicure/db";
import { WebhooksManager } from "@/components/settings/webhooks-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -12,16 +13,20 @@ export default async function WebhooksPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const rows = await db
.select({
id: webhooks.id,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, session.user.id));
const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const rows = dbUser?.isDeveloper
? await db
.select({
id: webhooks.id,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, session.user.id))
: [];
return (
<div className="space-y-8">
@@ -32,15 +37,18 @@ export default async function WebhooksPage() {
{m.settings.webhooksPage.description}
</p>
</div>
<WebhooksManager
initialWebhooks={rows.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt.toISOString(),
}))}
/>
{!dbUser?.isDeveloper && <DeveloperLockedNotice message={m.settings.developerLockedNotice} />}
{dbUser?.isDeveloper && (
<WebhooksManager
initialWebhooks={rows.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt.toISOString(),
}))}
/>
)}
</section>
</div>
);
@@ -10,6 +10,7 @@ import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopp
import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button";
import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
@@ -48,13 +49,20 @@ export default async function ShoppingListPage({ params }: Params) {
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
</p>
</div>
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
{access.role === "owner" && <ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} />}
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
{m.common.print}
</Link>
{access.role === "owner" && (
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
)}
<Tooltip>
<TooltipTrigger render={
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={m.common.print}>
<Printer className="h-4 w-4" />
</Link>
} />
<TooltipContent>{m.common.print}</TooltipContent>
</Tooltip>
<ExportMarkdownButton
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
filename={list.name}
@@ -63,6 +71,7 @@ export default async function ShoppingListPage({ params }: Params) {
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
)}
</div>
</TooltipProvider>
</div>
<ShoppingListView
listId={id}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, supportTickets, eq, desc } from "@epicure/db";
import { SupportManager } from "@/components/support/support-manager";
import { getMessages } from "@/lib/i18n/server";
import { getPublicUrl } from "@/lib/storage";
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
export const metadata: Metadata = {};
export default async function SupportPage({
searchParams,
}: {
searchParams: Promise<{ upgrade?: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const { upgrade } = await searchParams;
const upgradeFeature = FEATURE_DEFINITIONS.find((f) => f.key === upgrade);
const prefill = upgradeFeature
? { type: "suggestion" as const, title: `Interested in upgrading for: ${upgradeFeature.label}` }
: undefined;
const rows = await db.query.supportTickets.findMany({
where: eq(supportTickets.userId, session.user.id),
orderBy: desc(supportTickets.createdAt),
with: { attachments: true },
});
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.support.title}</h1>
<p className="text-muted-foreground text-sm mt-1">{m.support.subtitle}</p>
</div>
<SupportManager
prefill={prefill}
initialTickets={rows.map((r) => ({
id: r.id,
type: r.type,
title: r.title,
description: r.description,
status: r.status,
giteaIssueUrl: r.giteaIssueUrl,
createdAt: r.createdAt.toISOString(),
attachments: r.attachments.map((a) => ({
id: a.id,
contentType: a.contentType,
url: getPublicUrl(a.storageKey),
})),
}))}
/>
</div>
);
}
+2 -3
View File
@@ -24,6 +24,7 @@ import { FollowButton } from "@/components/social/follow-button";
import { BlockButton } from "@/components/social/block-button";
import { MessageButton } from "@/components/social/message-button";
import { getPublicUrl } from "@/lib/storage";
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
const PAGE_SIZE = 24;
@@ -247,9 +248,7 @@ export default async function UserProfilePage({ params, searchParams }: Params)
className="object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
🍽️
</div>
<RecipeCoverPlaceholder recipe={recipe} />
)}
</div>
<div className="p-2">
+6 -2
View File
@@ -25,7 +25,7 @@ export default function LoginPage() {
e.preventDefault();
setUnverified(false);
setLoading(true);
const { error } = await authClient.signIn.email({ email, password, callbackURL: "/recipes" });
const { data, error } = await authClient.signIn.email({ email, password, callbackURL: "/recipes" });
setLoading(false);
if (error) {
if (error.code === "EMAIL_NOT_VERIFIED") {
@@ -33,7 +33,11 @@ export default function LoginPage() {
} else {
toast.error(error.message ?? "Sign in failed");
}
} else {
} else if (!(data as { twoFactorRedirect?: boolean } | null)?.twoFactorRedirect) {
// 2FA-enabled accounts resolve with no `error` but `twoFactorRedirect: true` —
// no full session exists yet, so pushing to /recipes here races the SDK's own
// window.location.href to /verify-2fa (see twoFactorClient's onSuccess hook)
// and can lose, bouncing off middleware back to /login with no visible error.
router.push("/recipes");
}
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
export default function Verify2faPage() {
const router = useRouter();
const t = useTranslations("auth");
const [code, setCode] = useState("");
const [useBackupCode, setUseBackupCode] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = useBackupCode
? await authClient.twoFactor.verifyBackupCode({ code: code.trim() })
: await authClient.twoFactor.verifyTotp({ code: code.trim() });
setLoading(false);
if (error) {
setError(error.message ?? t("twoFactorInvalidCode"));
} else {
router.push("/recipes");
}
}
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-semibold tracking-tight">{t("twoFactorTitle")}</CardTitle>
<CardDescription>
{useBackupCode ? t("twoFactorBackupDescription") : t("twoFactorAppDescription")}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">{useBackupCode ? t("twoFactorBackupCodeLabel") : t("twoFactorCodeLabel")}</Label>
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
autoComplete="one-time-code"
autoFocus
inputMode={useBackupCode ? "text" : "numeric"}
placeholder={useBackupCode ? undefined : "000000"}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" type="submit" disabled={loading || !code.trim()}>
{loading ? t("twoFactorVerifying") : t("twoFactorVerify")}
</Button>
</CardContent>
</form>
<CardFooter className="flex justify-center">
<button
type="button"
onClick={() => { setUseBackupCode(!useBackupCode); setCode(""); setError(null); }}
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
{useBackupCode ? t("twoFactorUseApp") : t("twoFactorUseBackupCode")}
</button>
</CardFooter>
</Card>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { getMessages } from "@/lib/i18n/server";
import { getEffectiveMarketingLocale } from "@/lib/marketing-locale";
export const metadata: Metadata = {
title: "About — Epicure",
description: "The story behind Epicure.",
};
export default async function AboutPage() {
const m = getMessages(await getEffectiveMarketingLocale());
const t = m.marketing.about;
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t.title}</h1>
<div className="space-y-4 text-muted-foreground leading-relaxed">
{t.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Mail } from "lucide-react";
import { getMessages } from "@/lib/i18n/server";
import { getEffectiveMarketingLocale } from "@/lib/marketing-locale";
export const metadata: Metadata = {
title: "Contact — Epicure",
description: "Get in touch with the Epicure team.",
};
const SUPPORT_EMAIL = "support@epicure.app";
export default async function ContactPage() {
const m = getMessages(await getEffectiveMarketingLocale());
const t = m.marketing.contact;
return (
<div className="container mx-auto px-4 py-16 max-w-lg text-center space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t.title}</h1>
<p className="text-muted-foreground">{t.subtitle}</p>
<a
href={`mailto:${SUPPORT_EMAIL}`}
className="inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-accent transition-colors"
>
<Mail className="h-4 w-4" />
{SUPPORT_EMAIL}
</a>
</div>
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Camera, ListChecks, Utensils, Wand2, Sparkle, Replace } from "lucide-react";
import { getMessages } from "@/lib/i18n/server";
import { getEffectiveMarketingLocale } from "@/lib/marketing-locale";
import { isSignupsDisabled } from "@/lib/site-settings";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "Features — Epicure",
description: "AI recipe generation, meal planning, pantry tracking, cook mode, and a cooking assistant that can act on your behalf.",
};
const FEATURES = [
{ icon: Sparkles, key: "ai" },
{ icon: Camera, key: "photoImport" },
{ icon: MessageCircle, key: "assistant" },
{ icon: Utensils, key: "cookMode" },
{ icon: Wand2, key: "variations" },
{ icon: Sparkle, key: "recommendations" },
{ icon: Replace, key: "substitution" },
{ icon: Calendar, key: "mealPlan" },
{ icon: Package, key: "pantry" },
{ icon: ListChecks, key: "shoppingList" },
{ icon: Users, key: "social" },
{ icon: ChefHat, key: "batchCook" },
] as const;
export default async function FeaturesPage() {
const [locale, signupsDisabled] = await Promise.all([getEffectiveMarketingLocale(), isSignupsDisabled()]);
const m = getMessages(locale);
const t = m.marketing.features;
return (
<div className="container mx-auto px-4 py-16 space-y-12">
<div className="text-center space-y-4 max-w-2xl mx-auto">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight">{t.title}</h1>
<p className="text-lg text-muted-foreground">{t.subtitle}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{FEATURES.map(({ icon: Icon, key }) => (
<div key={key} className="rounded-xl border bg-card p-6 space-y-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="font-semibold text-lg">{t.items[key].title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{t.items[key].description}</p>
</div>
))}
</div>
<div className="text-center pt-4">
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{signupsDisabled ? m.marketing.nav.signupClosed : t.cta}
</Link>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { MarketingNav } from "@/components/marketing/marketing-nav";
import { MarketingFooter } from "@/components/marketing/marketing-footer";
import { getEffectiveMarketingLocale } from "@/lib/marketing-locale";
export default async function MarketingLayout({ children }: { children: React.ReactNode }) {
const locale = await getEffectiveMarketingLocale();
return (
<div className="flex min-h-screen flex-col">
<MarketingNav />
<main className="flex-1">{children}</main>
<MarketingFooter locale={locale} />
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/lib/auth/server";
import { getMessages } from "@/lib/i18n/server";
import { getEffectiveMarketingLocale } from "@/lib/marketing-locale";
import { isSignupsDisabled } from "@/lib/site-settings";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Sparkles, Calendar, Package, MessageCircle, Users, ChefHat, Utensils, Wand2, Sparkle } from "lucide-react";
const HIGHLIGHTS = [
{ icon: Sparkles, key: "ai" },
{ icon: MessageCircle, key: "assistant" },
{ icon: Calendar, key: "mealPlan" },
{ icon: Package, key: "pantry" },
{ icon: Utensils, key: "cookMode" },
{ icon: Wand2, key: "variations" },
{ icon: Users, key: "social" },
{ icon: ChefHat, key: "batchCook" },
{ icon: Sparkle, key: "recommendations" },
] as const;
export default async function MarketingHomePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (session) redirect("/recipes");
const [locale, signupsDisabled] = await Promise.all([getEffectiveMarketingLocale(), isSignupsDisabled()]);
const m = getMessages(locale);
const t = m.marketing.home;
const ctaLabel = signupsDisabled ? m.marketing.nav.signupClosed : t.ctaPrimary;
return (
<div>
<section className="container mx-auto px-4 py-20 sm:py-28 text-center space-y-6">
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight max-w-2xl mx-auto">
{t.heroTitle}
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
{t.heroSubtitle}
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{ctaLabel}
</Link>
<Link href="/features" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
{t.ctaSecondary}
</Link>
</div>
</section>
<section className="container mx-auto px-4 py-16 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{HIGHLIGHTS.map(({ icon: Icon, key }) => (
<div key={key} className="rounded-xl border bg-card p-6 space-y-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="font-semibold">{t.highlights[key].title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{t.highlights[key].description}</p>
</div>
))}
</div>
</section>
<section className="container mx-auto px-4 py-16 border-t text-center space-y-4">
<h2 className="text-2xl font-semibold">{t.bottomCtaTitle}</h2>
<p className="text-muted-foreground max-w-lg mx-auto">{t.bottomCtaSubtitle}</p>
<Link href="/signup" className={cn(buttonVariants({ size: "lg" }))}>
{ctaLabel}
</Link>
</section>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { AlertTriangle } from "lucide-react";
export const metadata: Metadata = {
title: "Privacy Policy — Epicure",
description: "How Epicure collects, uses, and protects your data.",
};
export default function PrivacyPage() {
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-4 flex gap-3 text-sm">
<AlertTriangle className="h-4 w-4 text-yellow-600 shrink-0 mt-0.5" />
<p>
<strong>Draft — not yet reviewed by counsel.</strong> This page is a
placeholder structure, not a finished, legally-binding policy. Replace
before accepting real signups/payments (see <code>STRIPE_PLAN.md</code> §10).
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Privacy Policy</h1>
<div className="space-y-6 text-sm text-muted-foreground leading-relaxed">
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Data we collect</h2>
<p>Account information (email, name), recipes and content you create, usage data (AI calls, storage), and payment information (processed by Stripe — we never store card details ourselves).</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">How we use it</h2>
<p>To provide the service (store your recipes, run AI features you request, process subscriptions), and nothing beyond that — no data is sold to third parties.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Third parties</h2>
<p>AI providers (OpenAI/Anthropic/OpenRouter/Ollama, depending on your configuration), Stripe for payments, and your own configured integrations (webhooks, API keys) that you explicitly set up.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Your rights</h2>
<p>Access, export, correct, or delete your data at any time from Settings, or by contacting us (see Contact page). EU/EEA users have rights under GDPR.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Contact</h2>
<p>Questions about this policy — see the Contact page.</p>
</section>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { AlertTriangle } from "lucide-react";
export const metadata: Metadata = {
title: "Terms of Service — Epicure",
description: "The terms governing use of Epicure.",
};
export default function TermsPage() {
return (
<div className="container mx-auto px-4 py-16 max-w-2xl space-y-6">
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-4 flex gap-3 text-sm">
<AlertTriangle className="h-4 w-4 text-yellow-600 shrink-0 mt-0.5" />
<p>
<strong>Draft — not yet reviewed by counsel.</strong> This page is a
placeholder structure, not finished, legally-binding terms. Replace
before accepting real signups/payments (see <code>STRIPE_PLAN.md</code> §10).
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Terms of Service</h1>
<div className="space-y-6 text-sm text-muted-foreground leading-relaxed">
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Using Epicure</h2>
<p>You&apos;re responsible for the content you create and share, and for keeping your account credentials secure.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Subscriptions</h2>
<p>Paid tiers (Pro, Family) renew automatically until cancelled. Cancel anytime from Settings — access continues until the end of the paid period.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Content ownership</h2>
<p>You own what you create. We only use it to provide the service to you (and, if you set a recipe public/unlisted/followers-visible, to whoever you&apos;ve chosen to share it with).</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">AI-generated content</h2>
<p>AI-generated recipes are provided as-is — always use your own judgment, especially around allergens and food safety.</p>
</section>
<section className="space-y-2">
<h2 className="font-semibold text-foreground">Changes</h2>
<p>We may update these terms; meaningful changes will be communicated in-app.</p>
</section>
</div>
</div>
);
}
+31 -79
View File
@@ -1,12 +1,18 @@
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, XCircle, Database, Cpu } from "lucide-react";
import Link from "next/link";
import { getAllSiteSettings } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
const SETTING_GROUP = {
title: "Provider Keys & Routing",
description: "Override the environment variable API keys at runtime (encrypted at rest), and set the OpenRouter/Ollama routing defaults. DB values here take precedence over environment variables — clear a value to fall back to the env var.",
keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const,
};
function resolveProvider(settings: Record<string, { value: string | null }>) {
if (settings["OPENROUTER_API_KEY"]?.value) return { provider: "OpenRouter", description: "Routes to many models via openrouter.ai" };
if (settings["OPENAI_API_KEY"]?.value) return { provider: "OpenAI", description: "Direct OpenAI API (GPT-4 etc.)" };
@@ -16,93 +22,39 @@ function resolveProvider(settings: Record<string, { value: string | null }>) {
}
export default async function AdminAiConfigPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings();
const active = resolveProvider(settings);
const keyRows = [
{ key: "OPENROUTER_API_KEY", label: "OPENROUTER_API_KEY" },
{ key: "OPENAI_API_KEY", label: "OPENAI_API_KEY" },
{ key: "ANTHROPIC_API_KEY", label: "ANTHROPIC_API_KEY" },
];
return (
<div className="space-y-6">
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1>
<p className="text-muted-foreground text-sm mt-1">
Current AI provider settings. Override values in{" "}
<Link href="/admin/settings" className="text-primary underline-offset-4 hover:underline">
Site Settings
</Link>
.
Provider keys, routing, and default models for AI generation.
</p>
<div className="flex items-center gap-2 mt-3">
<span className="text-xs text-muted-foreground">Fallback provider (no default set below):</span>
<Badge variant="secondary" className="text-xs">{active.provider}</Badge>
<span className="text-xs text-muted-foreground">— {active.description}</span>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Active Provider</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Badge className="text-sm px-3 py-1">{active.provider}</Badge>
<span className="text-muted-foreground text-sm">{active.description}</span>
</div>
<p className="text-xs text-muted-foreground mt-3">
Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins).
</p>
</CardContent>
</Card>
<AdminSettingsForm group={SETTING_GROUP} settings={settings} />
<Card>
<CardHeader>
<CardTitle className="text-base">API Keys</CardTitle>
</CardHeader>
<CardContent>
{keyRows.map(({ key, label }) => {
const meta = settings[key];
const present = !!meta?.value;
return (
<div key={key} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-2">
{present ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
<span className="font-mono text-sm">{label}</span>
</div>
<div className="flex items-center gap-2">
{present && meta?.fromDb && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> DB override
</span>
)}
{present && !meta?.fromDb && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" /> from .env
</span>
)}
<Badge variant={present ? "default" : "secondary"}>
{present ? "Configured" : "Not set"}
</Badge>
</div>
</div>
);
})}
<div className="flex items-center justify-between py-2 border-b">
<span className="font-mono text-sm text-muted-foreground">OLLAMA_BASE_URL</span>
<span className="text-sm text-muted-foreground font-mono">
{settings["OLLAMA_BASE_URL"]?.value || "http://localhost:11434 (default)"}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="font-mono text-sm text-muted-foreground">OPENROUTER_DEFAULT_MODEL</span>
<span className="text-sm text-muted-foreground font-mono">
{settings["OPENROUTER_DEFAULT_MODEL"]?.value || "google/gemini-flash-1.5 (default)"}
</span>
</div>
</CardContent>
</Card>
<AdminDefaultModelForm
initialValues={{
DEFAULT_TEXT_PROVIDER: settings["DEFAULT_TEXT_PROVIDER"]?.value ?? null,
DEFAULT_TEXT_MODEL: settings["DEFAULT_TEXT_MODEL"]?.value ?? null,
DEFAULT_CHAT_PROVIDER: settings["DEFAULT_CHAT_PROVIDER"]?.value ?? null,
DEFAULT_CHAT_MODEL: settings["DEFAULT_CHAT_MODEL"]?.value ?? null,
DEFAULT_VISION_PROVIDER: settings["DEFAULT_VISION_PROVIDER"]?.value ?? null,
DEFAULT_VISION_MODEL: settings["DEFAULT_VISION_MODEL"]?.value ?? null,
DEFAULT_MEAL_PLAN_PROVIDER: settings["DEFAULT_MEAL_PLAN_PROVIDER"]?.value ?? null,
DEFAULT_MEAL_PLAN_MODEL: settings["DEFAULT_MEAL_PLAN_MODEL"]?.value ?? null,
}}
initialToolCallingEnabled={settings["AI_TOOL_CALLING_ENABLED"]?.value !== "false"}
/>
</div>
);
}
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { db, auditLogs, users, eq, desc } from "@epicure/db";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -10,6 +11,7 @@ interface PageProps {
}
export default async function AdminAuditLogsPage({ searchParams }: PageProps) {
await requireFullAdminPage();
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10));
const offset = (page - 1) * PAGE_SIZE;
+3 -1
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import { ChangelogList } from "@/components/shared/changelog-list";
import { APP_VERSION } from "@/lib/changelog";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default function AdminChangelogPage() {
export default async function AdminChangelogPage() {
await requireFullAdminPage();
return (
<div className="space-y-6">
<div>
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
import { db, users, recipes, userUsage, supportTickets, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { BarChart } from "@/components/admin/charts/bar-chart";
import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
const DAYS = 30;
function lastNDays(n: number): string[] {
const out: string[] = [];
const now = new Date();
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
out.push(d.toISOString().slice(0, 10));
}
return out;
}
function formatShortDate(d: string) {
const date = new Date(`${d}T00:00:00Z`);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
}
function lastNMonths(n: number): string[] {
const out: string[] = [];
const now = new Date();
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return out;
}
export default async function AdminInsightsPage() {
await requireFullAdminPage();
const since = new Date();
since.setDate(since.getDate() - DAYS);
// Promise.allSettled, not all — six independent aggregate queries feeding
// six independent charts; one query breaking (e.g. a table that's empty
// in a fresh install) shouldn't take down every chart on the page.
const results = await Promise.allSettled([
db
.select({ day: sql<string>`to_char(${users.createdAt}, 'YYYY-MM-DD')`.as("day"), n: sql<number>`count(*)::int` })
.from(users)
.where(gte(users.createdAt, since))
.groupBy(sql`to_char(${users.createdAt}, 'YYYY-MM-DD')`),
db
.select({
day: sql<string>`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`.as("day"),
aiGenerated: recipes.aiGenerated,
n: sql<number>`count(*)::int`,
})
.from(recipes)
.where(gte(recipes.createdAt, since))
.groupBy(sql`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`, recipes.aiGenerated),
db.select({ tier: users.tier, n: sql<number>`count(*)::int` }).from(users).groupBy(users.tier),
db.select({ visibility: recipes.visibility, n: sql<number>`count(*)::int` }).from(recipes).groupBy(recipes.visibility),
db
.select({ month: userUsage.month, n: sql<number>`coalesce(sum(${userUsage.aiCallsUsed}), 0)::int` })
.from(userUsage)
.groupBy(userUsage.month),
db.select({ status: supportTickets.status, n: sql<number>`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status),
]);
for (const r of results) {
if (r.status === "rejected") console.error("[admin/insights] query failed", r.reason);
}
const [signupRows, recipeRows, tierRows, visibilityRows, usageRows, ticketRows] = results.map((r) =>
r.status === "fulfilled" ? r.value : []
) as [
{ day: string; n: number }[],
{ day: string; aiGenerated: boolean; n: number }[],
{ tier: "free" | "pro" | "family"; n: number }[],
{ visibility: "private" | "unlisted" | "public" | "followers"; n: number }[],
{ month: string; n: number }[],
{ status: "open" | "triaged" | "closed"; n: number }[],
];
const signupByDay = new Map(signupRows.map((r) => [r.day, r.n]));
const signupSeries = lastNDays(DAYS).map((day) => ({ date: day, value: signupByDay.get(day) ?? 0 }));
const recipesByDay = new Map<string, { manual: number; ai: number }>();
for (const r of recipeRows) {
const entry = recipesByDay.get(r.day) ?? { manual: 0, ai: 0 };
if (r.aiGenerated) entry.ai += r.n; else entry.manual += r.n;
recipesByDay.set(r.day, entry);
}
const recipesSeries = lastNDays(DAYS).map((day) => {
const entry = recipesByDay.get(day) ?? { manual: 0, ai: 0 };
return { label: formatShortDate(day), values: [entry.manual, entry.ai] };
});
const TIER_ORDER = ["free", "pro", "family"] as const;
const tierByKey = new Map(tierRows.map((r) => [r.tier, r.n]));
const tierData = TIER_ORDER.map((tier) => ({ label: tier, values: [tierByKey.get(tier) ?? 0] }));
const VISIBILITY_ORDER = ["private", "unlisted", "followers", "public"] as const;
const visByKey = new Map(visibilityRows.map((r) => [r.visibility, r.n]));
const visibilityData = VISIBILITY_ORDER.map((v) => ({ label: v, values: [visByKey.get(v) ?? 0] }));
const usageByMonth = new Map(usageRows.map((r) => [r.month, r.n]));
const usageSeries = lastNMonths(6).map((month) => ({ date: month, value: usageByMonth.get(month) ?? 0 }));
const STATUS_ORDER = ["open", "triaged", "closed"] as const;
const statusByKey = new Map(ticketRows.map((r) => [r.status, r.n]));
const statusData = STATUS_ORDER.map((s) => ({ label: s, values: [statusByKey.get(s) ?? 0] }));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Insights</h1>
<p className="text-muted-foreground text-sm mt-1">Trends and breakdowns across the last {DAYS} days (or 6 months for usage).</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">New signups</CardTitle>
<CardDescription>Daily, last {DAYS} days</CardDescription>
</CardHeader>
<CardContent>
<TimeSeriesChart data={signupSeries} dateFormat="day" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recipes created</CardTitle>
<CardDescription>Daily, manual vs AI-generated</CardDescription>
</CardHeader>
<CardContent>
<BarChart data={recipesSeries} seriesLabels={["Manual", "AI-generated"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Users by tier</CardTitle>
<CardDescription>All-time</CardDescription>
</CardHeader>
<CardContent>
<BarChart data={tierData} seriesLabels={["Users"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recipes by visibility</CardTitle>
<CardDescription>All-time</CardDescription>
</CardHeader>
<CardContent>
<BarChart data={visibilityData} seriesLabels={["Recipes"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">AI calls</CardTitle>
<CardDescription>Monthly total across all users, last 6 months</CardDescription>
</CardHeader>
<CardContent>
<TimeSeriesChart data={usageSeries} dateFormat="month" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Support tickets by status</CardTitle>
<CardDescription>All-time</CardDescription>
</CardHeader>
<CardContent>
<BarChart data={statusData} seriesLabels={["Tickets"]} />
</CardContent>
</Card>
</div>
</div>
);
}
+2
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import { listInvites } from "@/lib/invites";
import { InvitesManager } from "@/components/admin/invites-manager";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminInvitesPage() {
await requireFullAdminPage();
const invites = await listInvites();
const appUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
+25 -22
View File
@@ -1,31 +1,34 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import Link from "next/link";
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History } from "lucide-react";
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook } from "lucide-react";
import { cn } from "@/lib/utils";
import { getStaffRole } from "@/lib/require-admin-page";
import { redirect } from "next/navigation";
// adminOnly items are hidden from moderators (and, redundantly but safely,
// each of those pages also redirects moderators away server-side via
// requireFullAdminPage — the nav filter here is UX, not the real gate).
const adminNav = [
{ href: "/admin", label: "Overview", icon: BarChart3 },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/invites", label: "Invites", icon: Mail },
{ href: "/admin/recipes", label: "Recipes", icon: BookOpen },
{ href: "/admin/reports", label: "Reports", icon: Flag },
{ href: "/admin/tiers", label: "Tier Limits", icon: Gauge },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList },
{ href: "/admin/storage", label: "Storage", icon: HardDrive },
{ href: "/admin/ai-config", label: "AI Config", icon: Bot },
{ href: "/admin/settings", label: "Settings", icon: Settings },
{ href: "/admin/changelog", label: "Changelog", icon: History },
{ href: "/admin", label: "Overview", icon: BarChart3, adminOnly: true },
{ href: "/admin/insights", label: "Insights", icon: TrendingUp, adminOnly: true },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/invites", label: "Invites", icon: Mail, adminOnly: true },
{ href: "/admin/recipes", label: "Recipes", icon: BookOpen, adminOnly: false },
{ href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false },
{ href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true },
{ href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true },
{ href: "/admin/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true },
{ href: "/admin/storage", label: "Storage", icon: HardDrive, adminOnly: true },
{ href: "/admin/ai-config", label: "AI Config", icon: Bot, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
{ href: "/admin/changelog", label: "Changelog", icon: History, adminOnly: true },
];
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const role = await getStaffRole();
if (!role) redirect("/recipes");
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id));
if (dbUser?.role !== "admin") redirect("/recipes");
const visibleNav = role === "admin" ? adminNav : adminNav.filter((item) => !item.adminOnly);
return (
<div className="flex flex-col md:flex-row min-h-screen">
@@ -33,7 +36,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<div className="flex items-center justify-between gap-2 p-4 border-b font-semibold">
<span className="flex items-center gap-2">
<Shield className="h-4 w-4 text-destructive" />
Admin
{role === "admin" ? "Admin" : "Moderator"}
</span>
<Link
href="/recipes"
@@ -44,7 +47,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</Link>
</div>
<nav className="flex overflow-x-auto gap-1 p-2 md:flex-col md:overflow-visible md:flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{adminNav.map(({ href, label, icon: Icon }) => (
{visibleNav.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
+68 -17
View File
@@ -1,52 +1,103 @@
import type { Metadata } from "next";
import Link from "next/link";
import { db } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos } from "@epicure/db";
import { count, eq, sql } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos, ratings, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
import { count, eq, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, BookOpen, Sparkles, Image, History } from "lucide-react";
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminPage() {
await requireFullAdminPage();
const currentMonth = new Date().toISOString().slice(0, 7);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([
const [
userCount,
recipeCount,
proUserCount,
publicRecipeCount,
aiUsage,
imageCount,
storageUsage,
newUsers7d,
newRecipes7d,
cooked7d,
pendingReports,
webhookCount,
apiKeyCount,
] = await Promise.all([
db.select({ count: count() }).from(users).then((r) => r[0]),
db.select({ count: count() }).from(recipes).then((r) => r[0]),
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
db.select({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")).then((r) => r[0]),
db
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
.from(userUsage)
.where(eq(userUsage.month, currentMonth))
.then((r) => r[0]),
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
Promise.all([
db.select({ total: sql<string>`coalesce(sum(${recipePhotos.sizeMb}), 0)` }).from(recipePhotos).then((r) => r[0]),
db.select({ total: sql<string>`coalesce(sum(${ratings.photoSizeMb}), 0)` }).from(ratings).then((r) => r[0]),
db.select({ total: sql<string>`coalesce(sum(${users.avatarSizeMb}), 0)` }).from(users).then((r) => r[0]),
]).then(([photos, reviews, avatars]) => Number(photos?.total ?? 0) + Number(reviews?.total ?? 0) + Number(avatars?.total ?? 0)),
db.select({ count: count() }).from(users).where(gte(users.createdAt, sevenDaysAgo)).then((r) => r[0]),
db.select({ count: count() }).from(recipes).where(gte(recipes.createdAt, sevenDaysAgo)).then((r) => r[0]),
db.select({ count: count() }).from(cookingHistory).where(gte(cookingHistory.cookedAt, sevenDaysAgo)).then((r) => r[0]),
db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")).then((r) => r[0]),
db.select({ count: count() }).from(webhooks).where(eq(webhooks.active, true)).then((r) => r[0]),
db.select({ count: count() }).from(apiKeys).then((r) => r[0]),
]);
const stats = [
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
{ label: "Total Recipes", value: recipeCount?.count ?? 0, icon: BookOpen },
{ label: "New Users (7d)", value: newUsers7d?.count ?? 0, icon: UserPlus },
{ label: "Total Recipes", value: recipeCount?.count ?? 0, sub: `${publicRecipeCount?.count ?? 0} public`, icon: BookOpen },
{ label: "New Recipes (7d)", value: newRecipes7d?.count ?? 0, icon: BookOpen },
{ label: "Cooked (7d)", value: cooked7d?.count ?? 0, icon: ChefHat },
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
{ label: "Total Storage", value: storageUsage, unit: "MB", icon: HardDrive },
{
label: "Pending Reports",
value: pendingReports?.count ?? 0,
icon: Flag,
href: "/admin/reports",
highlight: (pendingReports?.count ?? 0) > 0,
},
{ label: "Active Webhooks", value: webhookCount?.count ?? 0, icon: Webhook },
{ label: "API Keys Issued", value: apiKeyCount?.count ?? 0, icon: KeyRound },
];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(({ label, value, sub, icon: Icon }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
))}
{stats.map(({ label, value, sub, unit, icon: Icon, href, highlight }) => {
const card = (
<Card className={highlight ? "border-destructive/50" : undefined}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className={highlight ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground"} />
</CardHeader>
<CardContent>
<div className={highlight ? "text-2xl font-bold text-destructive" : "text-2xl font-bold"}>
{value.toLocaleString()}{unit ? ` ${unit}` : ""}
</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
return (
<div key={label}>
{href ? <Link href={href}>{card}</Link> : card}
</div>
);
})}
</div>
<Card>
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { db, recipes, users, eq, desc, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { UnpublishRecipeButton } from "@/components/admin/unpublish-recipe-button";
export const metadata: Metadata = {};
@@ -10,6 +11,7 @@ const PAGE_SIZE = 100;
const VISIBILITY_COLORS = {
public: "default",
unlisted: "outline",
followers: "outline",
private: "secondary",
} as const;
@@ -60,12 +62,13 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</th>
<th className="px-4 py-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{publicRecipes.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No public recipes yet.
</td>
</tr>
@@ -103,6 +106,9 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
View
</Link>
</td>
<td className="px-4 py-3">
<UnpublishRecipeButton recipeId={recipe.id} />
</td>
</tr>
))
)}
+23 -11
View File
@@ -2,28 +2,39 @@ import type { Metadata } from "next";
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { SignupsToggle } from "@/components/admin/signups-toggle";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
const SETTING_GROUPS = [
{
title: "AI Provider Keys",
description: "Override the environment variable API keys at runtime. Values are encrypted at rest.",
keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"] as const,
},
{
title: "AI Configuration",
description: "Provider routing and model defaults.",
keys: ["OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const,
},
{
title: "Push Notifications (VAPID)",
description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys",
keys: ["NEXT_PUBLIC_VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY"] as const,
},
{
title: "Gitea Integration",
description:
"Support tickets submitted in-app automatically open an issue on this Gitea repo. Setup: " +
"GITEA_URL = your Gitea base URL, no trailing slash (e.g. https://git.example.com). " +
"GITEA_REPO = owner/repo (e.g. owner/my-app). " +
"GITEA_TOKEN = a personal access token — Gitea → Settings → Applications → Generate New Token — " +
"with the \"issue\" scope (read + write) only; nothing broader is needed since this integration " +
"never touches code or repo settings, just creates issues.",
keys: ["GITEA_URL", "GITEA_TOKEN", "GITEA_REPO"] as const,
},
{
title: "USDA FoodData Central",
description:
"Improves recipe nutrition estimates with real per-ingredient data instead of AI-only guesses, " +
"for ingredients whose quantity/unit can be converted to grams. Free — get a key at " +
"fdc.nal.usda.gov/api-key-signup. Unset falls back to the previous AI-only estimation for every ingredient.",
keys: ["USDA_API_KEY"] as const,
},
];
export default async function AdminSettingsPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings();
const signupsDisabled = await isSignupsDisabled();
@@ -33,7 +44,8 @@ export default async function AdminSettingsPage() {
<h1 className="text-2xl font-bold tracking-tight">Site Settings</h1>
<p className="text-muted-foreground text-sm mt-1">
Override .env values at runtime. DB values take precedence over environment variables.
Clear a value to fall back to the environment variable.
Clear a value to fall back to the environment variable. AI provider keys and model
defaults have moved to AI Config.
</p>
</div>
+14 -1
View File
@@ -3,10 +3,12 @@ import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HardDrive } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminStoragePage() {
await requireFullAdminPage();
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db
@@ -41,6 +43,7 @@ export default async function AdminStoragePage() {
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
const familyPhotos = Number(photosByTier.find((r) => r.tier === "family")?.photoCount ?? 0);
return (
<div className="space-y-6">
@@ -51,7 +54,7 @@ export default async function AdminStoragePage() {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle>
@@ -82,6 +85,16 @@ export default async function AdminStoragePage() {
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Family Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{familyPhotos.toLocaleString()}</div>
</CardContent>
</Card>
</div>
<div>
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { db, supportTickets, desc } from "@epicure/db";
import { AdminSupportManager } from "@/components/admin/admin-support-manager";
import { getPublicUrl } from "@/lib/storage";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminSupportPage() {
await requireFullAdminPage();
const rows = await db.query.supportTickets.findMany({
orderBy: desc(supportTickets.createdAt),
with: {
attachments: true,
user: { columns: { email: true, username: true } },
},
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Support Tickets</h1>
<p className="text-muted-foreground text-sm mt-1">
Bug reports, suggestions, and questions submitted through the app.
</p>
</div>
<AdminSupportManager
initialTickets={rows.map((r) => ({
id: r.id,
userEmail: r.user.email,
username: r.user.username,
type: r.type,
title: r.title,
description: r.description,
status: r.status,
giteaIssueUrl: r.giteaIssueUrl,
giteaError: r.giteaError,
createdAt: r.createdAt.toISOString(),
attachments: r.attachments.map((a) => ({
id: a.id,
contentType: a.contentType,
url: getPublicUrl(a.storageKey),
})),
}))}
/>
</div>
);
}
+13 -1
View File
@@ -1,11 +1,18 @@
import type { Metadata } from "next";
import { db, tierDefinitions } from "@epicure/db";
import { TierLimitsForm } from "@/components/admin/tier-limits-form";
import { FeatureFlagsForm } from "@/components/admin/feature-flags-form";
import { getFeatureFlagMatrix, FEATURE_DEFINITIONS } from "@/lib/feature-flags";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminTiersPage() {
const tiers = await db.select().from(tierDefinitions);
await requireFullAdminPage();
const [tiers, featureFlagMatrix] = await Promise.all([
db.select().from(tierDefinitions),
getFeatureFlagMatrix(),
]);
return (
<div className="space-y-8">
@@ -19,6 +26,11 @@ export default async function AdminTiersPage() {
{tiers.map((tierDefinition) => (
<TierLimitsForm key={tierDefinition.tier} tierDefinition={tierDefinition} />
))}
<FeatureFlagsForm
features={FEATURE_DEFINITIONS.map((f) => ({ key: f.key, label: f.label, description: f.description }))}
initialMatrix={featureFlagMatrix}
/>
</div>
);
}
+13 -9
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { db, users, recipes, userUsage, eq, desc, count, and, sql } from "@epicure/db";
import { db, users, recipes, userUsage, eq, desc, count, and } from "@epicure/db";
import { getStorageUsedMb } from "@/lib/tiers";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,6 +10,7 @@ import { UserEditor } from "@/components/admin/user-editor";
import { ResetUsageButton } from "@/components/admin/reset-usage-button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -25,9 +27,11 @@ const ROLE_COLORS = {
const TIER_COLORS = {
free: "secondary",
pro: "default",
family: "default",
} as const;
export default async function AdminUserDetailPage({ params }: PageProps) {
await requireFullAdminPage();
const { id } = await params;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
@@ -41,10 +45,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
.limit(1);
const [recipeCountRow] = await db
.select({ count: count() })
.from(recipes)
.where(eq(recipes.authorId, id));
const [[recipeCountRow], storageUsedMb] = await Promise.all([
db.select({ count: count() }).from(recipes).where(eq(recipes.authorId, id)),
getStorageUsedMb(id),
]);
const recentRecipes = await db
.select({
@@ -98,17 +102,17 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
<CardTitle className="text-base">Edit Role &amp; Tier</CardTitle>
</CardHeader>
<CardContent>
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} currentIsDeveloper={user.isDeveloper} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
<CardTitle className="text-base">Usage &amp; Limits</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">AI Calls Used</span>
<span className="text-muted-foreground">AI Calls Used ({currentMonth})</span>
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
@@ -117,7 +121,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Storage Used</span>
<span className="font-medium">{usage?.storageUsedMb ?? 0} MB</span>
<span className="font-medium">{storageUsedMb} MB</span>
</div>
<div className="pt-2">
<ResetUsageButton userId={user.id} />
+3
View File
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CreateUserDialog } from "@/components/admin/create-user-dialog";
import Link from "next/link";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -24,9 +25,11 @@ const ROLE_COLORS = {
const TIER_COLORS = {
free: "secondary",
pro: "default",
family: "default",
} as const;
export default async function AdminUsersPage({ searchParams }: PageProps) {
await requireFullAdminPage();
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { db, adminWebhooks } from "@epicure/db";
import { AdminWebhooksManager } from "@/components/admin/admin-webhooks-manager";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminWebhooksPage() {
await requireFullAdminPage();
const rows = await db
.select({
id: adminWebhooks.id,
url: adminWebhooks.url,
events: adminWebhooks.events,
active: adminWebhooks.active,
createdAt: adminWebhooks.createdAt,
})
.from(adminWebhooks);
const webhooks = rows.map((w) => ({ ...w, createdAt: w.createdAt.toISOString() }));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Webhooks</h1>
<p className="text-muted-foreground text-sm mt-1">
Site-wide ops events (new signups, support tickets, reports) — deliver to any HTTP endpoint (Slack/Discord incoming webhook, internal alerting, etc). Fires regardless of who&apos;s involved, unlike the per-user webhooks under Settings.
</p>
</div>
<AdminWebhooksManager initialWebhooks={webhooks} />
</div>
);
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, chatMessages, lt } from "@epicure/db";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
// protected by a shared secret rather than user auth.
//
// AI chat history (both the per-recipe chat and the general cooking
// assistant) has no size cap and no per-user retention setting — this just
// deletes anything past a fixed retention window so the table doesn't grow
// unbounded.
const RETENTION_DAYS = 90;
function isAuthorized(req: NextRequest): boolean {
const secret = process.env["CRON_SECRET"];
if (!secret) return false;
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = header.slice("Bearer ".length);
const a = Buffer.from(provided);
const b = Buffer.from(secret);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
const deleted = await db.delete(chatMessages).where(lt(chatMessages.createdAt, cutoff)).returning({ id: chatMessages.id });
return NextResponse.json({ ok: true, deleted: deleted.length });
}
@@ -5,6 +5,7 @@ import { sendEmail, notificationEmailHtml } from "@/lib/email";
import { sendPushNotification } from "@/lib/push";
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
@@ -53,11 +54,14 @@ export async function POST(req: NextRequest) {
const title = messages.notifications.pushTitle.leftoverExpiring;
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
const url = `/recipes/${log.recipe.id}`;
const pushEnabled = await isNotificationCategoryEnabled(log.user.id, "leftoverExpiring");
await Promise.all([
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
}),
pushEnabled
? sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
})
: Promise.resolve(),
log.user.email
? sendEmail({
to: log.user.email,
@@ -8,6 +8,7 @@ import {
ratings,
userFollows,
favorites,
userNotificationPrefs,
eq,
and,
gte,
@@ -21,10 +22,11 @@ import { sendEmail, weeklyDigestHtml } from "@/lib/email";
// schedule (see compose.prod.yml). Not part of the public API surface;
// protected by a shared secret rather than user auth.
//
// Computes, for every user: new followers / new comments / new ratings on
// their recipes in the last 7 days, plus a site-wide top-3 trending list, and
// emails a summary. Sends to all users (all users have a non-null email) —
// there's no per-user opt-out preference yet; out of scope for this pass.
// Computes, for every opted-in user: new followers / new comments / new
// ratings on their recipes in the last 7 days, plus a site-wide top-3
// trending list, and emails a summary. Excludes users who turned off
// "Weekly digest" in Settings → Notifications (userNotificationPrefs.weeklyDigestEmail,
// default true — no row means opted in).
const CHUNK_SIZE = 20;
@@ -56,8 +58,12 @@ export async function POST(req: NextRequest) {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([
const [allUsers, optedOutRows, followerRows, commentRows, ratingRows, trending] = await Promise.all([
db.select({ id: users.id, email: users.email }).from(users),
db
.select({ userId: userNotificationPrefs.userId })
.from(userNotificationPrefs)
.where(eq(userNotificationPrefs.weeklyDigestEmail, false)),
db
.select({ userId: userFollows.followingId, n: count() })
.from(userFollows)
@@ -92,6 +98,9 @@ export async function POST(req: NextRequest) {
.limit(3),
]);
const optedOut = new Set(optedOutRows.map((r) => r.userId));
const recipients = allUsers.filter((u) => !optedOut.has(u.id));
const followerMap = new Map(followerRows.map((r) => [r.userId, r.n]));
const commentMap = new Map(commentRows.map((r) => [r.userId, r.n]));
const ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n]));
@@ -100,7 +109,7 @@ export async function POST(req: NextRequest) {
let sent = 0;
let failed = 0;
for (const batch of chunk(allUsers, CHUNK_SIZE)) {
for (const batch of chunk(recipients, CHUNK_SIZE)) {
const results = await Promise.allSettled(
batch.map((user) => {
const newFollowers = followerMap.get(user.id) ?? 0;
@@ -127,5 +136,5 @@ export async function POST(req: NextRequest) {
}
}
return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed });
return NextResponse.json({ ok: true, totalUsers: allUsers.length, optedOut: optedOut.size, sent, failed });
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireAdmin } from "@/lib/api-auth";
import { getFeatureFlagMatrix, setFeatureFlag, FEATURE_KEYS, TIERS } from "@/lib/feature-flags";
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const matrix = await getFeatureFlagMatrix();
return NextResponse.json(matrix);
}
const UpdateBody = z.object({
featureKey: z.enum(FEATURE_KEYS as [string, ...string[]]),
tier: z.enum(TIERS as [string, ...string[]]),
enabled: z.boolean(),
});
export async function PATCH(req: NextRequest) {
const { session, response } = await requireAdmin();
if (response) return response;
const body = (await req.json()) as unknown;
const parsed = UpdateBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await setFeatureFlag(
parsed.data.featureKey as (typeof FEATURE_KEYS)[number],
parsed.data.tier as (typeof TIERS)[number],
parsed.data.enabled,
session!.user.id
);
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { createInvite, listInvites } from "@/lib/invites";
import { randomUUID } from "crypto";
const VALID_ROLES = ["user", "moderator", "admin"] as const;
const VALID_TIERS = ["free", "pro"] as const;
const VALID_TIERS = ["free", "pro", "family"] as const;
export async function GET() {
const { response } = await requireAdmin();
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { z } from "zod";
import { requireAdmin } from "@/lib/api-auth";
import { db, recipes, auditLogs, eq } from "@epicure/db";
const PatchSchema = z.object({
action: z.literal("unpublish"),
});
/** Admin/moderator takedown action — flips a public recipe to private.
* Deliberately narrow (just this one transition) rather than exposing full
* visibility management here, since that's the recipe owner's call. */
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const { id } = await params;
const parsed = PatchSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const [updated] = await db
.update(recipes)
.set({ visibility: "private" })
.where(eq(recipes.id, id))
.returning({ id: recipes.id });
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session!.user.id,
action: "admin.recipe.unpublish",
targetType: "recipe",
targetId: id,
metadata: null,
createdAt: new Date(),
});
return NextResponse.json({ ok: true });
}
@@ -8,7 +8,7 @@ interface RouteContext {
}
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin();
const { session, response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const { id } = await params;
+1 -1
View File
@@ -3,7 +3,7 @@ import { requireAdmin } from "@/lib/api-auth";
import { db, reports, users, eq, desc } from "@epicure/db";
export async function GET() {
const { response } = await requireAdmin();
const { response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const rows = await db
@@ -1,39 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
}));
vi.mock("@/lib/auth/server", () => ({
auth: { api: { getSession: vi.fn() } },
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
vi.mock("@/lib/site-settings", () => ({
setSiteSetting: vi.fn().mockResolvedValue(undefined),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
const { mockInsertValues } = vi.hoisted(() => ({
mockInsertValues: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { auth } = await import("@/lib/auth/server");
const { requireAdmin } = await import("@/lib/api-auth");
const { setSiteSetting } = await import("@/lib/site-settings");
import { PUT } from "../route";
@@ -47,21 +36,26 @@ function makeRequest(body: unknown) {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never);
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
});
describe("PUT /api/v1/admin/settings", () => {
it("returns 403 when caller is not an admin", async () => {
mockSelectChain.where.mockResolvedValue([{ role: "user" }]);
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
} as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("returns 403 when there is no session", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
it("returns 401 when there is no session", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
} as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
expect(res.status).toBe(401);
});
it("updates allowed keys and writes an audit log", async () => {
+20 -15
View File
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, auditLogs, eq } from "@epicure/db";
import { db, auditLogs } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
import { randomUUID } from "crypto";
@@ -14,33 +13,39 @@ const ALLOWED_KEYS: SiteSettingKey[] = [
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"SIGNUPS_DISABLED",
"DEFAULT_TEXT_PROVIDER",
"DEFAULT_TEXT_MODEL",
"DEFAULT_VISION_PROVIDER",
"DEFAULT_VISION_MODEL",
"DEFAULT_MEAL_PLAN_PROVIDER",
"DEFAULT_MEAL_PLAN_MODEL",
"DEFAULT_CHAT_PROVIDER",
"DEFAULT_CHAT_MODEL",
"AI_TOOL_CALLING_ENABLED",
"GITEA_URL",
"GITEA_TOKEN",
"GITEA_REPO",
"GITEA_WEBHOOK_SECRET",
"USDA_API_KEY",
];
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id));
if (dbUser?.role !== "admin") return null;
return session;
}
export async function PUT(req: NextRequest) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { session, response } = await requireAdmin();
if (response) return response;
const body = (await req.json()) as Record<string, string | null>;
const changed: string[] = [];
for (const [key, value] of Object.entries(body)) {
if (!ALLOWED_KEYS.includes(key as SiteSettingKey)) continue;
await setSiteSetting(key as SiteSettingKey, value, session.user.id);
await setSiteSetting(key as SiteSettingKey, value, session!.user.id);
changed.push(key);
}
if (changed.length > 0) {
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session.user.id,
userId: session!.user.id,
action: "admin.settings.update",
targetType: "site_settings",
metadata: JSON.stringify({ keys: changed }),
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, supportTickets, supportTicketComments, eq, asc } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { postGiteaComment } from "@/lib/gitea";
const CreateCommentBody = z.object({
body: z.string().trim().min(1).max(3000),
});
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const comments = await db.query.supportTicketComments.findMany({
where: eq(supportTicketComments.ticketId, id),
orderBy: asc(supportTicketComments.createdAt),
});
return NextResponse.json(comments);
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const ticket = await db.query.supportTickets.findFirst({ where: eq(supportTickets.id, id) });
if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 });
const parsed = CreateCommentBody.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
let giteaCommentId: number | null = null;
if (ticket.giteaIssueNumber) {
const { id: commentId } = await postGiteaComment(ticket.giteaIssueNumber, parsed.data.body);
giteaCommentId = commentId;
}
const commentId = crypto.randomUUID();
const now = new Date();
await db.insert(supportTicketComments).values({
id: commentId,
ticketId: id,
authorType: "admin",
authorId: session!.user.id,
body: parsed.data.body,
giteaCommentId,
createdAt: now,
});
return NextResponse.json(
{ id: commentId, ticketId: id, authorType: "admin", authorId: session!.user.id, body: parsed.data.body, createdAt: now.toISOString() },
{ status: 201 }
);
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, supportTickets, supportTicketAttachments, eq } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { createGiteaIssue, buildGiteaIssueBody, setGiteaIssueState } from "@/lib/gitea";
const UpdateTicketBody = z.object({
status: z.enum(["open", "triaged", "closed"]).optional(),
retryGitea: z.boolean().optional(),
});
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const body = (await req.json()) as unknown;
const parsed = UpdateTicketBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const [ticket] = await db.select().from(supportTickets).where(eq(supportTickets.id, id));
if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updates: Partial<typeof ticket> = { updatedAt: new Date() };
if (parsed.data.status) {
updates.status = parsed.data.status;
// Mirror the status change onto the linked Gitea issue — best-effort,
// never blocks the ticket update on Gitea being reachable.
if (ticket.giteaIssueNumber && parsed.data.status !== ticket.status) {
void setGiteaIssueState(ticket.giteaIssueNumber, parsed.data.status === "closed" ? "closed" : "open");
}
}
if (parsed.data.retryGitea) {
const attachments = await db
.select({ key: supportTicketAttachments.storageKey, contentType: supportTicketAttachments.contentType })
.from(supportTicketAttachments)
.where(eq(supportTicketAttachments.ticketId, id));
const { url, number, error } = await createGiteaIssue({
type: ticket.type,
title: ticket.title,
body: buildGiteaIssueBody({ description: ticket.description, userId: ticket.userId, attachments }),
});
updates.giteaIssueUrl = url;
updates.giteaIssueNumber = number;
updates.giteaError = error;
}
await db.update(supportTickets).set(updates).where(eq(supportTickets.id, id));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { db, supportTickets, desc } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { getPublicUrl } from "@/lib/storage";
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const rows = await db.query.supportTickets.findMany({
orderBy: desc(supportTickets.createdAt),
with: {
attachments: true,
user: { columns: { email: true, username: true } },
},
});
return NextResponse.json(
rows.map((r) => ({
id: r.id,
userId: r.userId,
userEmail: r.user.email,
username: r.user.username,
type: r.type,
title: r.title,
description: r.description,
status: r.status,
giteaIssueUrl: r.giteaIssueUrl,
giteaError: r.giteaError,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })),
}))
);
}
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response;
const { tier } = await params;
if (tier !== "free" && tier !== "pro") {
if (tier !== "free" && tier !== "pro" && tier !== "family") {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
}
+11 -7
View File
@@ -12,11 +12,11 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response;
const { id } = await params;
const body = await req.json() as { role?: string; tier?: string };
const { role, tier } = body;
const body = await req.json() as { role?: string; tier?: string; isDeveloper?: boolean };
const { role, tier, isDeveloper } = body;
const validRoles = ["user", "moderator", "admin"] as const;
const validTiers = ["free", "pro"] as const;
const validTiers = ["free", "pro", "family"] as const;
if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
@@ -24,18 +24,22 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
}
if (isDeveloper !== undefined && typeof isDeveloper !== "boolean") {
return NextResponse.json({ error: "Invalid isDeveloper" }, { status: 400 });
}
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro"; updatedAt: Date }> = {
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; isDeveloper: boolean; updatedAt: Date }> = {
updatedAt: new Date(),
};
if (role) updateData.role = role as "user" | "moderator" | "admin";
if (tier) updateData.tier = tier as "free" | "pro";
if (tier) updateData.tier = tier as "free" | "pro" | "family";
if (isDeveloper !== undefined) updateData.isDeveloper = isDeveloper;
const [updated] = await db
.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning({ id: users.id, role: users.role, tier: users.tier });
.returning({ id: users.id, role: users.role, tier: users.tier, isDeveloper: users.isDeveloper });
if (!updated) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
@@ -48,7 +52,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
action: "admin.user.update",
targetType: "user",
targetId: id,
metadata: JSON.stringify({ role, tier }),
metadata: JSON.stringify({ role, tier, isDeveloper }),
createdAt: new Date(),
});
@@ -19,9 +19,12 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { id } = await params;
const month = currentMonth();
// Only aiCallsUsed is a resettable monthly counter — recipe count and
// storage usage are lifetime totals derived live from real data (recipes,
// photos, avatar), so there's nothing to reset there.
const [updated] = await db
.update(userUsage)
.set({ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 })
.set({ aiCallsUsed: 0 })
.where(and(eq(userUsage.userId, id), eq(userUsage.month, month)))
.returning();
@@ -35,5 +38,5 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
createdAt: new Date(),
});
return NextResponse.json({ usage: updated ?? { userId: id, month, aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 } });
return NextResponse.json({ usage: updated ?? { userId: id, month, aiCallsUsed: 0 } });
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from "crypto";
import { APIError } from "better-auth";
const VALID_ROLES = ["user", "moderator", "admin"] as const;
const VALID_TIERS = ["free", "pro"] as const;
const VALID_TIERS = ["free", "pro", "family"] as const;
export async function POST(req: NextRequest) {
const { session, response } = await requireAdmin();
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { db, adminWebhooks, adminWebhookDeliveries, eq, desc } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const hook = await db.query.adminWebhooks.findFirst({ where: eq(adminWebhooks.id, id), columns: { id: true } });
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
const deliveries = await db
.select()
.from(adminWebhookDeliveries)
.where(eq(adminWebhookDeliveries.webhookId, id))
.orderBy(desc(adminWebhookDeliveries.createdAt))
.limit(20);
return NextResponse.json(deliveries);
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, adminWebhooks, adminWebhookDeliveries, eq, and } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { dispatchAdminWebhook, type AdminWebhookEvent } from "@/lib/admin-webhooks";
const Schema = z.object({ deliveryId: z.string().uuid() });
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const hook = await db.query.adminWebhooks.findFirst({ where: eq(adminWebhooks.id, id), columns: { id: true } });
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = Schema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Validation error", issues: body.error.issues }, { status: 400 });
const delivery = await db.query.adminWebhookDeliveries.findFirst({
where: and(eq(adminWebhookDeliveries.id, body.data.deliveryId), eq(adminWebhookDeliveries.webhookId, id)),
});
if (!delivery) return NextResponse.json({ error: "Delivery not found" }, { status: 404 });
void dispatchAdminWebhook(delivery.event as AdminWebhookEvent, (delivery.payload ?? {}) as object);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, adminWebhooks, eq } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks";
const UpdateWebhookBody = z.object({
url: z.string().min(1).max(2048).optional(),
events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).optional(),
active: z.boolean().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function DELETE(_req: NextRequest, { params }: Params) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1);
if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(adminWebhooks).where(eq(adminWebhooks.id, id));
return new NextResponse(null, { status: 204 });
}
export async function PATCH(req: NextRequest, { params }: Params) {
const { response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1);
if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = UpdateWebhookBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
if (parsed.data.url) {
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const updates: Partial<{ url: string; events: string[]; active: boolean }> = {};
if (parsed.data.url !== undefined) updates.url = parsed.data.url;
if (parsed.data.events !== undefined) updates.events = parsed.data.events;
if (parsed.data.active !== undefined) updates.active = parsed.data.active;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
}
await db.update(adminWebhooks).set(updates).where(eq(adminWebhooks.id, id));
const updated = await db
.select({
id: adminWebhooks.id,
url: adminWebhooks.url,
events: adminWebhooks.events,
active: adminWebhooks.active,
createdAt: adminWebhooks.createdAt,
})
.from(adminWebhooks)
.where(eq(adminWebhooks.id, id))
.limit(1);
return NextResponse.json(updated[0]);
}
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, adminWebhooks } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks";
import { encrypt } from "@/lib/encrypt";
const CreateWebhookBody = z.object({
url: z.string().min(1).max(2048),
events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).default([]),
});
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const rows = await db
.select({
id: adminWebhooks.id,
url: adminWebhooks.url,
events: adminWebhooks.events,
active: adminWebhooks.active,
createdAt: adminWebhooks.createdAt,
})
.from(adminWebhooks);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireAdmin();
if (response) return response;
const body = await req.json() as unknown;
const parsed = CreateWebhookBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const secret = crypto.randomBytes(32).toString("hex");
const id = crypto.randomUUID();
const now = new Date();
await db.insert(adminWebhooks).values({
id,
createdById: session!.user.id,
url: parsed.data.url,
events: parsed.data.events,
secret: encrypt(secret),
active: true,
createdAt: now,
});
return NextResponse.json(
{ id, url: parsed.data.url, events: parsed.data.events, secret, active: true, createdAt: now.toISOString() },
{ status: 201 }
);
}
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireDeveloper } from "@/lib/api-auth";
type Params = { params: Promise<{ provider: string }> };
export async function DELETE(_req: Request, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireDeveloper();
if (response) return response;
const { provider } = await params;
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireDeveloper } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt";
import { applyRateLimit } from "@/lib/rate-limit";
@@ -13,7 +13,7 @@ const PostSchema = z.object({
});
export async function GET() {
const { session, response } = await requireSession();
const { session, response } = await requireDeveloper();
if (response) return response;
const keys = await db.query.userAiKeys.findMany({
@@ -25,7 +25,7 @@ export async function GET() {
}
export async function POST(req: Request) {
const { session, response } = await requireSession();
const { session, response } = await requireDeveloper();
if (response) return response;
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
+81 -48
View File
@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
import { isRecipeUnchanged } from "@/lib/recipe-diff";
const Schema = z.object({
excludeIngredients: z.array(z.string()).default([]),
@@ -18,11 +21,14 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { id } = await params;
const userId = session!.user.id;
const limited = await applyRateLimit(`rl:ai:${userId}`, 10, 60);
if (limited) return limited;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id)),
@@ -50,7 +56,9 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
const locale = (session!.user as { locale?: string }).locale ?? "en";
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
adaptRecipe(
{
title: recipe.title,
@@ -64,58 +72,83 @@ export async function POST(req: NextRequest, { params }: Params) {
extraConstraints: parsed.data.extraConstraints,
},
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
)
locale
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
const adapted = result.data;
// The AI sometimes "adapts" a recipe into something byte-for-byte
// identical (e.g. it decides the constraints are already satisfied) —
// don't persist a duplicate recipe/variation when nothing actually changed.
if (isRecipeUnchanged(recipe, adapted)) {
return NextResponse.json({ id: recipe.id, adaptationNotes: adapted.adaptationNotes, unchanged: true });
}
const newId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id: newId,
authorId: userId,
title: adapted.title,
description: adapted.description,
baseServings: adapted.baseServings,
visibility: "private",
difficulty: adapted.difficulty,
prepMins: adapted.prepMins,
cookMins: adapted.cookMins,
dietaryTags: adapted.dietaryTags ?? {},
aiGenerated: true,
createdAt: now,
updatedAt: now,
try {
await db.transaction(async (tx) => {
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
await tx.insert(recipes).values({
id: newId,
authorId: userId,
title: adapted.title,
description: adapted.description,
baseServings: adapted.baseServings,
visibility: "private",
language: locale,
difficulty: adapted.difficulty,
prepMins: adapted.prepMins,
cookMins: adapted.cookMins,
dietaryTags: adapted.dietaryTags ?? {},
aiGenerated: true,
createdAt: now,
updatedAt: now,
});
if (adapted.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
adapted.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
unit: ing.unit,
note: ing.note,
order: i,
}))
);
}
if (adapted.steps.length > 0) {
await tx.insert(recipeSteps).values(
adapted.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: i,
}))
);
}
await tx.insert(recipeVariations).values({
id: crypto.randomUUID(),
parentRecipeId: recipe.id,
childRecipeId: newId,
description: adapted.adaptationNotes,
aiGenerated: true,
createdAt: now,
});
});
if (adapted.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
adapted.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
unit: ing.unit,
note: ing.note,
order: i,
}))
);
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
if (adapted.steps.length > 0) {
await tx.insert(recipeSteps).values(
adapted.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: i,
}))
);
}
});
return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 });
}
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
}
@@ -1,13 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, recipeBatchDishes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { generateBatchCook } from "@/lib/ai/features/generate-batch-cook";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { parseQuantity } from "@/lib/parse-quantity";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
const Schema = z.object({
dinners: z.number().int().min(0).max(6).default(4),
@@ -19,7 +20,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -37,13 +38,13 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "text")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const config = configResult.data;
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
generateBatchCook(
{
dinners: parsed.data.dinners,
@@ -55,14 +56,16 @@ export async function POST(req: NextRequest) {
},
{ ...config, userContext: privateBio ?? undefined },
locale
)
), { skipQuota: config.isByok }
);
if (!result.ok) return result.response;
const plan = result.data;
const recipeId = crypto.randomUUID();
await db.transaction(async (tx) => {
try {
await db.transaction(async (tx) => {
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
await tx.insert(recipes).values({
id: recipeId,
authorId: userId,
@@ -117,7 +120,13 @@ export async function POST(req: NextRequest) {
}))
);
}
});
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ id: recipeId });
}
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, chatMessages, eq, and, isNull, desc, asc, ilike } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
const Schema = z.object({
recipeId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
// "general" restricts to the homepage cooking assistant (recipeId null);
// omit both recipeId and scope to search across everything.
scope: z.enum(["general"]).optional(),
q: z.string().max(200).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
});
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { searchParams } = new URL(req.url);
const parsed = Schema.safeParse({
recipeId: searchParams.get("recipeId") ?? undefined,
conversationId: searchParams.get("conversationId") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
q: searchParams.get("q") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const { recipeId, conversationId, scope, q, limit } = parsed.data;
const conditions = [eq(chatMessages.userId, session!.user.id)];
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
else if (conversationId) conditions.push(eq(chatMessages.conversationId, conversationId));
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
if (q?.trim()) conditions.push(ilike(chatMessages.content, `%${q.trim()}%`));
const rows = await db.query.chatMessages.findMany({
where: and(...conditions),
orderBy: q?.trim() ? [desc(chatMessages.createdAt)] : [asc(chatMessages.createdAt)],
limit,
with: { recipe: { columns: { id: true, title: true } } },
});
return NextResponse.json({
data: rows.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
createdAt: r.createdAt.toISOString(),
recipeId: r.recipeId,
recipeTitle: r.recipe?.title ?? null,
})),
});
}
const DeleteSchema = z.object({
recipeId: z.string().uuid().optional(),
scope: z.enum(["general"]).optional(),
});
// No `q` here on purpose — clearing is "this whole conversation" (or
// everything), not "every message matching a search term".
export async function DELETE(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { searchParams } = new URL(req.url);
const parsed = DeleteSchema.safeParse({
recipeId: searchParams.get("recipeId") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const { recipeId, scope } = parsed.data;
const conditions = [eq(chatMessages.userId, session!.user.id)];
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
await db.delete(chatMessages).where(and(...conditions));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, aiConversations, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const RenameSchema = z.object({ title: z.string().max(100).nullable() });
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { id } = await params;
const body = await req.json() as unknown;
const parsed = RenameSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const [updated] = await db
.update(aiConversations)
.set({ title: parsed.data.title?.trim() || null, updatedAt: new Date() })
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
.returning();
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ id: updated.id, title: updated.title });
}
export async function DELETE(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { id } = await params;
const [deleted] = await db
.delete(aiConversations)
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
.returning({ id: aiConversations.id });
if (!deleted) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { db, aiConversations, eq, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const rows = await db.query.aiConversations.findMany({
where: eq(aiConversations.userId, session!.user.id),
orderBy: desc(aiConversations.updatedAt),
});
return NextResponse.json({
data: rows.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
});
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const id = crypto.randomUUID();
const now = new Date();
await db.insert(aiConversations).values({ id, userId: session!.user.id, title: null, createdAt: now, updatedAt: now });
return NextResponse.json({ id, title: null, createdAt: now.toISOString(), updatedAt: now.toISOString() }, { status: 201 });
}
@@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText, stepCountIs } from "ai";
import { db, chatMessages, aiConversations, sql } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { isAiToolCallingEnabled } from "@/lib/site-settings";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
import { createRecipeTool } from "@/lib/ai/tools/create-recipe-tool";
import { addToShoppingListTool } from "@/lib/ai/tools/add-to-shopping-list-tool";
const Schema = z.object({
question: z.string().min(1).max(500),
conversationId: z.string().uuid().optional(),
});
const LANG: Record<string, string> = { en: "English", fr: "French" };
// Forcing toolChoice to a specific tool makes some providers return the tool
// call with no accompanying text at all (their "forced function calling" is
// content-free by design) — the chat needs some reply, so fall back to this
// rather than showing an empty assistant bubble.
const DRAFT_FALLBACK_TEXT: Record<string, string> = {
en: "Here's a draft — check it below and confirm if it looks right.",
fr: "Voici un brouillon — vérifie-le ci-dessous et confirme si ça te convient.",
};
/** Weaker/free-tier models sometimes ignore the system prompt's "call the
* tool, don't write it out" rule and answer with the recipe spelled out as
* a numbered/bulleted list instead. That's the exact shape the tool call
* itself would have captured structurally — a reliable enough signal to
* self-correct with a forced-tool retry rather than surfacing prose. */
function looksLikeUnstructuredRecipe(text: string): boolean {
const listLines = text.split("\n").filter((line) => /^\s*(\d+[.)]|[-*•])\s+\S/.test(line));
return listLines.length >= 3;
}
// The above only catches the model spelling the whole recipe out. A second,
// more common failure with weaker models: it gives a short reply that even
// *claims* to have drafted something ("here's a draft, check below") without
// ever calling the tool — so nothing renders below it. Rather than guess from
// that claim (fragile across models/phrasing), detect intent from the user's
// own message instead: same noun+verb pattern the system prompt itself uses
// as its createRecipe examples, checked in whichever of the two supported
// locales the request is in.
const RECIPE_INTENT: Record<string, { noun: RegExp; verb: RegExp }> = {
en: { noun: /\brecipe\b/i, verb: /\b(create|make|generate|give|write|save|invent)\b/i },
fr: { noun: /\brecettes?\b/i, verb: /\b(cr[ée]e?r?|fais|donne|[ée]cri[st]|note[rz]?|sauvegard\w*|enregistr\w*|invente\w*|g[ée]n[èe]re\w*)\b/i },
};
function looksLikeRecipeCreationRequest(question: string, locale: string): boolean {
const pattern = RECIPE_INTENT[locale] ?? RECIPE_INTENT["en"]!;
return pattern.noun.test(question) && pattern.verb.test(question);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
if (limited) return limited;
const [configResult, privateBio, toolCallingEnabled] = await Promise.all([
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "chat")),
getUserPrivateBio(session!.user.id),
isAiToolCallingEnabled(),
]);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const model = resolveModel(aiConfig);
const bioContext = buildUserBioContext(privateBio);
const locale = (session!.user as { locale?: string }).locale ?? "en";
const lang = LANG[locale] ?? "English";
const toolsInstructions = toolCallingEnabled
? `\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.`
: "";
const system = `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${toolsInstructions}${bioContext}`;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
generateText(
toolCallingEnabled
? {
model,
system,
prompt: parsed.data.question,
tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool },
stopWhen: stepCountIs(3),
}
: { model, system, prompt: parsed.data.question }
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
let final = result.data;
// Weaker/free-tier models sometimes ignore the MUST-call-the-tool rule
// above — either by spelling the recipe out in prose, or (more common,
// and easy to miss) giving a short reply that *claims* a draft exists
// without ever calling the tool, so nothing actually renders below it.
// Catch both: the prose-list shape, or the user's own message clearly
// asking for a recipe in the first place. Rather than surface either
// failure, force the tool call on one extra pass — same system+prompt,
// just no longer letting the model opt out. Skipped entirely when tool
// calling is turned off admin-side (e.g. a local model that can't
// reliably call tools) — there's no tool to force in that case.
if (toolCallingEnabled && final.toolCalls.length === 0 && (looksLikeUnstructuredRecipe(final.text) || looksLikeRecipeCreationRequest(parsed.data.question, locale))) {
try {
const forced = await generateText({
model,
system,
prompt: parsed.data.question,
// Same tool set as the first pass (just forcing createRecipe via
// toolChoice below) — keeps the result type identical to `result.data`
// so `final` can hold either without a type mismatch.
tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool },
toolChoice: { type: "tool", toolName: "createRecipe" },
stopWhen: stepCountIs(1),
});
final = { ...forced, text: forced.text || (DRAFT_FALLBACK_TEXT[locale] ?? DRAFT_FALLBACK_TEXT["en"]!) };
} catch (err) {
console.error("[cooking-chat] forced-tool retry failed, keeping prose answer", err);
}
}
const { conversationId } = parsed.data;
const proposedRecipe = final.toolCalls.find((c) => c.toolName === "createRecipe")?.input;
const proposedShoppingList = final.toolCalls.find((c) => c.toolName === "addToShoppingList")?.input;
void db.insert(chatMessages).values([
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question },
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "assistant", content: final.text },
]).catch((err) => console.error("[cooking-chat] failed to persist chat history", err));
if (conversationId) {
// Bumps updatedAt (for the conversation list's sort order) and, only if
// this is the conversation's first message, auto-titles it from the
// opening question — so users aren't left staring at "Untitled" entries;
// they can still rename it later.
void db.execute(sql`
UPDATE ${aiConversations}
SET updated_at = now(), title = COALESCE(title, ${parsed.data.question.slice(0, 60)})
WHERE ${aiConversations.id} = ${conversationId} AND ${aiConversations.userId} = ${session!.user.id}
`).catch((err) => console.error("[cooking-chat] failed to touch conversation", err));
}
return NextResponse.json({ answer: final.text, proposedRecipe, proposedShoppingList });
}
+21 -4
View File
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -17,9 +19,24 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
try {
await requireFeatureEnabled(session!.user.id, "drink_pairing");
} catch (err) {
if (err instanceof FeatureDisabledError) {
return NextResponse.json(
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
{ status: 403 }
);
}
throw err;
}
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
@@ -43,7 +60,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
suggestDrinks(
{
title: recipe.title,
@@ -55,7 +72,7 @@ export async function POST(req: NextRequest, { params }: Params) {
parsed.data.count,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
)
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
@@ -1,12 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { parseQuantity } from "@/lib/parse-quantity";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
const Schema = z.object({
title: z.string().min(1).max(200),
@@ -17,7 +19,7 @@ const Schema = z.object({
const LANG: Record<string, string> = { en: "English", fr: "French" };
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -32,59 +34,76 @@ export async function POST(req: NextRequest) {
const privateBio = await getUserPrivateBio(session!.user.id);
const locale = (session!.user as { locale?: string }).locale ?? "en";
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
generateRecipe(parsed.data.title, {
provider: parsed.data.provider,
model: parsed.data.model,
...aiConfig,
userContext: privateBio ?? undefined,
language: LANG[locale] ?? "English",
})
}), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
const recipe = result.data;
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: session!.user.id,
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
visibility: "private",
aiGenerated: true,
language: locale,
dietaryTags: recipe.dietaryTags ?? {},
tags: [],
});
try {
await db.transaction(async (tx) => {
await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
if (recipe.ingredients?.length) {
await db.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: parseQuantity(ing.quantity) ?? null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
await tx.insert(recipes).values({
id: recipeId,
authorId: session!.user.id,
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
visibility: "private",
aiGenerated: true,
language: locale,
dietaryTags: recipe.dietaryTags ?? {},
tags: [],
});
if (recipe.steps?.length) {
await db.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
if (recipe.ingredients?.length) {
await tx.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: parseQuantity(ing.quantity) ?? null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps?.length) {
await tx.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ id: recipeId });
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, collections, collectionRecipes, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { getMessages } from "@/lib/i18n/server";
const Schema = z.object({
collectionId: z.string().min(1),
theme: z.string().min(1).max(200),
courses: z.array(z.enum(MEAL_COURSES)).min(2).max(6).refine((c) => new Set(c).size === c.length, "Duplicate courses"),
servings: z.number().int().min(1).max(20).default(4),
dietaryPrefs: z.string().max(200).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const userId = session!.user.id;
const tier = session!.user.tier as "free" | "pro" | "family";
const { collectionId, theme, courses, servings, dietaryPrefs, difficulty } = parsed.data;
const collection = await db.query.collections.findFirst({
where: and(eq(collections.id, collectionId), eq(collections.userId, userId)),
});
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
const limited = await applyRateLimit(`rl:ai:${userId}`, 3, 60);
if (limited) return limited;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const config = configResult.data;
const result = await withAiQuota(userId, tier, () =>
generateMeal(
{ theme, courses, servings, dietaryPrefs, difficulty },
{ ...config, userContext: privateBio ?? undefined },
locale
), { skipQuota: config.isByok }
);
if (!result.ok) return result.response;
const meal = result.data;
const created: Array<{ id: string; title: string; course: string }> = [];
try {
await db.transaction(async (tx) => {
// Each recipe in the meal creates a real recipe row — check the recipe
// limit covers all of them before inserting anything.
await checkTierLimitInTransaction(tx, userId, tier, "recipe", meal.recipes.length);
for (const recipe of meal.recipes) {
const recipeId = crypto.randomUUID();
await tx.insert(recipes).values({
id: recipeId,
authorId: userId,
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: "private",
language: locale,
aiGenerated: true,
difficulty: recipe.difficulty ?? null,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
dietaryTags: recipe.dietaryTags ?? {},
tags: [getMessages(locale).collections.course[recipe.course]],
});
if (recipe.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
await tx.insert(collectionRecipes).values({ collectionId, recipeId }).onConflictDoNothing();
created.push({ id: recipeId, title: recipe.title, course: recipe.course });
}
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ collectionId, recipes: created });
}
+13 -7
View File
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { withUserKey } from "@/lib/ai/resolve-user-key";
const Schema = z.object({
prompt: z.string().min(3).max(500),
@@ -15,7 +16,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -29,14 +30,19 @@ export async function POST(req: NextRequest) {
const privateBio = await getUserPrivateBio(session!.user.id);
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
generateRecipe(parsed.data.prompt, {
provider: parsed.data.provider,
model: parsed.data.model,
...aiConfig,
language: parsed.data.language,
difficulty: parsed.data.difficulty,
userContext: privateBio ?? undefined,
})
}), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
+37 -12
View File
@@ -1,11 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
const Schema = z.object({
imageBase64: z.string().max(14_000_000),
@@ -13,7 +14,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -28,38 +29,56 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const visionConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
if (!visionConfigResult.ok) return visionConfigResult.response;
const visionConfig = visionConfigResult.data;
// Fall back to vision-capable defaults if no explicit model configured
if (!aiConfig.model) {
if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
if (!visionConfig.model) {
if (visionConfig.provider === "openai") visionConfig.model = "gpt-4o";
else if (visionConfig.provider === "anthropic") visionConfig.model = "claude-sonnet-4-6";
}
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale)
const textConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "text"));
if (!textConfigResult.ok) return textConfigResult.response;
const textConfig = textConfigResult.data;
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
{ skipQuota: visionConfig.isByok && textConfig.isByok }
);
if (!result.ok) return result.response;
const recipe = result.data;
if (!recipe.found) {
return NextResponse.json({ error: "No recipe recognized in photo" }, { status: 422 });
}
const newRecipeId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
try {
await db.transaction(async (tx) => {
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
await tx.insert(recipes).values({
id: newRecipeId,
authorId: userId,
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings ?? 4,
recipeType: recipe.recipeType,
visibility: "private",
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
dietaryTags: recipe.dietaryTags ?? {},
aiGenerated: true,
// The extraction prompt always writes the recipe in the caller's own
// locale (see importFromPhoto's langInstruction) regardless of what
// language the photo/label was in — so this *is* the recipe's
// language, not a guess, and lets the Translate button correctly
// stay hidden until the user's app language changes.
language: locale,
createdAt: now,
updatedAt: now,
});
@@ -89,7 +108,13 @@ export async function POST(req: NextRequest) {
}))
);
}
});
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ id: newRecipeId });
}
+12 -8
View File
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { importFromUrl } from "@/lib/ai/features/import-url";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { withUserKey } from "@/lib/ai/resolve-user-key";
const Schema = z.object({
url: z.string().url(),
@@ -13,7 +14,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -30,11 +31,14 @@ export async function POST(req: NextRequest) {
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
importFromUrl(parsed.data.url, {
provider: parsed.data.provider,
model: parsed.data.model,
})
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -23,7 +23,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
}
}
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
generateMealPlan(
{
dietaryPrefs: parsed.data.dietaryPrefs,
@@ -88,28 +88,11 @@ export async function POST(req: NextRequest) {
},
{ ...config, userContext: privateBio ?? undefined },
locale
)
), { skipQuota: config.isByok }
);
if (!result.ok) return result.response;
const plan = result.data;
// Each plan entry creates a draft recipe — charge the recipe limit for all
// of them before inserting anything, refunding on breach so a rejected plan
// doesn't consume quota.
let chargedRecipes = 0;
try {
for (let i = 0; i < plan.entries.length; i++) {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "recipe");
chargedRecipes++;
}
} catch (err) {
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
// Ensure meal plan row exists for the week
let mealPlan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
@@ -118,12 +101,16 @@ export async function POST(req: NextRequest) {
if (!mealPlan) {
const planId = crypto.randomUUID();
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, isPublic: false, createdAt: new Date() };
}
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
await db.transaction(async (tx) => {
try {
await db.transaction(async (tx) => {
// Each plan entry creates a draft recipe — check the recipe limit covers
// all of them before inserting anything.
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe", plan.entries.length);
for (const entry of plan.entries) {
// Create draft recipe
const recipeId = crypto.randomUUID();
@@ -134,6 +121,7 @@ export async function POST(req: NextRequest) {
description: entry.recipe.description,
baseServings: entry.servings,
visibility: "private",
language: locale,
aiGenerated: true,
difficulty: entry.recipe.difficulty ?? null,
prepMins: entry.recipe.prepMins ?? null,
@@ -189,7 +177,13 @@ export async function POST(req: NextRequest) {
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
}
});
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
}
+21 -4
View File
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -17,9 +19,24 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
try {
await requireFeatureEnabled(session!.user.id, "meal_pairing");
} catch (err) {
if (err instanceof FeatureDisabledError) {
return NextResponse.json(
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
{ status: 403 }
);
}
throw err;
}
const { id } = await params;
// Allow pairings for own recipes or public recipes
@@ -44,7 +61,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
suggestPairings(
{
title: recipe.title,
@@ -56,7 +73,7 @@ export async function POST(req: NextRequest, { params }: Params) {
parsed.data.count,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
)
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
+14 -8
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { db, recipes, eq, and } from "@epicure/db";
import { db, recipes, chatMessages, eq, and } from "@epicure/db";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
import { hasQuantity } from "@/lib/fractions";
@@ -18,7 +18,7 @@ const Schema = z.object({
const LANG: Record<string, string> = { en: "English", fr: "French" };
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -64,25 +64,31 @@ ${stepList || "None listed"}
`.trim();
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")),
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "chat")),
getUserPrivateBio(session!.user.id),
]);
if (!configResult.ok) return configResult.response;
const model = resolveModel(configResult.data);
const aiConfig = configResult.data;
const model = resolveModel(aiConfig);
const bioContext = buildUserBioContext(privateBio);
const locale = (session!.user as { locale?: string }).locale ?? "en";
const lang = LANG[locale] ?? "English";
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
generateText({
model,
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
system: `You are Epicure, a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
${recipeContext}${bioContext}`,
prompt: parsed.data.question,
})
}), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
void db.insert(chatMessages).values([
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "user", content: parsed.data.question },
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "assistant", content: result.data.text },
]).catch((err) => console.error("[recipe-chat] failed to persist chat history", err));
return NextResponse.json({ answer: result.data.text });
}
+6 -5
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateObject } from "ai";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
@@ -25,7 +25,7 @@ const IdeasSchema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -42,7 +42,8 @@ export async function POST(req: NextRequest) {
getUserPrivateBio(session!.user.id),
]);
if (!configResult.ok) return configResult.response;
const model = resolveModel(configResult.data);
const aiConfig = configResult.data;
const model = resolveModel(aiConfig);
const bioContext = buildUserBioContext(privateBio);
const userContext = bioContext
@@ -56,13 +57,13 @@ export async function POST(req: NextRequest) {
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
generateObject({
model,
schema: IdeasSchema,
system: `Respond in ${lang}.`,
prompt,
})
}), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { regenerateRecipe } from "@/lib/ai/features/regenerate-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
const Schema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.union([z.string(), z.number()]).optional(),
unit: z.string().max(50).optional(),
})).max(100),
steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100),
instruction: z.string().min(1).max(500),
language: z.string().max(10).default("en"),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
// No recipeId, no DB access — this is a stateless AI transform over whatever
// draft the editor currently holds (including unsaved edits), not the saved
// row. The caller merges the result into their own in-progress form state.
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const { instruction, language, ...current } = parsed.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
return NextResponse.json(result.data);
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
@@ -13,7 +13,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const limited = await applyRateLimit(`rl:ai:scale:${session!.user.id}`, 20, 60);
@@ -40,11 +40,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
scaleRecipe(
{
title: recipe.title,
@@ -55,7 +55,7 @@ export async function POST(req: NextRequest) {
recipe.baseServings,
aiConfig,
(session!.user as { locale?: string }).locale ?? "en"
)
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
+8 -6
View File
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
import type { AiConfig } from "@/lib/ai/factory";
const Schema = z.object({
ingredient: z.string().min(1).max(200),
@@ -14,7 +15,7 @@ const Schema = z.object({
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
@@ -28,19 +29,20 @@ export async function POST(req: NextRequest) {
? `recipe "${parsed.data.recipeTitle}"`
: "a general recipe";
let aiConfig;
let aiConfig: AiConfig;
if (parsed.data.provider) {
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
} else {
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
if (!configResult.ok) return configResult.response;
aiConfig = configResult.data;
}
const locale = (session!.user as { locale?: string }).locale ?? "en";
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale)
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale),
{ skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
+28 -8
View File
@@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { withAiQuota } from "@/lib/ai/ai-error";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
const Schema = z.object({
targetLanguage: z.string().min(2).max(50),
@@ -15,9 +18,12 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
@@ -35,7 +41,13 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
translateRecipe(
{
title: recipe.title,
@@ -44,8 +56,8 @@ export async function POST(req: NextRequest, { params }: Params) {
steps: recipe.steps,
},
parsed.data.targetLanguage,
{ provider: parsed.data.provider, model: parsed.data.model }
)
aiConfig
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
const translation = result.data;
@@ -54,7 +66,9 @@ export async function POST(req: NextRequest, { params }: Params) {
const newId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
try {
await db.transaction(async (tx) => {
await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
await tx.insert(recipes).values({
id: newId,
authorId: session!.user.id,
@@ -96,7 +110,13 @@ export async function POST(req: NextRequest, { params }: Params) {
}))
);
}
});
});
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
return NextResponse.json({ id: newId });
}
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(5).default(3),
@@ -18,9 +20,24 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
try {
await requireFeatureEnabled(session!.user.id, "recipe_variations");
} catch (err) {
if (err instanceof FeatureDisabledError) {
return NextResponse.json(
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
{ status: 403 }
);
}
throw err;
}
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
@@ -47,7 +64,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
suggestVariations(
{
title: recipe.title,
@@ -59,7 +76,7 @@ export async function POST(req: NextRequest, { params }: Params) {
{ ...aiConfig, userContext: privateBio ?? undefined },
parsed.data.directions,
(session!.user as { locale?: string }).locale ?? "en"
)
), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { db, apiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { requireDeveloper } from "@/lib/api-auth";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, response } = await requireSession();
const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;

Some files were not shown because too many files have changed in this diff Show More