Compare commits

..

188 Commits

Author SHA1 Message Date
Arnaud f871f4f588 feat: real Stripe billing -- Checkout, Customer Portal, admin billing dashboard (v0.73.0)
Implements plans/STRIPE_PLAN.md sections 1-9 for solo Pro/Family
billing (family multi-user sharing, section 1a, deliberately deferred
-- flagged in that plan as the most novel/error-prone piece).

Decisions locked in: cancel/downgrade at period end (Stripe Portal
default), no trial period.

- lib/stripe.ts: single client factory reading STRIPE_SECRET_KEY via
  site-settings (DB overrides env, same pattern as every other
  provider key in this codebase).
- Webhook route rewritten on the real `stripe` SDK
  (stripe.webhooks.constructEvent replaces the hand-rolled HMAC
  verifier) and now handles the full event set: checkout.session.completed,
  customer.subscription.{updated,deleted}, invoice.{payment_failed,paid}.
  past_due deliberately never downgrades tier on its own -- Stripe
  retries the card first, recovering via invoice.paid or eventually
  giving up via subscription.deleted. Every handler audit-logs under
  billing.<event>. Dedup via the existing processed_stripe_events
  table, unchanged.
- Schema: tierDefinitions gained stripe{ProductId,PriceIdMonthly,
  PriceIdYearly} (the lookup table mapping a Price back to a tier on
  checkout); users gained stripeSubscriptionId/subscriptionStatus/
  currentPeriodEnd.
- New POST /api/v1/billing/checkout (creates a subscription Checkout
  Session, allow_promotion_codes: true), POST /api/v1/billing/portal
  (Stripe's hosted self-serve cancel/upgrade/card-update), GET
  /api/v1/billing/status.
- /settings/billing: current plan + renewal date, past_due warning,
  usage-vs-limits (reuses the existing UsageQuotaSection), plan
  comparison cards with per-tier Checkout buttons, manage-billing
  button once a Stripe customer exists.
- /admin/billing: connection status (test/live mode detection),
  subscriber counts, past-due list, recent billing audit events, link
  to Stripe Dashboard. Tier Limits page extended with the three Stripe
  price fields per tier (own render branch, not the numeric+Unlimited-
  switch machinery the existing fields use).

Before going live: an admin needs to create real Products/Prices in
Stripe, enter the IDs on Tier Limits, and configure the Stripe-side
webhook endpoint -- all operational steps the plan always called for,
none of it code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:37:14 +02:00
Arnaud 8d5787e56e feat: self-serve developer access for paid tiers, split BYOK into its own permission (v0.72.0)
Splits what was one isDeveloper flag (gating webhooks, API keys, AND
BYOK together) into two independent permissions:

- isDeveloper (webhooks + self-serve API keys): admin-toggled as
  before, but now ALSO self-serve -- PATCH /api/v1/users/me/developer-access
  lets any paid-tier (tier !== "free") user turn it on themselves, no
  added fee. Free tier still needs an admin grant. Turning it off is
  always self-serve regardless of tier, since revoking your own
  access needs no gatekeeping. canSelfServeDeveloperAccess() in
  lib/permissions.ts is the single check for "is this tier eligible."

- isByokEnabled (BYOK AI provider keys): new column, admin-only, no
  self-serve path at all -- routing real AI provider spend through
  Epicure on the user's own key warrants a manual admin check-in that
  webhooks/API access don't need. requireByok() replaces
  requireDeveloper() on the three ai-keys routes.

Migration adds is_byok_enabled and grandfathers in anyone who already
has a BYOK key configured (the earlier grandfather migration only
covered the combined isDeveloper flag, which BYOK no longer reads).

Settings UI: webhooks/API-keys pages show a self-serve "Enable"
toggle for paid-tier non-developers instead of the locked notice
(still shown to free-tier users), plus a "Disable developer access"
link once enabled. Settings -> AI's BYOK section now checks
isByokEnabled instead of isDeveloper -- unaffected by the self-serve
change, still fully admin-gated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:31:04 +02:00
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
Arnaud 4f36ce24b2 chore: bump version to 0.5.0, changelog entry 2026-07-12 18:37:55 +02:00
Arnaud aa6bd03c5e feat: push notification when a shared shopping list is updated
Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.

Deliberately skips the notifications table/notification-center (same
pattern as the existing leftover-expiry cron) — this is a lightweight
best-effort ping, not a persistent event, so it avoids a notification_type
enum migration and a new FK column for something transient.

Verified locally: added a real member to a shared list, registered a
push subscription, confirmed the check-off request's added latency
(945ms vs ~20ms baseline) shows the actual webpush call fired against
the recipient — individual subscription failures are swallowed by design
(Promise.allSettled in the existing sendPushNotification, unchanged);
confirmed a solo list stays fast (no recipients to notify, true no-op).
2026-07-12 18:36:20 +02:00
Arnaud 9566e19cd0 fix: weekly meal-plan nutrition compared a week's total against a daily goal
userNutritionGoals are daily targets (the nutrition diary already compares
a single day's totals against them directly) — but the weekly meal-plan
route summed an entire week's entries and compared that raw total straight
against the same daily number, so coverage read roughly 7x too high.

Now computes a daily average (across days that actually have planned
meals) and compares that against the goal instead, adds a per-day
breakdown (byDay) for a future day-view, and tracks unknownCount for
entries whose recipe has no nutrition data yet, matching the diary route.

Also fixed a real bug this surfaced: meal-plan/page.tsx and print/meal-plan
parsed the ?week= date via new Date(dateStr) (UTC midnight) then read
.getDay() (local time) — in positive-UTC-offset timezones this resolves to
the wrong Monday, and the reverse (Date -> string via toISOString()) has
the same mismatch in the other direction. Both now do local y/m/d math
throughout.

Verified locally: correct daily-average math end to end (recipe entry +
batch-dish entry, which already attributed nutrition correctly via its
required parent recipeId — no separate fix needed there), and confirmed
the meal-plan page now resolves the right week and renders real coverage
bars against actual planned entries.
2026-07-12 18:35:58 +02:00
Arnaud a9dc1b63c1 fix: batch-cook dishes never deducted pantry when marked cooked
Ingredients aren't attributable to individual batch dishes (they're one
merged/shared list for the whole prep session, unlike steps which have
`appliesTo`) — the cooked route's pantry-deduction block was unconditionally
skipped whenever batchDishId was set, so pantry was never touched for any
batch-cook recipe.

Deducts once, on the first dish marked cooked for that recipe+user (checked
via prior cookingHistory rows) — matches the fridge/freezer-day design
intent of "cook the batch once, eat dishes over several days" rather than
deducting (or trying to deduct) per individual dish.

Verified locally: first dish cooked deducted the full recipe's ingredients
from pantry; second dish cooked did not double-deduct.
2026-07-12 18:35:32 +02:00
Arnaud 1bcea2f273 chore: bump version to 0.4.0, changelog entry 2026-07-12 16:13:09 +02:00
Arnaud a1a11ff5e5 feat: share shopping lists via a public link
Mirrors the existing recipe public-share pattern (/r/[id], gated by a
visibility flag, no auth required) — shopping lists previously only
supported private per-user collaborator invites (email + viewer/editor
role), with no way to hand someone a link who isn't already a member.

- shoppingLists.isPublic (owner-only toggle, same access-control pattern
  already used for renaming/deleting the list).
- New public /s/[id] route added to PUBLIC_PATHS — read-only aisle-grouped
  view with a signup CTA for logged-out visitors, styled after /r/[id].
- The existing collaborator-invite dialog (ShareShoppingListButton) now
  also has a "Public link" toggle + copy-link button at the top, so
  there's one Share entry point for both private and public sharing.

Verified locally: toggling public via the real dialog UI persists
correctly (confirmed via DB + a fresh curl PATCH to rule out a client
race in my own test script), and the public link loads with zero auth
for a logged-out browser context.
2026-07-12 16:11:57 +02:00
Arnaud e98a9c3bb7 feat: show an "imported from" badge on recipe cards
sourceUrl was already persisted and shown on the recipe detail page, but
never surfaced on cards in the grid/list/compact views or the simpler
RecipeCard (collections) — added a small external-link icon with a
tooltip naming the source hostname, sized identically to the existing
batch-cook badge so it doesn't reintroduce the compact-view alignment
bug fixed earlier this session.
2026-07-12 16:11:26 +02:00
Arnaud d7e0d7eada feat: profile pictures — Gravatar fallback + custom upload
New users get a Gravatar-backed avatar automatically (computed from email
at signup); users can upload a custom photo instead via Settings, or
revert to the Gravatar/initials fallback. avatarUrl stays the single
resolved value (custom photo, OAuth photo, or precomputed Gravatar URL)
so every existing avatar-rendering spot across the app needs zero changes.

- users.hasCustomAvatar tracks whether avatarUrl is a real upload vs a
  computed Gravatar fallback, so "remove photo" knows what to revert to.
- New /api/v1/upload/avatar-presign route (session-scoped, generic —
  the existing recipe-photo presign route required a recipeId).
- CSP img-src needed www.gravatar.com added, or every browser blocks
  the fallback avatar outright.
- Settings page now reads avatarUrl from a fresh DB query instead of
  Better Auth's session object — the session cookie cache (5 min TTL)
  was serving a stale image right after upload, showing the initials
  fallback until the cache happened to expire.

Verified locally: signup auto-sets a Gravatar URL, upload persists
across reload, remove correctly reverts to Gravatar/initials.
2026-07-12 16:10:54 +02:00
Arnaud c54c554374 chore: rename unused recipeForm.visibility key to visibilityMenuLabel for clarity 2026-07-12 15:43:33 +02:00
Arnaud b627dda5bf fix: batch-cook recipes shift left in compact view
The dish-count/servings and time spans had no fixed width (unlike the
difficulty badge slot, which reserves w-16 even when empty) — a batch-cook
recipe's longer "N dishes"/"Nm total" text, or a missing time value,
changed that column's width row-to-row, shifting the date and icon
cluster after it. Gave both spans fixed widths so every compact row has
identical column layout regardless of content.
2026-07-12 15:37:36 +02:00
Arnaud a42fd497b0 chore: bump version to 0.3.1, changelog entry 2026-07-12 15:08:07 +02:00
Arnaud 13128df19f feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving
- AI recipe generation always used the app's own locale rather than a
  separate language picker — removed the picker, generation and the
  saved recipe's language now both follow useLocale() directly.
- Bulk-select action bar had an ungated max-w-md that capped it at 448px
  even past the sm: breakpoint where sm:w-auto was supposed to let it
  grow to content — added sm:max-w-none so desktop never scrolls.
- Batch-cooking generation had no free-text "describe" field like the
  standard recipe generator does — added one (BatchCookFields, both the
  standalone dialog and the AI dialog's batch tab), threaded through the
  API route and generateBatchCook's prompt as additional guidance.
- Recipes imported from a URL never actually got their sourceUrl
  persisted — CreateRecipeSchema didn't declare the field, so Zod
  silently stripped it before it ever reached the insert. The detail
  page's "Source: <hostname>" link already existed but was dead code
  for every real import until now.

Verified all 4 live: generate dialog has no language selector, batch
tab has a working describe textarea, bulk-select bar renders at full
content width with zero horizontal overflow on desktop, and a recipe
created with sourceUrl now round-trips and renders its source link.
2026-07-12 15:06:48 +02:00
Arnaud 5b8c50dd1e chore: bump version to 0.3.0, changelog entry for manual batch-cook editing 2026-07-12 14:46:35 +02:00
Arnaud 61014a3224 feat: manually create and edit batch-cook recipes
recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).

Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
2026-07-12 14:45:24 +02:00
Arnaud 20e4ce3597 chore: bump version to 0.2.2, changelog entry for storage-URL fix 2026-07-12 13:54:23 +02:00
Arnaud 8df292dfee fix: photo thumbnails always used localhost:9000 in production
getPublicUrl() runs in the browser (called from client components rendering
recipe thumbnails), but read the plain STORAGE_PUBLIC_URL env var — never
inlined into the client bundle, so every browser fell back to the hardcoded
localhost:9000 default regardless of the real deployed storage domain,
tripping CSP img-src and mixed-content blocks in production. Added a
NEXT_PUBLIC_STORAGE_PUBLIC_URL build arg (Dockerfile, compose.prod.yml) wired
from the same STORAGE_PUBLIC_URL value, and getPublicUrl() now reads that.

Verified locally: building with a fake public storage domain set shows it
correctly inlined into the client JS chunk (previously only the localhost
fallback ever appeared there).
2026-07-12 13:53:15 +02:00
Arnaud b160fdc338 chore: bump version to 0.2.1, changelog entry for photo fixes 2026-07-12 13:21:07 +02:00
Arnaud 546ba98d2f fix: photos lost on save, multi-upload only kept last file, thumbnails 400
recipe-form/API routes were never sending/persisting photos on create+update.
PhotoUploader accumulated uploads via a stale closure over the photos prop, so
only the last file of a multi-select survived. Once both were fixed, thumbnails
still 400'd because Next's image optimizer blocks upstream hosts resolving to
private/loopback IPs (localhost:9000 MinIO) — skip optimization for
storage-hosted photos since they're already web-sized user uploads.
2026-07-12 13:16:37 +02:00
Arnaud 77f43ee673 fix: publish MinIO port in prod so the browser can actually reach it
minio had no ports: mapping at all in compose.prod.yml — only reachable
inside the docker network, never by the browser. No STORAGE_PUBLIC_URL
value could have fixed uploads without this: the browser's direct PUT
to a presigned URL has no route to the container regardless of what
hostname the URL uses. Published on STORAGE_PORT (default 9000),
mirroring how `web` is exposed via WEB_PORT for the Traefik LXC to
route to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:52:34 +02:00
Arnaud ccc41a2018 fix: MinIO CORS blocking browser uploads, changelog admin-only
- MinIO had no CORS config at all, so the browser's direct PUT to a
  presigned URL (cross-origin: app on :3001/:3000, storage on :9000)
  was blocked outright. Added MINIO_API_CORS_ALLOW_ORIGIN — "*" in
  dev, the app's own origin in prod. Verified end-to-end: photo
  upload now succeeds with zero console errors.
- Removed the public /changelog page and its account-menu link —
  changelog is admin-only now (/admin/changelog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:33:53 +02:00
Arnaud 321507b570 fix: docker build crash when STORAGE_PUBLIC_URL unset
compose.prod.yml passes STORAGE_PUBLIC_URL through ${STORAGE_PUBLIC_URL}
build args/env — when unset in .env.production, docker-compose
substitutes an empty string, not an absent var. ?? only falls back on
null/undefined, so next.config.ts's `new URL("")` threw and failed the
whole `pnpm --filter web build` step. Switched to || in next.config.ts
and lib/storage.ts so empty string also falls back to the default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:16:11 +02:00
Arnaud 83d18f5ad4 feat: add changelog and versioning, surface it in admin
Bumps version to 0.2.0 (root + apps/web package.json) and adds
CHANGELOG.md as the canonical human-readable history, mirrored by
apps/web/lib/changelog.ts as the in-app data source (single
ChangelogList component renders both).

- /changelog: user-facing page, linked from the account dropdown.
- /admin/changelog: same list, plus a "Version" card on the admin
  overview dashboard linking to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:06:07 +02:00
Arnaud 2682eba2be fix: presigned upload URLs, card badge alignment, filter menu UX, floating save
- Presigned upload URLs were signed against STORAGE_ENDPOINT (the
  internal docker hostname, e.g. http://minio:9000) instead of a
  browser-reachable URL — uploads/edits with photos were broken in
  prod. Now signed with a separate client bound to STORAGE_PUBLIC_URL,
  which also needed threading through as a Docker build arg (CSP
  headers are computed at build time) and into compose.prod.yml/
  .env.production (gitignored, not committed).
- recipe-form.tsx used the wrong i18n namespace for the visibility
  label (t("recipeForm") instead of t_recipe("recipe")), causing
  MISSING_MESSAGE in French.
- Compact recipe-list view: batch-cook rows showed no dish count, and
  the date/difficulty columns shifted per-row depending on whether a
  difficulty badge was present — reserved a fixed-width slot for it.
- All three card icon badges (batch-cook, favorite, visibility) now
  share the same muted color instead of the batch badge standing out.
- Recipes filter dropdown: clicking a filter option closed the whole
  menu, and the tag text input couldn't be typed into (the menu's
  roving-focus/type-ahead handling was intercepting keystrokes) — menu
  items now stay open on click, and the tag input stops event
  propagation so it behaves like a normal text field.
- Recipe form: added a floating save/cancel bar so long recipes don't
  require scrolling back down to save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 10:53:12 +02:00
Arnaud 1baf5ce20e fix: recipe card badge placement, missing tooltips, untranslated label
- Batch-cook indicator moved from next to the title into the icon row
  alongside favorite/visibility (all three now get a tooltip, matching
  the existing favorite-button pattern).
- Favorite button's tooltip text was hardcoded English ("Save"/
  "Saved") instead of using the translation keys already used for its
  aria-label.
- Shortened the batch-cook generate dialog's "Generating…" label
  (fr.json edited by user) — the long text was the real cause of the
  modal overlap, not the dialog structure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 10:29:13 +02:00
Arnaud 002f14ced0 feat: batch-cook shopping list (already worked) + leftover expiry reminders
Shopping list add already worked generically for batch-cook recipes —
no code needed there.

New: mark a specific batch-cook dish as "cooked today", track its
fridge expiry (cookingHistory.batchDishId), surface a "Leftovers
expiring soon" widget on the pantry page, and send a daily push+email
reminder via a new /api/internal/cron/leftover-reminders endpoint
(mirrors the weekly-digest cron pattern; doesn't use the social
notifications table, which requires a non-null actor and isn't built
for self-reminders).

Also fixes, from user-reported bugs:
- Recipe cards showed no batch-cook badge/dish-count/prep-time in some
  views — added dishCount + prepMins/cookMins (now generated by the AI
  and persisted) to the card component and /recipes query.
- Batch-cook descriptions occasionally contained raw markdown
  (**bold**) — added explicit "plain prose only" prompt instructions
  and a stripMarkdown() defensive fallback at render time.
- Truncated/cut-off descriptions — the generateObject call had no
  maxOutputTokens set, so long structured responses could get cut off
  mid-field; now capped explicitly at 8000.
- Generate dialogs (batch-cook + the main AI dialog) could show
  buttons unreachable once the progress bar appeared mid-generation —
  restructured so the action row is pinned outside the scrollable
  content area, not affected by content height changes.
- /api/internal/* routes were being redirected to /login by middleware
  before their own CRON_SECRET check ever ran (pre-existing bug,
  affected the weekly-digest cron too) — added to PUBLIC_PATHS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 10:03:52 +02:00
Arnaud 646c97128d refactor: fold batch-cooking into the main recipes list and Generate dialog
Batch-cook recipes no longer live in a separate /batch-cooking section:
- Removed the dedicated page/route and its nav button.
- /recipes shows both kinds together, with a chef-hat badge marking
  batch sessions, and a filter to isolate/hide them.
- "Batch cooking" is now a third tab in the existing "Generate recipe
  with AI" dialog (alongside Describe/Photo) instead of a separate
  button — one AI entry point instead of three.
- Extracted the counters/difficulty/dietary form into BatchCookFields,
  shared between that dialog and the meal-planner's batch-cooking
  dialog.
- Also fixed a dialog resize issue (progress bar mounting mid-generate
  grew the dialog's height, which the positioning library didn't
  always handle) by reserving space up front and capping dialog height
  with a scroll fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 09:31:04 +02:00
Arnaud f29fa531a1 feat: add batch-cooking generation entry point to meal planner
Adds a "Batch cooking" button next to "Generate with AI" on the meal
plan page, opening the same wizard used on /batch-cooking. Closes the
loop on the original ask: batch-cooking sessions can now be started
directly from the meal planner, not just from the recipes list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 02:45:53 +02:00
Arnaud ba1614073b feat: adapt recipe surfaces for batch-cook sessions
- Recipes page action buttons reordered/restyled to push AI generation
  and batch cooking ahead of manual recipe creation.
- Recipe detail page hides meal/drink pairing (doesn't make sense for
  a multi-dish batch session).
- Print pages force light mode regardless of app theme (dark bg +
  hardcoded dark text was unreadable) and render sectioned steps +
  dishes/storage for batch-cook recipes.
- Markdown export mirrors the same sectioned structure.
- Cooking mode tags each step with which dish(es) it belongs to.
- Meal planner: mealPlanEntries.batchDishId lets a slot point at one
  specific dish within a batch session; picking a batch-cook recipe
  now prompts for which dish, and the grid shows the dish name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 02:32:57 +02:00
Arnaud 8e83cc0dc7 fix: wait for minio-init before starting web
web only depended on minio being healthy, not on minio-init having
created the upload bucket — a race on cold deploys, and likely why
the deploy tool flagged minio-init's exit(0) as an anomaly (it wasn't
wired into the dependency graph the way migrate is).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 01:44:59 +02:00
Arnaud 78d0599ffc feat: add batch-cooking sessions (single "mega recipe" with dish sections)
New AI-generated batch-cooking flow: pick N dinners/lunches + servings,
get one unified recipe covering a full prep session — merged shopping
list, steps tagged per-dish (a step can advance several dishes at once,
e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact
day-of reheat/finishing instructions.

Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new
recipeBatchDishes table. Batch recipes are excluded from the normal
/recipes list and live under their own /batch-cooking section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 01:23:37 +02:00
Arnaud 68f0f490b9 chore: move docker/ compose files and infra to repo root
Moved compose.yml, compose.prod.yml, DEPLOY.md, traefik/, and cron/ out
of docker/ into the repo root (docker/ removed). Updated every reference:

- Dockerfile: COPY paths for the cron stage
- .dockerignore: dropped the now-unneeded docker/!docker-cron
  exclude+exception pair (cron/ is just a normal top-level dir now, no
  special-casing needed)
- compose.prod.yml: build context ".." -> "." (Dockerfile and compose
  files are now siblings, not one level apart)
- CLAUDE.md, DEPLOY.md, compose.yml, traefik/epicure.yml,
  cron/run-digest.sh, weekly-digest route.ts: path references in comments/
  docs

Verified: both compose files validate, and all three Dockerfile targets
(runner/cron/migrator) build clean via a local --no-cache docker build
from the new layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 20:25:38 +02:00
Arnaud 8b749f432e feat: shared, more pleasing empty-state component across the app
Every "nothing here" page had its own copy-pasted dashed-border box —
icon + one muted line, inconsistent (some had a CTA, some didn't, no
description text anywhere). Replaced with one shared EmptyState
component: icon in a soft tinted circle, a real heading plus optional
description, and primary/secondary actions (either a Link or an
arbitrary action slot for things like "New Collection" that open a
dialog rather than navigate).

Applied to: recipes (no recipes / no search match), favorites, feed
(no one followed / no new posts / trending / for-you), collections
(index + detail), shopping lists, pantry, notifications, can-cook.
Left the small inline "no trending"/"no recent" lines inside Explore's
already-labeled sections and the notification-bell dropdown alone —
different context, a full empty-state box would be heavier than the
space warrants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:30:56 +02:00
Arnaud e0d39c86ef fix: clicking the favorite button on recipe cards still navigated to the recipe
stopPropagation() alone doesn't cancel a Link's native anchor navigation —
that's the browser's default action for the click reaching the <a>, not
a bubbling JS handler propagation can block. Needed preventDefault() too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:09:06 +02:00
Arnaud 0cb926bd26 fix: grid-card favorite button too prominent, move next to visibility icon
Was floating over the cover photo with a circular dark backdrop — too
heavy. Moved into the bottom meta row next to the visibility lock icon,
plain ghost icon button matching the recipe detail page's style exactly
(no background, same size/variant). List and compact rows were already
placed there with no backdrop, only the grid card needed this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:50:56 +02:00
Arnaud 580d5bbf2f feat: favorite button on the My Recipes grid; fix mobile overflow in bulk-action bar
- Added a heart toggle to all three recipe-grid views (grid card overlay,
  list row, compact row), hidden while in multi-select mode. Detail page
  already had it; this was the other place it made sense.
- recipes/page.tsx now fetches which of the current page's recipes are
  already favorited and passes it through.
- FavoriteButton gained an optional iconClassName so the unfavorited heart
  reads on a photo overlay (was invisible in light mode against the dark
  backdrop — filled/favorited state was already fine since it's opaque red).
- Fixed the floating bulk-select action bar overflowing both edges of the
  viewport on mobile (screenshotted: centered with no max-width, wider
  than the screen, clipping "Supprimer" on one side and the selected-count
  label on the other). Now caps to viewport width, scrolls horizontally
  as a fallback, and drops button text labels below sm (icon + aria-label
  + tooltip only), matching the icon-row convention used elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:37:23 +02:00
Arnaud a5661ef882 feat: dedicated favorites page for recipes
Favoriting already worked (heart toggle on the recipe detail page, DB
table, API route) but there was nowhere to see what you'd favorited —
the actual missing piece behind "add a favorites feature".

- New /recipes/favorites page: paginated grid of favorited recipes (cover
  photo, difficulty, time, author), sorted by most-recently favorited.
  A previously-favorited recipe stays visible even if the author later
  goes private, matching the existing private-accounts scope decision
  (existing access isn't revoked, only new discovery is affected).
- Unfavoriting from that page removes the card immediately — added an
  optional onToggle callback to FavoriteButton for this.
- Added a lightweight "My Recipes / Favorites" tab pair at the top of
  /recipes linking between the two, rather than a new top-level nav item
  (nav is already at 8 entries).

Deliberately out of scope: favorite toggles on the main recipe grid, feed
cards, or search/explore results — favoriting your own recipes is odd
UX, and wiring session-aware favorited state into the public
(unauthenticated) search route is a separate, bigger change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 13:46:15 +02:00
Arnaud 1a40eccad5 fix: shopping list detail page action buttons wrap to a second line
flex-wrap let the header buttons (export, share, print, markdown export,
rename/delete) drop to a second row once they didn't fit. Switched to the
same non-wrapping, horizontally-scrollable row pattern already used for
the recipe detail page's action buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 12:52:28 +02:00
Arnaud 521ecce68f refactor: shopping list rename/delete — direct buttons instead of "..." menu
This was the actual menu being reported (a prior fix mistakenly targeted
the per-item category dropdown instead, reverted). Replaced the single
MoreVertical dropdown trigger (rename + delete hidden behind it) with two
directly visible icon buttons + tooltips, matching the recipe detail
page's icon-row convention. Used on both the shopping-lists index rows
and the list detail page header, unchanged at both call sites since only
the component's internals changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:57:20 +02:00
Arnaud 5d2cad255c Revert "refactor: shopping list item row — direct buttons instead of "..." overflow menu"
This reverts commit 0022d807d0.
2026-07-10 11:55:18 +02:00
Arnaud 0022d807d0 refactor: shopping list item row — direct buttons instead of "..." overflow menu
Replaced the hidden dropdown (change category submenu + delete, behind a
single unlabeled MoreVertical icon) with two directly visible controls,
matching the recipe detail page's icon-row pattern: a compact category
Select showing the item's current category at a glance (still supports
picking a predefined category, "Other", or typing a new custom one), and
a direct trash icon button with a tooltip. Row stays single-line via
truncation on the name and a fixed-width category trigger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:44:02 +02:00
Arnaud 70ad1bec9c fix: dragged item didn't visually move into another category until drop
Cross-category drops worked (previous fix) but had no live preview — the
item stayed rendered in its original group the whole time you were
dragging over a different one, since dnd-kit only computes a transform
for items actually present in a group's array.

Added the standard multi-container dnd-kit pattern: onDragOver moves the
item into the target group locally as soon as you drag over it (pure
preview, nothing persisted yet); onDragEnd finalizes the exact position
and only fires a network request for whatever actually changed — order
within the final group, and the aisle itself only if it differs from
what it was when the drag started. Also handles dropping outside any
group: the local preview move is reverted instead of silently leaving an
unpersisted, visually-wrong position until refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:19:43 +02:00
Arnaud d951587d31 fix: dragging an item into a different shopping-list category didn't work
Each category group had its own isolated DndContext, so dnd-kit had no way
to resolve a drop that landed in a different group — cross-category drags
were silently no-ops by construction, not an edge-case bug.

Lifted to a single DndContext wrapping all groups, with one handleDragEnd
that looks up both the dragged item's and the drop target's current group
from live state: same group reorders as before, different group changes
the item's aisle (appended after the item it was dropped on) via the
existing changeCategory persistence path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:11:07 +02:00
Arnaud 51a323b054 fix: shopping list categories were English-only and untranslated, page too narrow
Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:

- Categories are now translated (grocery-categories.ts stores locale-
  independent slugs like "produce", translated for display via
  shoppingLists.categories.* instead of storing/rendering the English label
  directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
  actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
  its label — base-ui's Select.Value has no built-in value->label lookup,
  it needs an explicit render function, which no Select in this call site
  had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
  the quantity embedded in the name (e.g. "1 avocat") still showed that way
  even though quantity/unit were already populated separately, because the
  extraction helper only fired when quantity was empty. Now it always
  cleans the name but only backfills quantity/unit when they're missing,
  and runs at shopping-list generation time too (not just recipe save) so
  it also cleans up already-contaminated recipes, not just new ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:03:11 +02:00
Arnaud 5afc7cd182 feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix
- Fixed a real i18n bug: the checked-count line called the wrong
  translation namespace and rendered the literal key on screen
- Shopping lists can now be renamed and deleted from both the list index
  and detail pages (API already supported delete; rename was net new)
- Root-caused "long list UI is off": meal-plan-generated lists never set
  an aisle, so every item fell into one undifferentiated "Other" bucket
  despite the grouping UI existing. Added a keyword-based aisle guesser
  wired into list generation (fallback only, never overrides an explicit
  aisle) plus a one-click "auto-categorize" for existing lists
- Items can now be reordered by drag-and-drop within a category (dnd-kit),
  recategorized via a dropdown, deleted, searched, and sorted (category /
  alphabetical / unchecked-first); searching flattens the grouped view
- Fixed a separate bug: AI-generated ingredients sometimes embedded the
  quantity/unit in the name itself (e.g. "2 cups flour" as one string).
  Added extractIngredientQuantity() as a Zod transform at both recipe
  create/update routes (the choke point every creation path funnels
  through) to split it back out, plus schema descriptions on the AI
  ingredient schemas as a prevention layer

New migration 0028 (shopping_list_items.sort_order), left unapplied like
the others. Verified with typecheck, lint, and a clean --no-cache docker
build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 10:29:28 +02:00
Arnaud d62e2a6383 feat: one-click shopping list from meal plan, fix ingredient merge bug
- Meal-plan page now has a direct "Add to shopping list" action for the
  currently-viewed week, instead of requiring a trip to Shopping Lists and
  manually typing the week's Monday date.
- Fixed a real under-shopping bug in shopping-lists/route.ts: when
  generating from a meal-plan week, ingredients appearing in more than one
  recipe were merged by keeping only the FIRST occurrence's quantity and
  silently dropping the rest. New mergeIngredients() (pantry-shopping-match.ts)
  groups by ingredientId (or normalized name+unit) and sums quantities
  across every recipe in the week; incompatible/unparseable units are kept
  as separate line items rather than guessed at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:45:51 +02:00
Arnaud 9c545a5bb3 feat: private accounts, explore/people merge, meal-plan fixes, i18n and theme cleanup
- Private accounts: users.isPrivate hides a user from search and their
  recipes from search/trending/for-you discovery surfaces (follow-aware
  where the route already has session context, blanket exclusion where it
  doesn't); existing followers and direct links are unaffected, no
  follow-request approval flow was built (explicit scope limit)
- Merged /people into the Explore tab (tab=people query param); the old
  standalone route now redirects there
- "Get Ideas" vs "Surprise Me" were doing the same empty-prompt call;
  Surprise Me now injects a real random constraint (5-ingredient, one-pot,
  etc.), matching the existing pattern in the AI recipe-generate dialog
- Meal-plan day cells now link to their recipe (was dead text) and gained
  a one-click "mark as cooked" action
- Theme toggle is now a real three-way light/dark/system control instead
  of a binary flip
- Nutrition goals form had zero i18n wiring; fully localized now

New migration 0027 (users.is_private) generated, left unapplied like the
others. Verified with typecheck, lint, and a clean --no-cache docker build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:26:03 +02:00
Arnaud 36e7698096 docs: resolve recipe-quota semantics decision in HANDOFF.md
Keep maxRecipes as a creations-per-month counter; deleting a recipe does
not free up quota. No code change needed, current behavior already matches.
2026-07-10 08:25:35 +02:00
Arnaud b0849c3989 feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
Six M-sized items from HANDOFF.md's new-features backlog:

- Profile tabs: cooking-history stats (total cooked, last-cooked, streak)
  and a "cooked it" photo gallery, both owner-only
- Display-time unit conversion (metric<->imperial) for recipe ingredients,
  respecting each user's unitPref; original value always shown alongside
  the conversion
- Nutrition daily diary: per-day macro totals computed from cooking history
  x recipe nutritionData, compared against user goals
- Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key)
  with an AI-vision fallback for unbarcoded items, always confirm-before-
  insert, both paths tier/rate-limited like other AI features
- Weekly digest email: new followers/comments/ratings + trending recipes,
  sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron`
  compose service hitting a bearer-token-protected internal route
- Meal-plan generation can now target a user's nutrition goals as a
  prompt-level nudge (recipes are AI-invented, not DB-sourced, so this
  can't be a hard macro constraint)

Caught a real deploy-breaking issue while adding the cron stage: appending
it after `runner` silently changed the Dockerfile's default build target,
and `web`'s compose config didn't pin one — fixed by pinning `target:
runner` explicitly. Verified with typecheck, lint, and three separate
`docker build --target` runs (runner/cron/migrator) plus `docker compose
config` validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 08:06:28 +02:00
Arnaud 913410dbf3 feat: notifications inbox page
The bell only ever showed the last 30; add a full paginated /notifications
page, per-notification mark-read, and a shared notificationHref helper so
the bell and the page route identically instead of duplicating the logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 07:57:17 +02:00
Arnaud 45b886e398 feat: push+email notifications, recipe notes, fork/clone, pantry-aware lists, GDPR export
Five S-sized items from HANDOFF.md's new-features backlog, all wiring up
previously-orphaned infra:

- createNotification now sends web push + email for every notification type
  (follow/comment/reply/reaction/rating/mention), not just comments
- Personal recipe notes: private per-user notes on any viewable recipe
  (recipeNotes table had zero API/UI before this)
- Recipe fork/clone: deep-copies a viewable recipe into your own library as
  a private draft, linked via recipeVariations, respects tier quota
- Pantry-aware shopping lists: meal-plan-generated lists now subtract
  on-hand pantry quantities (ingredientId match, falling back to normalized
  name match) and flag partial/ambiguous matches instead of guessing
- GDPR data export: downloadable JSON of a user's own content and activity
  across every relevant table, secrets/internal tables excluded

New migrations 0025 (unique index for recipe-notes upsert) and 0026
(shopping_list_items.in_pantry) generated, left unapplied like 0023/0024.
Verified with typecheck, lint, and a full local `docker build`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 07:50:58 +02:00
Arnaud d035378520 fix: remove dead api-types Dockerfile refs, drop duplicate middleware.ts
Dockerfile still COPYed packages/api-types (deleted in the prior audit-fixes
commit), breaking Portainer builds. Also drop apps/web/middleware.ts, added
in the same commit for auth guarding — it duplicated and conflicted with the
pre-existing apps/web/proxy.ts (Next 16's middleware entry point), which
already redirects unauthenticated requests to /login, 401s API routes, and
covers /recipes/new, /explore, /search, /settings/webhooks/docs via its
catch-all matcher. Having both middleware.ts and proxy.ts breaks `next build`
outright. Verified with a local `docker build` end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 07:22:24 +02:00
Arnaud 362f65656b fix: audit fixes — tier-quota bypass, webhook SSRF, auth hardening, pagination, a11y
Full audit (bugs/UI-UX/backend/feature-gap) turned up a money-leak AI quota
bypass, webhook SSRF, and a long tail of missing pagination/auth/a11y work.
Fixes land together since HANDOFF.md tracked them as one backlog.

- AI routes charge tier quota before generating; nutrition POST is author-only
- Webhook dispatch re-validates URL per delivery (SSRF/DNS-rebinding), treats
  redirects as failures; recipe.published now actually dispatches
- New indexes/unique constraints on recipes, meal-planning, comments FK cascade
- Recipe PUT/restore snapshot only inside the transaction, after validation
- Recipe DELETE cleans up S3 objects (recipe + review photos)
- Optimistic UI (favorite/star/follow/shopping-list) rolls back on failure
- Upload presign enforces file size cap + per-tier storage quota
- Route-level loading/error/not-found states across (app), admin, and root
- middleware.ts guards (app)/admin; requireAdmin checks DB role, not cached
  session; rate limiting applied to both session and API-key branches,
  bucketed per key; Stripe webhook dedupes by event id
- Pagination added to recipes, feed, profile, comments, pantry, admin tables
- Nav shows real avatar + profile link + dark-mode toggle; destructive actions
  standardized on AlertDialog
- Unsaved-changes guard + real ingredient/step validation on recipe form;
  canonical /recipes/[id] used in-app; next/image migration; aria-labels and
  alt text across icon buttons, avatars, recipe photos
- packages/api-types removed (zero callers, too drifted to safely rewire);
  openapi.ts and ai-keys error shape drift fixed; BYOK decrypt failures now
  surface instead of silently falling back to the platform key

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:50:35 +02:00
553 changed files with 238203 additions and 4060 deletions
-1
View File
@@ -5,5 +5,4 @@
.git .git
.env* .env*
!.env.example !.env.example
docker
*.md *.md
+21 -1
View File
@@ -57,9 +57,29 @@ SMTP_FROM=Epicure <noreply@epicure.app>
NEXT_PUBLIC_VAPID_PUBLIC_KEY= NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Stripe (optional — webhook stub only) # 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 — billing for Pro/Family tiers). Only needed as a
# bootstrap fallback for self-hosters without the admin settings UI set up
# yet — a value stored via Admin > Settings takes precedence.
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
# Shared secret for internal cron-triggered endpoints (e.g. weekly digest email).
# Generate with: openssl rand -base64 32
CRON_SECRET=
# AI Providers (configure at least one) # AI Providers (configure at least one)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5 OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5
+1 -1
View File
@@ -44,4 +44,4 @@ coverage/
# Misc # Misc
.claude/ .claude/
project_epicure.md project_epicure.md
PLAN.md plans/
+726
View File
@@ -0,0 +1,726 @@
# Changelog
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.73.0 — 2026-07-23 14:00
### Added
- Real Stripe billing for Pro/Family: subscribe and manage your plan yourself at Settings → Billing (Checkout + Stripe's hosted Customer Portal for cancel/upgrade/card updates), instead of only an admin being able to change your tier. Cancel takes effect at the end of the paid period.
- Admin → Billing: connection status, subscriber counts, past-due payments needing attention, recent billing events. Admin → Tier Limits gained Stripe Product/Price ID fields per tier.
Family tier is purchasable solo but doesn't yet support inviting other accounts into a shared subscription — that's a separate, larger piece tracked in `plans/STRIPE_PLAN.md`, deliberately shipped after solo billing.
## 0.72.0 — 2026-07-23 09:30
### Added
- Developer access (webhooks + API keys) is now self-serve for any paid-tier user, free of charge — enable it yourself in Settings instead of waiting on an admin. Free tier still needs an admin grant. BYOK is now a fully separate permission, admin-only, unaffected by the self-serve change.
## 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.
### Fixed
- Batch-cook dishes never deducted pantry when marked cooked — now deducts once, on the first dish cooked per batch session.
- 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 16:13
### Added
- **Profile pictures**: upload a custom photo, or get a Gravatar-backed avatar automatically — no more generic initials by default.
- **"Imported from" badge** on recipe cards for recipes brought in from a URL.
- **Share shopping lists via a public link** — anyone with the link can view, no account required, alongside the existing private collaborator invites.
### 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 15:08
### Added
- A free-text "describe what you're after" field for batch-cooking generation, matching the standard recipe generator.
### Fixed
- AI recipe generation used a separate language picker instead of following the app's own language — removed, generation now always matches your app language.
- 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 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 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 13:21
### Fixed
- Recipe photos were lost after saving, and multi-file uploads only kept the last file selected.
- Photo thumbnails failed to load (blocked by Next.js's image optimizer refusing local-storage hosts).
## 0.2.0 — 2026-07-12
### Added
- **Batch cooking**: generate a single "mega recipe" covering several meals at once — merged shopping list, steps tagged per dish (including steps that advance several dishes at once), and per-dish storage/reheat guidance. Available from the recipe list's Generate dialog and from the meal planner.
- **Leftover expiry reminders**: mark a batch-cook dish as cooked, track its fridge life, see it surfaced on the Pantry page, and get a daily reminder before it goes bad.
- **"Cooked it" photo reviews**: attach a photo and written review when rating a recipe.
- **Pantry-expiry recipe suggestions**: the Pantry page now surfaces recipes that use up ingredients about to expire.
- **Trending collections**: star public collections; browse trending and recently-added ones under Collections → Discover.
- **Add to collection from the recipe list**: select recipes and add them to a new or existing collection without leaving the page.
- List and compact view modes for the recipe list, alongside the existing grid view.
### Fixed
- Recipe version-comparison diff was unreadable and partially untranslated.
- Print/export pages always render light, regardless of the app's dark/light setting.
- Presigned photo-upload URLs were signed against an internal-only address in production, breaking uploads.
- Several recipe-list and recipe-form UI issues: filter menu closing on every click, tag filter not accepting keystrokes, inconsistent card badge styling, missing tooltips, a floating save button on long recipe forms.
## 0.1.0 — earlier
Initial feature set: recipes (create, edit, AI-generate, import from URL/photo), meal planning, shopping lists, pantry tracking, collections, social features (follow, comment, rate, notifications), nutrition tracking, admin dashboard, and account/billing basics.
+3 -4
View File
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash ```bash
cp .env.example .env.local # fill in secrets (lives at repo root) cp .env.example .env.local # fill in secrets (lives at repo root)
ln -s ../../.env.local apps/web/.env.local # Next.js reads from apps/web/, not root ln -s ../../.env.local apps/web/.env.local # Next.js reads from apps/web/, not root
docker compose -f docker/compose.yml up -d # start postgres, redis, minio docker compose -f compose.yml up -d # start postgres, redis, minio
pnpm install pnpm install
pnpm db:generate # generate migrations from schema (first time) pnpm db:generate # generate migrations from schema (first time)
pnpm db:migrate # apply migrations pnpm db:migrate # apply migrations
@@ -36,7 +36,6 @@ pnpm db:studio # open Drizzle Studio
**Monorepo** (pnpm workspaces): **Monorepo** (pnpm workspaces):
- `apps/web` — Next.js 15 App Router app (`@epicure/web`) - `apps/web` — Next.js 15 App Router app (`@epicure/web`)
- `packages/db` — Drizzle ORM schema + client (`@epicure/db`) - `packages/db` — Drizzle ORM schema + client (`@epicure/db`)
- `packages/api-types` — Zod schemas, single source of truth for API types + OpenAPI (`@epicure/api-types`)
**Route groups in `apps/web/app/`**: **Route groups in `apps/web/app/`**:
- `(auth)/` — login, signup, verify-email (no auth required) - `(auth)/` — login, signup, verify-email (no auth required)
@@ -45,7 +44,7 @@ pnpm db:studio # open Drizzle Studio
- `api/v1/` — REST API endpoints - `api/v1/` — REST API endpoints
- `api/auth/[...all]/` — Better Auth handler - `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`. **DB**: Drizzle ORM on Postgres. Schema in `packages/db/src/schema/` split by domain: `users`, `recipes`, `social`, `meal-planning`, `tiers`. Import from `@epicure/db`.
@@ -66,7 +65,7 @@ cd apps/web && pnpm dlx shadcn@latest add <component-name>
1. Edit `packages/db/src/schema/*.ts` 1. Edit `packages/db/src/schema/*.ts`
2. `pnpm db:generate` — creates migration file 2. `pnpm db:generate` — creates migration file
3. `pnpm db:migrate` — applies it 3. `pnpm db:migrate` — applies it
4. Update corresponding Zod schemas in `packages/api-types/src/` 4. Update the corresponding inline Zod schemas in the affected `apps/web/app/api/v1/**/route.ts` files (and `apps/web/lib/openapi.ts` if documented there)
## Environment Variables ## Environment Variables
+3 -3
View File
@@ -4,7 +4,7 @@
1. Stacks → Add stack → **Repository** 1. Stacks → Add stack → **Repository**
2. Repository URL: this repo. Reference: branch to track (e.g. `main`) 2. Repository URL: this repo. Reference: branch to track (e.g. `main`)
3. Compose path: `docker/compose.prod.yml` 3. Compose path: `compose.prod.yml`
4. Environment variables (Portainer stack env, not committed): 4. Environment variables (Portainer stack env, not committed):
``` ```
@@ -47,7 +47,7 @@ ANTHROPIC_API_KEY=
OLLAMA_BASE_URL= OLLAMA_BASE_URL=
``` ```
Every var listed here must exist in `docker/compose.prod.yml`'s `web.environment` block to actually Every var listed here must exist in `compose.prod.yml`'s `web.environment` block to actually
reach the container — Portainer stack env alone isn't enough, it only fills in `${...}` refs the reach the container — Portainer stack env alone isn't enough, it only fills in `${...}` refs the
compose file declares. compose file declares.
@@ -70,7 +70,7 @@ Both are idempotent — safe to re-run on every stack redeploy, not just the fir
## Traefik (separate LXC, file provider) ## Traefik (separate LXC, file provider)
1. Copy `docker/traefik/epicure.yml` into the traefik LXC's dynamic config directory. 1. Copy `traefik/epicure.yml` into the traefik LXC's dynamic config directory.
2. Replace `HOST_DOMAIN` with the public hostname and `PORTAINER_LXC_IP` with the portainer LXC's network IP (must match `WEB_PORT` published in compose.prod.yml). 2. Replace `HOST_DOMAIN` with the public hostname and `PORTAINER_LXC_IP` with the portainer LXC's network IP (must match `WEB_PORT` published in compose.prod.yml).
3. Confirm `certResolver` name matches what's set in traefik's static config. 3. Confirm `certResolver` name matches what's set in traefik's static config.
4. Traefik picks it up automatically (file provider watches for changes) — no restart needed. 4. Traefik picks it up automatically (file provider watches for changes) — no restart needed.
+23 -2
View File
@@ -8,7 +8,6 @@ WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json COPY packages/db/package.json packages/db/package.json
COPY packages/api-types/package.json packages/api-types/package.json
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# ---- migrator: applies drizzle migrations + seeds tier definitions ---- # ---- migrator: applies drizzle migrations + seeds tier definitions ----
@@ -23,7 +22,6 @@ WORKDIR /repo
COPY --from=deps /repo/node_modules ./node_modules COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/apps/web/node_modules ./apps/web/node_modules COPY --from=deps /repo/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules
COPY --from=deps /repo/packages/api-types/node_modules ./packages/api-types/node_modules
COPY . . COPY . .
# apps/web/.env.local is normally a symlink to repo-root .env.local (gitignored); # apps/web/.env.local is normally a symlink to repo-root .env.local (gitignored);
# it doesn't exist in the build context, so Next's env loader chokes on the dangling # it doesn't exist in the build context, so Next's env loader chokes on the dangling
@@ -41,6 +39,16 @@ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY
ENV NEXT_PUBLIC_GITHUB_ENABLED=$NEXT_PUBLIC_GITHUB_ENABLED ENV NEXT_PUBLIC_GITHUB_ENABLED=$NEXT_PUBLIC_GITHUB_ENABLED
ENV NEXT_PUBLIC_DISCORD_ENABLED=$NEXT_PUBLIC_DISCORD_ENABLED ENV NEXT_PUBLIC_DISCORD_ENABLED=$NEXT_PUBLIC_DISCORD_ENABLED
ENV NEXT_PUBLIC_AUTHENTIK_ENABLED=$NEXT_PUBLIC_AUTHENTIK_ENABLED ENV NEXT_PUBLIC_AUTHENTIK_ENABLED=$NEXT_PUBLIC_AUTHENTIK_ENABLED
# next.config.ts reads STORAGE_PUBLIC_URL to compute the CSP connect-src/img-src
# allowlist, and next.config.ts's headers() are evaluated at build time — so this
# also needs to be a build arg, not just a runtime env var (unlike STORAGE_ENDPOINT).
ARG STORAGE_PUBLIC_URL
ENV STORAGE_PUBLIC_URL=$STORAGE_PUBLIC_URL
# lib/storage.ts's getPublicUrl() is called from client components to render photo
# <img> tags — a plain (non-NEXT_PUBLIC_) env var is never inlined into the browser
# bundle, so it must be duplicated under a NEXT_PUBLIC_ name to reach the client.
ARG NEXT_PUBLIC_STORAGE_PUBLIC_URL
ENV NEXT_PUBLIC_STORAGE_PUBLIC_URL=$NEXT_PUBLIC_STORAGE_PUBLIC_URL
RUN pnpm --filter web build RUN pnpm --filter web build
# ---- runtime ---- # ---- runtime ----
@@ -58,3 +66,16 @@ COPY --from=build --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/.
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]
# ---- cron: periodically triggers internal cron endpoints (e.g. weekly digest) ----
# Minimal alpine + busybox crond image — just curls the running `web` service on a
# schedule. Doesn't need the app build output at all, so it's based on `base`
# rather than `runner`, keeping the image tiny and independent of the Next build.
FROM base AS cron
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
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"]
+163
View File
@@ -0,0 +1,163 @@
# 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 + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
| Admin dashboard | Exists | 13 sections: overview, insights/analytics, users, invites, recipe moderation, reports, support, tiers, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/**` |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest, requires developer access | `apps/web/app/api/v1/api-keys` |
| Public OpenAPI spec + docs page | Exists | Scalar-rendered at `/docs`, spec at `/api/v1/openapi.json` | `apps/web/lib/openapi.ts` |
| i18n | Partial | Only `en` and `fr` — no other locales | `apps/web/messages/*.json` |
| 2FA | Exists | TOTP + backup codes + OTP, passwordless-account-compatible | Better Auth `twoFactor` plugin |
| Social login | Exists | Google always, GitHub/Discord/generic OIDC if configured | `apps/web/lib/auth/server.ts` |
| Rate limiting | Exists | Two layers: Better Auth's Redis-backed built-in limiter (tighter on 2FA verify endpoints) + a general sliding-window limiter for AI/API routes | `apps/web/lib/rate-limit.ts` |
| Invite-gated signup | Exists | Signups can be globally disabled, invite-only fallback; first-ever user auto-promoted to admin | — |
---
## 5. PWA, offline, mobile
| Feature | Status | Description | Key files |
|---|---|---|---|
| Installability | Exists | Manifest + SVG icons (no raster/PNG fallback), Chromium `beforeinstallprompt` button, **iOS Safari gets a static "tap Share" instructional banner** (no programmatic install API exists on Safari — Apple restriction, not fixable) | `apps/web/app/manifest.ts`, `apps/web/components/pwa/install-prompt.tsx` |
| Service worker | Exists | Cache-first for `/cook` pages, network-first-with-cache-populate elsewhere, `/api/*` bypassed | `apps/web/public/sw.js` |
| Background Sync | Exists | Chromium via native Background Sync API; Safari/Firefox (no support at all) via an `online`-event fallback reading the same IndexedDB queue | `apps/web/lib/offline-queue.ts`, `apps/web/public/sw.js` |
| Offline save-for-later | Exists | Per-recipe explicit pin + IndexedDB-tracked list shown on the `/offline` fallback page | `apps/web/lib/offline-db.ts`, `apps/web/components/recipe/save-offline-button.tsx` |
| Offline scope | Partial | Recipe viewing + one write path (mark-cooked). **Shopping lists and meal plan have no offline/queue coverage** | — |
| Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` |
| Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` |
| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target` → `/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` |
| Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — |
| Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — |
---
## Real gaps (confirmed absent, worth considering)
Ranked roughly by likely value:
1. ~~Stripe checkout + billing portal~~ — closed 2026-07-23 (solo Pro/Family subscribe/cancel/manage; family multi-user sharing still deferred, see the note in the table above).
2. **Recipe video** — no support at all.
3. ~~Nutrition trend/history view~~ — closed 2026-07-22 (7/30/90-day calorie chart + macro averages).
4. ~~USDA/nutrition-database lookup~~ — closed 2026-07-22 (per-ingredient, gram-convertible ingredients only; AI fills the rest).
5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
7. ~~Anonymous public link for meal plans~~ — closed 2026-07-21 (read-only, with a QR code).
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live).
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
## 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.
-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`).
+77 -23
View File
@@ -2,15 +2,23 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { Printer } from "lucide-react"; import { Printer, UtensilsCrossed, StickyNote, ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, collections, eq, and, or } from "@epicure/db"; import { db, collections, eq, and } from "@epicure/db";
import { RecipeCard } from "@/components/recipe/recipe-card"; 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 { ForkCollectionButton } from "@/components/collections/fork-collection-button";
import { ShareCollectionButton } from "@/components/collections/share-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 { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { EmptyState } from "@/components/shared/empty-state";
import { collectionToMarkdown } from "@/lib/markdown/collection"; import { collectionToMarkdown } from "@/lib/markdown/collection";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
@@ -25,16 +33,19 @@ export default async function CollectionPage({ params }: Params) {
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const col = await db.query.collections.findFirst({ const col = await db.query.collections.findFirst({
where: and( where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
eq(collections.id, id), with: {
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true)) recipes: {
), orderBy: (t, { asc }) => asc(t.position),
with: { recipes: { with: { recipe: { with: { photos: true } } } } }, with: { recipe: { with: { photos: true } } },
},
},
}); });
if (!col) notFound(); if (!col) notFound();
const isOwner = col.userId === session.user.id; const isOwner = col.userId === session.user.id;
const recipeList = col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : []));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -42,42 +53,85 @@ export default async function CollectionPage({ params }: Params) {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1> <h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>} {col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
<p className="text-sm text-muted-foreground mt-1"> {col.tags.length > 0 && (
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"} <div className="flex flex-wrap gap-1.5 mt-2">
</p> {col.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> )}
{col.recipes.length > 0 && ( <p className="text-sm text-muted-foreground mt-1">
{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>
<TooltipProvider>
<div className="flex flex-wrap items-center gap-1">
{recipeList.length > 0 && (
<> <>
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> <Tooltip>
<TooltipTrigger render={
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Printer className="h-4 w-4" /> <Printer className="h-4 w-4" />
{m.collections.exportPdf}
</Link> </Link>
} />
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
</Tooltip>
<ExportMarkdownButton <ExportMarkdownButton
markdown={collectionToMarkdown({ markdown={collectionToMarkdown({
name: col.name, name: col.name,
description: col.description, description: col.description,
recipes: col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : [])), recipes: recipeList,
})} })}
filename={col.name} filename={col.name}
/> />
</> </>
)} )}
{isOwner && <GenerateMealDialog collectionId={id} />}
{isOwner && <ShareCollectionButton collectionId={id} />} {isOwner && <ShareCollectionButton collectionId={id} />}
{!isOwner && col.isPublic && ( {isOwner && (
<EditCollectionDialog
collectionId={id}
initialName={col.name}
initialDescription={col.description}
initialNotes={col.notes}
initialTags={col.tags}
initialVisibility={col.visibility}
/>
)}
{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} /> <ForkCollectionButton collectionId={id} />
)} )}
</div> </div>
</TooltipProvider>
</div> </div>
{col.recipes.length === 0 ? ( {recipeList.length === 0 ? (
<div className="flex items-center justify-center h-48 border-2 border-dashed rounded-xl"> <EmptyState icon={UtensilsCrossed} title={m.collections.emptyCollection} compact />
<p className="text-muted-foreground text-sm">No recipes in this collection yet.</p> ) : isOwner ? (
</div> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{col.recipes.map(({ recipe }) => ( {recipeList.map((recipe) => (
recipe && <RecipeCard key={recipe.id} recipe={recipe} /> <Link key={recipe.id} href={`/recipes/${recipe.id}`}>
<RecipeGridCard recipe={recipe} />
</Link>
))} ))}
</div> </div>
)} )}
@@ -49,7 +49,7 @@ export default async function ExploreCollectionsPage() {
collectionFavorites, collectionFavorites,
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo)) 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) .groupBy(collections.id, users.id)
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`)) .orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
.limit(12); .limit(12);
@@ -63,7 +63,7 @@ export default async function ExploreCollectionsPage() {
}) })
.from(collections) .from(collections)
.innerJoin(users, eq(collections.userId, users.id)) .innerJoin(users, eq(collections.userId, users.id))
.where(eq(collections.isPublic, true)) .where(eq(collections.visibility, "public"))
.orderBy(desc(collections.createdAt)) .orderBy(desc(collections.createdAt))
.limit(12); .limit(12);
@@ -0,0 +1,14 @@
import { PageHeaderSkeleton, InfoCardSkeleton } from "@/components/shared/skeletons";
export default function CollectionsLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<InfoCardSkeleton key={i} />
))}
</div>
</div>
);
}
+64 -9
View File
@@ -1,29 +1,84 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; 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 { CollectionsPageContent } from "@/components/collections/collections-page-content";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {}; 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() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; if (!session) return null;
const userCollections = await db.query.collections.findMany({ const { q } = await searchParams;
where: eq(collections.userId, session.user.id), const query = q?.trim();
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 1 } }, 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 ( return (
<CollectionsPageContent <CollectionsPageContent
query={query ?? ""}
collections={userCollections.map((col) => ({ collections={userCollections.map((col) => ({
id: col.id, id: col.id,
name: col.name, name: col.name,
description: col.description, description: col.description,
isPublic: col.isPublic, tags: col.tags,
recipeCount: col.recipes.length, 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,
}];
}),
}))} }))}
/> />
); );
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
An unexpected error occurred while loading this page. You can try again, or head back
later.
</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton } from "@/components/ui/skeleton";
import { InfoCardSkeleton } from "@/components/shared/skeletons";
function ExploreSection() {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<InfoCardSkeleton key={i} />
))}
</div>
</div>
);
}
export default function ExploreLoading() {
return (
<div className="space-y-8">
<Skeleton className="h-12 w-full max-w-xl rounded-md" />
<ExploreSection />
<ExploreSection />
</div>
);
}
+74 -23
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers";
import { import {
db, db,
recipes, recipes,
users, users,
favorites, favorites,
userFollows,
eq, eq,
desc, desc,
sql, sql,
@@ -11,17 +13,29 @@ import {
gte, gte,
count, count,
} from "@epicure/db"; } from "@epicure/db";
import { auth } from "@/lib/auth/server";
import { ExplorePageContent } from "@/components/search/explore-page-content"; import { ExplorePageContent } from "@/components/search/explore-page-content";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export type RecipeResult = { export type RecipeResult = {
id: string; id: string;
title: string; title: string;
description: string | null;
authorName: string | null; authorName: string | null;
difficulty: string | null; difficulty: "easy" | "medium" | "hard" | null;
baseServings: number;
prepMins: number | null; prepMins: number | null;
cookMins: 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({ export default async function ExplorePage({
@@ -30,19 +44,30 @@ export default async function ExplorePage({
searchParams: Promise<{ q?: string }>; searchParams: Promise<{ q?: string }>;
}) { }) {
const { q } = 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 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 // Trending: public recipes ordered by favorite count in last 7 days
const trendingRows = await db const trendingRows = await db
.select({ .select({ ...columns, favoriteCount: count(favorites.recipeId) })
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
favoriteCount: count(favorites.recipeId),
})
.from(recipes) .from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id)) .innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin( .leftJoin(
@@ -52,29 +77,55 @@ export default async function ExplorePage({
gte(favorites.createdAt, sevenDaysAgo) gte(favorites.createdAt, sevenDaysAgo)
) )
) )
.where(eq(recipes.visibility, "public")) .where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.groupBy(recipes.id, users.id) .groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`)) .orderBy(desc(sql`count(${favorites.recipeId})`))
.limit(12); .limit(12);
// Recent: public recipes ordered by createdAt desc // Recent: public recipes ordered by createdAt desc
const recentRows = await db const recentRows = await db
.select({ .select(columns)
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
})
.from(recipes) .from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id)) .innerJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public")) .where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.orderBy(desc(recipes.createdAt)) .orderBy(desc(recipes.createdAt))
.limit(12); .limit(12);
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r); const [trending, recent] = await Promise.all([
const recent: RecipeResult[] = recentRows; attachCardExtras(trendingRows.map(({ favoriteCount: _fc, ...r }) => r), session?.user.id),
attachCardExtras(recentRows, session?.user.id),
]);
return <ExplorePageContent trending={trending} recent={recent} initialQuery={q ?? ""} />; // 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 ?? ""}
popularTags={popularTags}
followedCount={followedCount}
/>
);
} }
+11
View File
@@ -0,0 +1,11 @@
import { FeedItemSkeleton } from "@/components/shared/skeletons";
export default function FeedLoading() {
return (
<div className="max-w-2xl mx-auto space-y-8">
{Array.from({ length: 4 }).map((_, i) => (
<FeedItemSkeleton key={i} />
))}
</div>
);
}
+3 -52
View File
@@ -1,54 +1,5 @@
import type { Metadata } from "next"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, users, userFollows, eq, desc, inArray } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage";
import { FeedPageContent } from "@/components/feed/feed-page-content";
export const metadata: Metadata = {}; export default function FeedPage() {
redirect("/explore");
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));
const followedIds = followedRows.map((r) => r.followingId);
if (followedIds.length === 0) {
return <FeedPageContent followedCount={0} feedRecipes={[]} />;
}
const feedRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(inArray(recipes.authorId, followedIds))
.orderBy(desc(recipes.createdAt))
.limit(40);
return (
<FeedPageContent
followedCount={followedIds.length}
feedRecipes={feedRecipes.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
/>
);
} }
+10
View File
@@ -0,0 +1,10 @@
import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons";
export default function AppLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<RecipeCardGridSkeleton />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeaderSkeleton } from "@/components/shared/skeletons";
export default function MealPlanLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={4} subtitle />
<Skeleton className="h-16 w-full rounded-xl" />
<div className="grid grid-cols-1 md:grid-cols-7 gap-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full rounded-xl" />
))}
</div>
</div>
);
}
+82 -22
View File
@@ -1,12 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; 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 { auth } from "@/lib/auth/server";
import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db"; import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MealPlanner } from "@/components/meal-plan/meal-planner"; import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button"; import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar"; import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
@@ -16,16 +18,30 @@ import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
function getMonday(dateStr?: string): Date { function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date(); // new Date("YYYY-MM-DD") parses as UTC midnight, but getDay() below reads
const day = d.getDay(); // local time — in negative UTC-offset zones that's still "yesterday",
const diff = (day === 0 ? -6 : 1 - day); // shifting the resolved Monday back by a full week. Parse the y/m/d parts
// directly into local time instead.
const d = dateStr
? (() => {
const [y, m, day] = dateStr.split("-").map(Number);
return new Date(y!, m! - 1, day!);
})()
: new Date();
const dow = d.getDay();
const diff = (dow === 0 ? -6 : 1 - dow);
d.setDate(d.getDate() + diff); d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
return d; return d;
} }
function toDateStr(d: Date): string { function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10); // Read local y/m/d, not toISOString() (which converts to UTC and can
// shift the date by a day depending on the server's timezone offset).
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
function addWeeks(d: Date, n: number): Date { function addWeeks(d: Date, n: number): Date {
@@ -52,26 +68,35 @@ export default async function MealPlanPage({
const sunday = addWeeks(monday, 1); const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1); sunday.setDate(sunday.getDate() - 1);
const [plan, userRecipes, sharedMemberships] = await Promise.all([ const [plan, userRecipes, sharedMemberships, nutritionGoals] = await Promise.all([
db.query.mealPlans.findFirst({ db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)), where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: { with: {
entries: { entries: {
with: { recipe: { with: { photos: true } } }, with: { recipe: { with: { photos: true } }, batchDish: true },
}, },
}, },
}), }),
db.query.recipes.findMany({ db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id), where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt), orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true }, columns: { id: true, title: true, isBatchCook: true },
with: { batchDishes: { columns: { id: true, name: true }, orderBy: (t, { asc }) => asc(t.order) } },
}), }),
db.query.mealPlanMembers.findMany({ db.query.mealPlanMembers.findMany({
where: eq(mealPlanMembers.userId, session.user.id), where: eq(mealPlanMembers.userId, session.user.id),
with: { mealPlan: { with: { user: true } } }, with: { mealPlan: { with: { user: true } } },
}), }),
db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session.user.id),
}),
]); ]);
const hasNutritionGoals = !!(
nutritionGoals &&
(nutritionGoals.caloriesKcal || nutritionGoals.proteinG || nutritionGoals.carbsG || nutritionGoals.fatG)
);
const entries = (plan?.entries ?? []).map((e) => ({ const entries = (plan?.entries ?? []).map((e) => ({
id: e.id, id: e.id,
day: e.day, day: e.day,
@@ -79,42 +104,77 @@ export default async function MealPlanPage({
servings: e.servings, servings: e.servings,
note: e.note, note: e.note,
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null, recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
batchDish: e.batchDish ? { id: e.batchDish.id, name: e.batchDish.name } : null,
})); }));
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<TooltipProvider>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1> <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>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} /> <ShareMealPlanButton weekStart={weekStart} mealPlanId={plan?.id} initialIsPublic={plan?.isPublic ?? false} />
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> <NewShoppingListButton
defaultWeekStart={weekStart}
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
/>
<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" /> <ShoppingCart className="h-4 w-4" />
{msgs.mealPlan.shoppingLists}
</Link> </Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> } />
<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" /> <Printer className="h-4 w-4" />
{msgs.common.print}
</Link> </Link>
} />
<TooltipContent>{msgs.common.print}</TooltipContent>
</Tooltip>
<ExportMarkdownButton <ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })} markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`} filename={`meal-plan-${weekStart}`}
/> />
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> <Tooltip>
<ChevronLeft className="h-4 w-4" /> <TooltipTrigger render={
</Link> <a href={`/api/v1/meal-plans/${weekStart}/export/ics`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.exportCalendar}>
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> <CalendarDays className="h-4 w-4" />
<ChevronRight className="h-4 w-4" /> </a>
</Link> } />
<TooltipContent>{msgs.mealPlan.exportCalendar}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
</TooltipProvider>
<WeeklyNutritionBar weekStart={weekStart} /> <WeeklyNutritionBar weekStart={weekStart} />
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} /> <MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
{sharedMemberships.length > 0 && ( {sharedMemberships.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function ConversationPage({ params }: Params) {
<div className="border-b p-3 flex items-center gap-3"> <div className="border-b p-3 flex items-center gap-3">
<Link href={`/u/${other.username}`} className="flex items-center gap-3"> <Link href={`/u/${other.username}`} className="flex items-center gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
{other.avatarUrl && <AvatarImage src={other.avatarUrl} />} {other.avatarUrl && <AvatarImage src={other.avatarUrl} alt={other.name} />}
<AvatarFallback className="text-xs">{other.name.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback className="text-xs">{other.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<span className="font-medium text-sm hover:underline">{other.name}</span> <span className="font-medium text-sm hover:underline">{other.name}</span>
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
export default function AppNotFound() {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h1 className="text-2xl font-bold tracking-tight">Page not found</h1>
<p className="text-muted-foreground max-w-md">
The page you&apos;re looking for doesn&apos;t exist or may have been moved.
</p>
<Link href="/recipes" className={buttonVariants({ variant: "default" })}>
Back home
</Link>
</div>
);
}
@@ -0,0 +1,14 @@
import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons";
export default function NotificationsLoading() {
return (
<div className="space-y-6 max-w-2xl">
<PageHeaderSkeleton actions={1} />
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { NotificationsPageContent } from "@/components/notifications/notifications-page-content";
export const metadata: Metadata = {};
export default async function NotificationsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return <NotificationsPageContent />;
}
+36
View File
@@ -0,0 +1,36 @@
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 = {};
export default async function NutritionDiaryPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
</div>
<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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons";
export default function PantryLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<Skeleton className="h-20 w-full rounded-xl" />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
</div>
);
}
+30 -3
View File
@@ -1,12 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, pantryItems, recipes, eq, or, inArray } from "@epicure/db"; import { db, pantryItems, recipes, cookingHistory, eq, and, or, inArray, isNotNull } from "@epicure/db";
import { asc } from "@epicure/db"; import { asc } from "@epicure/db";
import { PantryManager } from "@/components/meal-plan/pantry-manager"; import { PantryManager } from "@/components/meal-plan/pantry-manager";
import { PantryPageHeader } from "@/components/pantry/pantry-page-header"; import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions"; import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match"; import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -15,7 +17,7 @@ export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; if (!session) return null;
const [items, candidateRecipes] = await Promise.all([ const [items, candidateRecipes, cookedDishes] = await Promise.all([
db.query.pantryItems.findMany({ db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id), where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName), orderBy: asc(pantryItems.rawName),
@@ -24,6 +26,13 @@ export default async function PantryPage() {
where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])), where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
with: { ingredients: true, photos: true }, with: { ingredients: true, photos: true },
}), }),
db.query.cookingHistory.findMany({
where: and(eq(cookingHistory.userId, session.user.id), isNotNull(cookingHistory.batchDishId)),
with: {
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
recipe: { columns: { id: true, title: true } },
},
}),
]); ]);
const mappedItems = items.map((i) => ({ const mappedItems = items.map((i) => ({
@@ -47,11 +56,29 @@ export default async function PantryPage() {
}; };
}); });
const latestByDish = new Map<string, (typeof cookedDishes)[number]>();
for (const log of cookedDishes) {
if (!log.batchDish) continue;
const existing = latestByDish.get(log.batchDish.id);
if (!existing || log.cookedAt > existing.cookedAt) latestByDish.set(log.batchDish.id, log);
}
const leftovers = [...latestByDish.values()]
.filter((log) => log.batchDish && isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays))
.map((log) => ({
recipeId: log.recipe.id,
dishName: log.batchDish!.name,
recipeTitle: log.recipe.title,
expiresAt: dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays).toISOString(),
daysLeft: daysUntil(dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays)),
}))
.sort((a, b) => a.daysLeft - b.daysLeft);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PantryPageHeader items={mappedItems} /> <PantryPageHeader items={mappedItems} />
<ExpiringLeftovers leftovers={leftovers} />
<ExpiringSoonSuggestions suggestions={suggestions} /> <ExpiringSoonSuggestions suggestions={suggestions} />
<PantryManager initialItems={mappedItems} /> <PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
</div> </div>
); );
} }
+3 -20
View File
@@ -1,22 +1,5 @@
import type { Metadata } from "next"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { PeopleSearch } from "@/components/social/people-search";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {}; export default function PeoplePage() {
redirect("/explore?tab=people");
export default async function PeoplePage() {
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.people.title}</h1>
<p className="text-muted-foreground text-sm mt-1">{m.people.subtitle}</p>
</div>
<PeopleSearch />
</div>
);
} }
@@ -30,14 +30,18 @@ export default async function CookPage({ params }: Params) {
if (!recipe || recipe.steps.length === 0) notFound(); if (!recipe || recipe.steps.length === 0) notFound();
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
return ( return (
<CookingMode <CookingMode
recipeId={id} recipeId={id}
recipeTitle={recipe.title} recipeTitle={recipe.title}
unitPref={unitPref}
steps={recipe.steps.map((s) => ({ steps={recipe.steps.map((s) => ({
id: s.id, id: s.id,
instruction: s.instruction, instruction: s.instruction,
timerSeconds: s.timerSeconds, timerSeconds: s.timerSeconds,
appliesTo: recipe.isBatchCook ? s.appliesTo : undefined,
}))} }))}
ingredients={recipe.ingredients.map((i) => ({ ingredients={recipe.ingredients.map((i) => ({
rawName: i.rawName, rawName: i.rawName,
+39 -4
View File
@@ -6,6 +6,7 @@ import { db, recipes } from "@epicure/db";
import { and, eq } from "@epicure/db"; import { and, eq } from "@epicure/db";
import { RecipeForm } from "@/components/recipe/recipe-form"; import { RecipeForm } from "@/components/recipe/recipe-form";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { getMessages } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
@@ -22,15 +23,19 @@ export default async function EditRecipePage({ params }: Params) {
ingredients: { orderBy: (t, { asc }) => asc(t.order) }, ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: { orderBy: (t, { asc }) => asc(t.order) }, photos: { orderBy: (t, { asc }) => asc(t.order) },
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
}, },
}); });
if (!recipe) notFound(); if (!recipe) notFound();
const m = getMessages((session.user as { locale?: string }).locale);
const defaultValues = { const defaultValues = {
title: recipe.title, title: recipe.title,
description: recipe.description ?? undefined, description: recipe.description ?? undefined,
baseServings: recipe.baseServings, baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: recipe.visibility, visibility: recipe.visibility,
difficulty: recipe.difficulty, difficulty: recipe.difficulty,
prepMins: recipe.prepMins, prepMins: recipe.prepMins,
@@ -44,22 +49,52 @@ export default async function EditRecipePage({ params }: Params) {
unit: ing.unit ?? "", unit: ing.unit ?? "",
note: ing.note ?? "", note: ing.note ?? "",
})), })),
steps: recipe.steps.map((step) => ({ 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, id: step.id,
instruction: step.instruction, instruction: step.instruction,
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "", timerSeconds: timer.value,
})), timerUnit: timer.unit,
appliesTo: step.appliesTo ?? [],
};
}),
photos: recipe.photos.map((photo) => ({ photos: recipe.photos.map((photo) => ({
key: photo.storageKey, key: photo.storageKey,
isCover: photo.isCover, isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey), 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,
description: dish.description ?? "",
fridgeDays: String(dish.fridgeDays),
freezerFriendly: dish.freezerFriendly,
freezerNote: dish.freezerNote ?? "",
dayOfInstructions: dish.dayOfInstructions,
})), })),
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <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> <p className="text-muted-foreground mt-1">{recipe.title}</p>
</div> </div>
<RecipeForm recipeId={id} defaultValues={defaultValues} /> <RecipeForm recipeId={id} defaultValues={defaultValues} />
@@ -0,0 +1,29 @@
import { Skeleton } from "@/components/ui/skeleton";
import { ListRowSkeleton } from "@/components/shared/skeletons";
export default function RecipeDetailLoading() {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="space-y-4">
<Skeleton className="h-9 w-2/3" />
<div className="flex items-center gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-8 rounded-lg" />
))}
</div>
<Skeleton className="h-4 w-full max-w-lg" />
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-20" />
</div>
</div>
<Skeleton className="aspect-video w-full rounded-xl" />
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</div>
);
}
+129 -21
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; 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 { VariationsButton } from "@/components/recipe/variations-button";
import { TranslateButton } from "@/components/recipe/translate-button"; import { TranslateButton } from "@/components/recipe/translate-button";
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button"; import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button";
@@ -11,13 +12,16 @@ import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button";
import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button"; import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button";
import { PrintButton } from "@/components/recipe/print-button"; import { PrintButton } from "@/components/recipe/print-button";
import { ShareRecipeButton } from "@/components/recipe/share-recipe-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 { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button"; import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel"; import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button"; import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, avg } from "@epicure/db"; import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
import { and, eq, or, count, inArray } from "@epicure/db"; import { and, eq, count, desc } from "@epicure/db";
import { visibleToViewer } from "@/lib/visibility";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
@@ -27,24 +31,36 @@ import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button"; import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars"; import { RatingStars } from "@/components/social/rating-stars";
import { CookedItReview } from "@/components/social/cooked-it-review"; import { CookedItReview } from "@/components/social/cooked-it-review";
import { RecipeNotes } from "@/components/recipe/recipe-notes";
import { CommentsSection } from "@/components/social/comments-section"; import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils"; import { cn, stripMarkdown } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; 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 { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { recipeToMarkdown } from "@/lib/markdown/recipe"; import { recipeToMarkdown } from "@/lib/markdown/recipe";
import { getMessages, formatMessage } from "@/lib/i18n/server"; import { getMessages, formatMessage } from "@/lib/i18n/server";
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> { export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { id } = await params; 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" }; 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; const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
export default async function RecipePage({ params }: Params) { export default async function RecipePage({ params }: Params) {
@@ -55,29 +71,60 @@ export default async function RecipePage({ params }: Params) {
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const VISIBILITY_LABEL = m.recipe.visibility; const VISIBILITY_LABEL = m.recipe.visibility;
const DIETARY_LABELS = m.recipe.dietary; const DIETARY_LABELS = m.recipe.dietary;
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating] = await Promise.all([ const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags, featurePrefs] = await Promise.all([
db.query.recipes.findFirst({ db.query.recipes.findFirst({
where: and( where: and(
eq(recipes.id, id), eq(recipes.id, id),
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])) visibleToViewer(session.user.id)
), ),
with: { with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) }, ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: { orderBy: (t, { asc }) => asc(t.order) }, photos: { orderBy: (t, { asc }) => asc(t.order) },
author: { columns: { id: true, name: true, username: true, avatarUrl: true } }, author: { columns: { id: true, name: true, username: true, avatarUrl: true } },
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
}, },
}), }),
db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)), db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)),
db.query.favorites.findFirst({ where: and(eq(favorites.userId, session.user.id), eq(favorites.recipeId, id)) }), db.query.favorites.findFirst({ where: and(eq(favorites.userId, session.user.id), eq(favorites.recipeId, id)) }),
db.query.ratings.findFirst({ where: and(eq(ratings.recipeId, id), eq(ratings.userId, session.user.id)) }), db.query.ratings.findFirst({ where: and(eq(ratings.recipeId, id), eq(ratings.userId, session.user.id)) }),
db.query.recipeVariations.findFirst({
where: eq(recipeVariations.childRecipeId, id),
with: { parent: { columns: { id: true, title: true } } },
}),
db.query.recipeNotes.findFirst({
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
columns: { content: true },
}),
db.query.cookingHistory.findMany({
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)),
orderBy: desc(cookingHistory.cookedAt),
columns: { batchDishId: true, cookedAt: true },
}),
getFeatureFlagMatrix(),
getFeaturePrefs(session.user.id),
]); ]);
if (!recipe) notFound(); 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 isOwner = recipe.authorId === session.user.id;
const dishCookedAtMap = new Map<string, string>();
for (const log of dishCookLog) {
if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) {
dishCookedAtMap.set(log.batchDishId, log.cookedAt.toISOString());
}
}
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null; const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
const ratingCount = ratingData[0]?.total ?? 0; const ratingCount = ratingData[0]?.total ?? 0;
const isFavorited = !!favoriteData; const isFavorited = !!favoriteData;
@@ -93,11 +140,19 @@ export default async function RecipePage({ params }: Params) {
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return ( 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 /> <KeepScreenAwake />
{/* Header */} {/* Header */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1> <h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
{recipe.isBatchCook && (
<Badge variant="secondary" className="shrink-0">{m.recipe.batchCookBadge}</Badge>
)}
</div>
{!isOwner && recipe.author.username && ( {!isOwner && recipe.author.username && (
<Link <Link
href={`/u/${recipe.author.username}`} href={`/u/${recipe.author.username}`}
@@ -110,6 +165,14 @@ export default async function RecipePage({ params }: Params) {
{formatMessage(m.recipe.byAuthor, { name: recipe.author.name })} {formatMessage(m.recipe.byAuthor, { name: recipe.author.name })}
</Link> </Link>
)} )}
{forkedFrom && (
<Link
href={`/recipes/${forkedFrom.parent.id}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
{formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })}
</Link>
)}
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"> <div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && ( {recipe.steps.length > 0 && (
@@ -123,8 +186,12 @@ export default async function RecipePage({ params }: Params) {
</Tooltip> </Tooltip>
)} )}
<FavoriteButton recipeId={id} initialFavorited={isFavorited} /> <FavoriteButton recipeId={id} initialFavorited={isFavorited} />
<MealPairingButton recipeId={id} /> {!recipe.isBatchCook && recipe.recipeType !== "drink" && (
<DrinkPairingButton recipeId={id} /> <>
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
</>
)}
{recipe.visibility === "public" && ( {recipe.visibility === "public" && (
<Tooltip> <Tooltip>
<TooltipTrigger render={ <TooltipTrigger render={
@@ -174,8 +241,11 @@ export default async function RecipePage({ params }: Params) {
timerSeconds: s.timerSeconds, timerSeconds: s.timerSeconds,
order: s.order, order: s.order,
}))} }))}
locked={locked.variations}
/> />
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} /> <ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<SaveOfflineButton recipeId={id} recipeTitle={recipe.title} />
<PrintButton recipeId={id} /> <PrintButton recipeId={id} />
<ExportMarkdownButton <ExportMarkdownButton
markdown={recipeToMarkdown({ markdown={recipeToMarkdown({
@@ -188,6 +258,8 @@ export default async function RecipePage({ params }: Params) {
sourceUrl: recipe.sourceUrl, sourceUrl: recipe.sourceUrl,
ingredients: recipe.ingredients, ingredients: recipe.ingredients,
steps: recipe.steps, steps: recipe.steps,
isBatchCook: recipe.isBatchCook,
batchDishes: recipe.batchDishes,
})} })}
filename={recipe.title} filename={recipe.title}
/> />
@@ -232,7 +304,9 @@ export default async function RecipePage({ params }: Params) {
)} )}
{recipe.description && ( {recipe.description && (
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p> <p className="text-muted-foreground leading-relaxed">
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
</p>
)} )}
{recipe.sourceUrl && ( {recipe.sourceUrl && (
@@ -283,15 +357,25 @@ export default async function RecipePage({ params }: Params) {
))} ))}
</div> </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> </div>
{/* Cover photo */} {/* Cover photo */}
{cover && ( {cover && (
<div className="aspect-video overflow-hidden rounded-xl bg-muted"> <div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
<img <Image
src={getPublicUrl(cover.storageKey)} src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title} alt={recipe.title}
className="w-full h-full object-cover" fill
className="object-cover"
/> />
</div> </div>
)} )}
@@ -325,6 +409,7 @@ export default async function RecipePage({ params }: Params) {
<ServingScaler <ServingScaler
baseServings={recipe.baseServings} baseServings={recipe.baseServings}
recipeTitle={recipe.title} recipeTitle={recipe.title}
unitPref={unitPref}
ingredients={recipe.ingredients.map((ing) => ({ ingredients={recipe.ingredients.map((ing) => ({
id: ing.id, id: ing.id,
rawName: ing.rawName, rawName: ing.rawName,
@@ -334,7 +419,7 @@ export default async function RecipePage({ params }: Params) {
order: ing.order, order: ing.order,
}))} }))}
/> />
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} /> <NutritionPanel recipeId={id} initialData={recipe.nutritionData} initialManual={recipe.nutritionManual} />
</div> </div>
)} )}
@@ -343,6 +428,9 @@ export default async function RecipePage({ params }: Params) {
<Separator /> <Separator />
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-semibold">{m.recipe.instructions}</h2> <h2 className="text-xl font-semibold">{m.recipe.instructions}</h2>
{recipe.isBatchCook ? (
<BatchCookSteps steps={recipe.steps} />
) : (
<ol className="space-y-6"> <ol className="space-y-6">
{recipe.steps.map((step, i) => ( {recipe.steps.map((step, i) => (
<li key={step.id} className="flex gap-4"> <li key={step.id} className="flex gap-4">
@@ -351,7 +439,7 @@ export default async function RecipePage({ params }: Params) {
</div> </div>
<div className="flex-1 space-y-1 pt-1"> <div className="flex-1 space-y-1 pt-1">
<p className="leading-relaxed">{step.instruction}</p> <p className="leading-relaxed">{step.instruction}</p>
{step.timerSeconds && ( {!!step.timerSeconds && (
<p className="text-xs text-muted-foreground flex items-center gap-1"> <p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{step.timerSeconds >= 60 {step.timerSeconds >= 60
@@ -363,19 +451,36 @@ export default async function RecipePage({ params }: Params) {
</li> </li>
))} ))}
</ol> </ol>
)}
</div> </div>
</> </>
)} )}
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
<>
<Separator />
<BatchCookDishes
recipeId={id}
dishes={recipe.batchDishes.map((d) => ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))}
/>
</>
)}
{recipe.photos.length > 1 && ( {recipe.photos.length > 1 && (
<> <>
<Separator /> <Separator />
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-xl font-semibold">Photos</h2> <h2 className="text-xl font-semibold">Photos</h2>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{recipe.photos.map((photo) => ( {recipe.photos.map((photo, i) => (
<div key={photo.id} className="aspect-square rounded-lg overflow-hidden bg-muted"> <div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden bg-muted">
<img src={getPublicUrl(photo.storageKey)} alt="" className="w-full h-full object-cover" /> <Image
src={getPublicUrl(photo.storageKey)}
unoptimized
alt={`${recipe.title} photo ${i + 1}`}
fill
className="object-cover"
/>
</div> </div>
))} ))}
</div> </div>
@@ -403,7 +508,10 @@ export default async function RecipePage({ params }: Params) {
</> </>
)} )}
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} /> <Separator />
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
{featurePrefs.chatbots && <RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />}
</div> </div>
); );
} }
@@ -0,0 +1,121 @@
import type { Metadata } from "next";
import Link from "next/link";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, favorites, recipes, recipePhotos, users, eq, and, or, ne, desc, inArray, count } from "@epicure/db";
import { FavoritesGrid } from "@/components/recipe/favorites-grid";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {};
const PAGE_SIZE = 24;
export default async function FavoriteRecipesPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
// A previously-favorited recipe stays visible to the person who favorited it even if
// the author later makes their account/recipe private — same "existing access isn't
// revoked, only new discovery is" rule as the private-accounts feature.
const where = and(
eq(favorites.userId, session.user.id),
or(ne(recipes.visibility, "private"), eq(recipes.authorId, session.user.id))
);
const [rows, totalRow] = await Promise.all([
db
.select({
id: recipes.id,
title: recipes.title,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
authorId: recipes.authorId,
authorName: users.name,
})
.from(favorites)
.innerJoin(recipes, eq(favorites.recipeId, recipes.id))
.innerJoin(users, eq(recipes.authorId, users.id))
.where(where)
.orderBy(desc(favorites.createdAt))
.limit(PAGE_SIZE)
.offset(offset),
db
.select({ total: count() })
.from(favorites)
.innerJoin(recipes, eq(favorites.recipeId, recipes.id))
.where(where),
]);
const recipeIds = rows.map((r) => r.id);
const photos = recipeIds.length > 0
? await db
.select({ recipeId: recipePhotos.recipeId, storageKey: recipePhotos.storageKey, isCover: recipePhotos.isCover })
.from(recipePhotos)
.where(inArray(recipePhotos.recipeId, recipeIds))
: [];
const coverByRecipe = new Map<string, string>();
for (const p of photos) {
if (!coverByRecipe.has(p.recipeId) || p.isCover) coverByRecipe.set(p.recipeId, p.storageKey);
}
const total = totalRow[0]?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const favoriteRecipes = rows.map((r) => ({
id: r.id,
title: r.title,
difficulty: r.difficulty,
prepMins: r.prepMins,
cookMins: r.cookMins,
authorName: r.authorId === session.user.id ? null : r.authorName,
coverPhotoKey: coverByRecipe.get(r.id) ?? null,
}));
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm">
<Link href="/recipes" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
{m.recipes.tabMine}
</Link>
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabFavorites}</span>
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{m.recipes.tabFavorites}</h1>
<p className="text-muted-foreground mt-1">
{formatMessage(total === 1 ? m.recipes.favoritesCount : m.recipes.favoritesCountPlural, { count: total })}
</p>
</div>
<FavoritesGrid initialRecipes={favoriteRecipes} emptyTitle={m.recipes.favoritesEmptyTitle} emptyDescription={m.recipes.favoritesEmpty} />
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{page > 1 && (
<Link href={`/recipes/favorites?page=${page - 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link href={`/recipes/favorites?page=${page + 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Next
</Link>
)}
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons";
export default function RecipesLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={2} subtitle />
<RecipeCardGridSkeleton />
</div>
);
}
+127 -15
View File
@@ -1,22 +1,48 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, recipes, sql } from "@epicure/db"; import { db, recipes, favorites, sql, count } from "@epicure/db";
import { eq, desc, asc, and, ilike, or } from "@epicure/db"; import { eq, desc, asc, and, ilike, or, inArray } from "@epicure/db";
import { RecipesHeader } from "@/components/recipe/recipes-header"; import { RecipesHeader } from "@/components/recipe/recipes-header";
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state"; import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
import { RecipesGrid } from "@/components/recipe/recipes-grid"; 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 = {}; export const metadata: Metadata = {};
const PAGE_SIZE = 24;
type SearchParams = Promise<{ type SearchParams = Promise<{
q?: string; q?: string;
sort?: string; sort?: string;
visibility?: string; visibility?: string;
difficulty?: string; difficulty?: string;
tag?: string; 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 = { const SORT_MAP = {
updated_desc: desc(recipes.updatedAt), updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt), updated_asc: asc(recipes.updatedAt),
@@ -31,45 +57,131 @@ type SortKey = keyof typeof SORT_MAP;
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) { export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; 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 } = 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 query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey; const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined; 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) const visibilityFilter = visibility && ["private", "unlisted", "public", "followers"].includes(visibility)
? (visibility as "private" | "unlisted" | "public") ? (visibility as "private" | "unlisted" | "public" | "followers")
: undefined; : undefined;
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty) const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
? (difficulty as "easy" | "medium" | "hard") ? (difficulty as "easy" | "medium" | "hard")
: undefined; : undefined;
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
const recipeTypeFilter = recipeType === "dish" || recipeType === "drink" ? recipeType : undefined;
const userRecipes = await db.query.recipes.findMany({ // Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
where: and( const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
const where = and(
eq(recipes.authorId, session.user.id), eq(recipes.authorId, session.user.id),
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
query 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, : undefined,
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined, visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined, difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined, tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
), recipeTypeFilter ? eq(recipes.recipeType, recipeTypeFilter) : undefined,
);
const [userRecipes, totalRow] = await Promise.all([
db.query.recipes.findMany({
where,
orderBy: SORT_MAP[sortKey], orderBy: SORT_MAP[sortKey],
with: { photos: true }, with: { photos: true, batchDishes: { columns: { id: true } } },
}); limit: PAGE_SIZE,
offset,
}),
db.select({ count: count() }).from(recipes).where(where),
]);
const total = totalRow[0]?.count ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const recipeIds = userRecipes.map((r) => r.id);
const favoritedRows = recipeIds.length > 0
? await db
.select({ recipeId: favorites.recipeId })
.from(favorites)
.where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds)))
: [];
const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId));
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id), dishCount: r.batchDishes.length }));
const pageHref = (p: number) => {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (sortKey !== "updated_desc") params.set("sort", sortKey);
if (visibilityFilter) params.set("visibility", visibilityFilter);
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 ( 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">
{m.recipes.tabFavorites}
</Link>
</div>
<RecipesHeader <RecipesHeader
count={userRecipes.length} count={total}
initialQuery={query} initialQuery={query}
initialSort={sortKey} initialSort={sortKey}
initialVisibility={visibilityFilter ?? ""} initialVisibility={visibilityFilter ?? ""}
initialDifficulty={difficultyFilter ?? ""} initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""} initialTag={tagFilter ?? ""}
initialBatchCook={batchCookFilter ?? ""}
initialRecipeType={recipeTypeFilter ?? ""}
sharedUrl={sharedUrl}
/> />
<RecipesEmptyState query={query} count={userRecipes.length} /> <RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} /> <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">
{page > 1 && (
<Link href={pageHref(page - 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link href={pageHref(page + 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Next
</Link>
)}
</div>
)}
{featurePrefs.chatbots && <CookingAssistantPanel />}
</div> </div>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import { Skeleton } from "@/components/ui/skeleton";
import { InfoCardSkeleton } from "@/components/shared/skeletons";
export default function SearchLoading() {
return (
<div className="max-w-5xl mx-auto space-y-6">
<div>
<Skeleton className="h-9 w-56 mb-6" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="mt-3 flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-9 w-36" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<InfoCardSkeleton key={i} />
))}
</div>
</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"; import { SearchPageContent } from "@/components/search/search-page-content";
type Params = { searchParams: Promise<{ q?: string; difficulty?: string; dietary?: string }> }; type Params = { searchParams: Promise<{ q?: string; difficulty?: string; dietary?: string }> };
export default async function SearchPage({ searchParams }: Params) { export default async function SearchPage({ searchParams }: Params) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams; const { q } = await searchParams;
return <SearchPageContent initialQuery={q ?? ""} />; return <SearchPageContent initialQuery={q ?? ""} />;
} }
+56 -3
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; 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 { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form"; 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 = {}; export const metadata: Metadata = {};
@@ -13,7 +16,9 @@ export default async function AiSettingsPage() {
if (!session) return null; if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale); 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({ db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, session.user.id), where: eq(userAiKeys.userId, session.user.id),
columns: { provider: true }, columns: { provider: true },
@@ -21,10 +26,54 @@ export default async function AiSettingsPage() {
db.query.userModelPrefs.findFirst({ db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session.user.id), 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, isByokEnabled: 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 ( return (
<div className="space-y-8"> <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"> <section className="rounded-xl border p-6 space-y-4">
<div> <div>
<h2 className="font-semibold text-lg">{m.settings.byok.title}</h2> <h2 className="font-semibold text-lg">{m.settings.byok.title}</h2>
@@ -32,7 +81,11 @@ export default async function AiSettingsPage() {
{m.settings.byok.description} {m.settings.byok.description}
</p> </p>
</div> </div>
{dbUser?.isByokEnabled ? (
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} /> <ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
) : (
<DeveloperLockedNotice message={m.settings.byokLockedNotice} />
)}
</section> </section>
<section className="rounded-xl border p-6 space-y-4"> <section className="rounded-xl border p-6 space-y-4">
+24 -3
View File
@@ -3,8 +3,12 @@ import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server"; 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 { ApiKeysManager } from "@/components/settings/api-keys-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { DeveloperAccessToggle } from "@/components/settings/developer-access-toggle";
import { DisableDeveloperAccessButton } from "@/components/settings/disable-developer-access-button";
import { canSelfServeDeveloperAccess } from "@/lib/permissions";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -14,15 +18,20 @@ export default async function ApiKeysPage() {
if (!session) return null; if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const keys = await db const dbUser = (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const keys = dbUser?.isDeveloper
? await db
.select({ .select({
id: apiKeys.id, id: apiKeys.id,
name: apiKeys.name, name: apiKeys.name,
scope: apiKeys.scope,
lastUsedAt: apiKeys.lastUsedAt, lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt, createdAt: apiKeys.createdAt,
}) })
.from(apiKeys) .from(apiKeys)
.where(eq(apiKeys.userId, session.user.id)); .where(eq(apiKeys.userId, session.user.id))
: [];
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -41,14 +50,26 @@ export default async function ApiKeysPage() {
{m.settings.apiKeysPage.docsLink} {m.settings.apiKeysPage.docsLink}
</Link> </Link>
</div> </div>
{!dbUser?.isDeveloper && dbUser && canSelfServeDeveloperAccess(dbUser) && (
<DeveloperAccessToggle message={m.settings.developerSelfServeNotice} />
)}
{!dbUser?.isDeveloper && (!dbUser || !canSelfServeDeveloperAccess(dbUser)) && (
<DeveloperLockedNotice message={m.settings.developerLockedNotice} />
)}
{dbUser?.isDeveloper && (
<>
<ApiKeysManager <ApiKeysManager
initialKeys={keys.map((k) => ({ initialKeys={keys.map((k) => ({
id: k.id, id: k.id,
name: k.name, name: k.name,
scope: k.scope,
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null, lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
createdAt: k.createdAt.toISOString(), createdAt: k.createdAt.toISOString(),
}))} }))}
/> />
<DisableDeveloperAccessButton />
</>
)}
</section> </section>
</div> </div>
); );
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, tierDefinitions, tierEnum, userUsage, eq, and } from "@epicure/db";
import { getRecipeCount, getStorageUsedMb, UNLIMITED } from "@/lib/tiers";
import { BillingPlanCards } from "@/components/settings/billing-plan-cards";
import { ManageBillingButton } from "@/components/settings/manage-billing-button";
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = {};
const TIER_NAMES: Record<string, string> = { free: "Free", pro: "Pro", family: "Family" };
function describeLimit(n: number, unit: string): string {
return n === UNLIMITED ? `Unlimited ${unit}` : `${n} ${unit}`;
}
export default async function BillingPage({ searchParams }: { searchParams: Promise<{ checkout?: string }> }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { checkout } = await searchParams;
const currentMonth = new Date().toISOString().slice(0, 7);
const [dbUser, allTierDefs, usage, recipeCount, storageUsedMb] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { tier: true, subscriptionStatus: true, currentPeriodEnd: true, stripeCustomerId: true },
}),
db.select().from(tierDefinitions),
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 currentTier = dbUser?.tier ?? "free";
const currentTierDef = allTierDefs.find((t) => t.tier === currentTier);
const asLimit = (n: number | undefined) => (n === undefined || n === UNLIMITED ? null : n);
const usageMetrics = [
{ label: "AI calls", used: usage?.aiCallsUsed ?? 0, limit: asLimit(currentTierDef?.aiCallsPerMonth), unlimitedLabel: "Unlimited" },
{ label: "Recipes created", used: recipeCount, limit: asLimit(currentTierDef?.maxRecipes), unlimitedLabel: "Unlimited" },
{ label: "Storage", used: storageUsedMb, limit: asLimit(currentTierDef?.storageMb), unit: " MB", unlimitedLabel: "Unlimited" },
];
const plans = tierEnum.enumValues.map((tier) => {
const def = allTierDefs.find((t) => t.tier === tier);
return {
tier,
name: TIER_NAMES[tier] ?? tier,
monthlyPrice: tier === "free" ? "€0" : "Contact for pricing",
yearlyPrice: null,
features: def
? [
describeLimit(def.maxRecipes, "recipes"),
describeLimit(def.aiCallsPerMonth, "AI calls/month"),
describeLimit(def.storageMb, "MB storage"),
]
: [],
purchasable: tier === "free" || !!(def?.stripePriceIdMonthly),
};
});
return (
<div className="space-y-8">
{checkout === "success" && (
<div className="rounded-lg border border-primary/40 bg-primary/5 p-4 text-sm">
Payment received — your plan updates within a few seconds as Stripe confirms it. Refresh if it doesn&apos;t show up right away.
</div>
)}
{checkout === "canceled" && (
<div className="rounded-lg border p-4 text-sm text-muted-foreground">
Checkout canceled — no changes made.
</div>
)}
<section className="rounded-xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Current plan: {TIER_NAMES[currentTier] ?? currentTier}</h2>
{dbUser?.currentPeriodEnd && (
<p className="text-sm text-muted-foreground mt-1">
Renews {dbUser.currentPeriodEnd.toLocaleDateString()}
</p>
)}
</div>
{dbUser?.subscriptionStatus === "past_due" && (
<Badge variant="destructive">Payment failed — update your card</Badge>
)}
</div>
{dbUser?.stripeCustomerId && <ManageBillingButton />}
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Usage this month</h2>
<p className="text-sm text-muted-foreground mt-1">AI calls reset monthly. Recipes and storage are lifetime totals.</p>
</div>
<UsageQuotaSection metrics={usageMetrics} />
</section>
<section className="space-y-4">
<h2 className="font-semibold text-lg">Plans</h2>
<BillingPlanCards currentTier={currentTier} plans={plans} />
</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>
);
}
+8 -1
View File
@@ -1,12 +1,19 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { SettingsSidebar } from "@/components/settings/settings-sidebar"; import { SettingsSidebar } from "@/components/settings/settings-sidebar";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
import { canSelfServeDeveloperAccess } from "@/lib/permissions";
export default async function SettingsLayout({ children }: { children: React.ReactNode }) { export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string })?.locale); const m = getMessages((session?.user as { locale?: string })?.locale);
const dbUser = session
? (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
: undefined;
const showDeveloperNav = !!dbUser && (dbUser.isDeveloper || canSelfServeDeveloperAccess(dbUser));
return ( return (
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="mb-8"> <div className="mb-8">
@@ -14,7 +21,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
<p className="text-muted-foreground mt-1">{m.settings.subtitle}</p> <p className="text-muted-foreground mt-1">{m.settings.subtitle}</p>
</div> </div>
<div className="flex flex-col md:flex-row gap-4 md:gap-8 md:items-start"> <div className="flex flex-col md:flex-row gap-4 md:gap-8 md:items-start">
<SettingsSidebar /> <SettingsSidebar showDeveloperNav={showDeveloperNav} />
<main className="flex-1 min-w-0 space-y-6">{children}</main> <main className="flex-1 min-w-0 space-y-6">{children}</main>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button"; import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
import { NotificationCategoriesForm } from "@/components/settings/notification-categories-form";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -21,6 +22,16 @@ export default async function NotificationsPage() {
</div> </div>
<PushSubscribeButton /> <PushSubscribeButton />
</section> </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> </div>
); );
} }
@@ -1,9 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, userNutritionGoals, eq } from "@epicure/db"; import { db, userNutritionGoals, eq } from "@epicure/db";
import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form"; import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -38,6 +41,15 @@ export default async function NutritionPage() {
} }
/> />
</section> </section>
<section className="rounded-xl border p-6 space-y-3">
<div>
<h2 className="font-semibold text-lg">{m.nutritionDiary.title}</h2>
<p className="text-sm text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
</div>
<Link href="/nutrition" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{m.settings.nutritionGoals.viewDiaryCta}
</Link>
</section>
</div> </div>
); );
} }
+6 -2
View File
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
const dbUser = await db.query.users.findFirst({ const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id), where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true }, columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true, useGravatar: true },
}); });
return ( return (
@@ -20,10 +20,14 @@ export default async function SettingsPage() {
user={{ user={{
name: session.user.name, name: session.user.name,
email: session.user.email, email: session.user.email,
image: session.user.image ?? null, image: dbUser?.avatarUrl ?? null,
locale: (session.user as { locale?: string }).locale ?? "en", locale: (session.user as { locale?: string }).locale ?? "en",
bio: dbUser?.bio ?? null, bio: dbUser?.bio ?? null,
privateBio: dbUser?.privateBio ?? null, 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 type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, accounts, users, eq, and } from "@epicure/db";
import { SecurityForm } from "@/components/settings/security-form"; import { SecurityForm } from "@/components/settings/security-form";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -9,5 +10,16 @@ export default async function SecurityPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; 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 type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export default function WebhookDocsPage() { export default function WebhookDocsPage() {
return ( return (
<div className="max-w-3xl mx-auto space-y-10 py-8"> <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> <div>
<h1 className="text-2xl font-bold mb-2">Webhook Documentation</h1> <h1 className="text-2xl font-bold mb-2">Webhook Documentation</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
+22 -3
View File
@@ -1,8 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; 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 { WebhooksManager } from "@/components/settings/webhooks-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { DeveloperAccessToggle } from "@/components/settings/developer-access-toggle";
import { DisableDeveloperAccessButton } from "@/components/settings/disable-developer-access-button";
import { canSelfServeDeveloperAccess } from "@/lib/permissions";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -12,7 +16,10 @@ export default async function WebhooksPage() {
if (!session) return null; if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const rows = await db const dbUser = (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const rows = dbUser?.isDeveloper
? await db
.select({ .select({
id: webhooks.id, id: webhooks.id,
url: webhooks.url, url: webhooks.url,
@@ -21,7 +28,8 @@ export default async function WebhooksPage() {
createdAt: webhooks.createdAt, createdAt: webhooks.createdAt,
}) })
.from(webhooks) .from(webhooks)
.where(eq(webhooks.userId, session.user.id)); .where(eq(webhooks.userId, session.user.id))
: [];
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -32,6 +40,14 @@ export default async function WebhooksPage() {
{m.settings.webhooksPage.description} {m.settings.webhooksPage.description}
</p> </p>
</div> </div>
{!dbUser?.isDeveloper && dbUser && canSelfServeDeveloperAccess(dbUser) && (
<DeveloperAccessToggle message={m.settings.developerSelfServeNotice} />
)}
{!dbUser?.isDeveloper && (!dbUser || !canSelfServeDeveloperAccess(dbUser)) && (
<DeveloperLockedNotice message={m.settings.developerLockedNotice} />
)}
{dbUser?.isDeveloper && (
<>
<WebhooksManager <WebhooksManager
initialWebhooks={rows.map((w) => ({ initialWebhooks={rows.map((w) => ({
id: w.id, id: w.id,
@@ -41,6 +57,9 @@ export default async function WebhooksPage() {
createdAt: w.createdAt.toISOString(), createdAt: w.createdAt.toISOString(),
}))} }))}
/> />
<DisableDeveloperAccessButton />
</>
)}
</section> </section>
</div> </div>
); );
@@ -8,7 +8,9 @@ import { db, shoppingLists, eq } from "@epicure/db";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view"; import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button"; import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button";
import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button"; 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 { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
@@ -30,7 +32,7 @@ export default async function ShoppingListPage({ params }: Params) {
const list = await db.query.shoppingLists.findFirst({ const list = await db.query.shoppingLists.findFirst({
where: eq(shoppingLists.id, id), where: eq(shoppingLists.id, id),
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } }, with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.sortOrder), asc(t.rawName)] } },
}); });
if (!list) notFound(); if (!list) notFound();
@@ -38,7 +40,7 @@ export default async function ShoppingListPage({ params }: Params) {
const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart"; const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart";
return ( return (
<div className="space-y-6 max-w-lg"> <div className="space-y-6 max-w-2xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1> <h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
@@ -47,18 +49,29 @@ export default async function ShoppingListPage({ params }: Params) {
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""} {list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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} /> <GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
{access.role === "owner" && <ShareShoppingListButton listId={id} />} {access.role === "owner" && (
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> <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" /> <Printer className="h-4 w-4" />
{m.common.print}
</Link> </Link>
} />
<TooltipContent>{m.common.print}</TooltipContent>
</Tooltip>
<ExportMarkdownButton <ExportMarkdownButton
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })} markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
filename={list.name} filename={list.name}
/> />
{access.role === "owner" && (
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
)}
</div> </div>
</TooltipProvider>
</div> </div>
<ShoppingListView <ShoppingListView
listId={id} listId={id}
@@ -70,6 +83,7 @@ export default async function ShoppingListPage({ params }: Params) {
unit: i.unit, unit: i.unit,
aisle: i.aisle, aisle: i.aisle,
checked: i.checked, checked: i.checked,
sortOrder: i.sortOrder,
}))} }))}
/> />
</div> </div>
@@ -0,0 +1,14 @@
import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons";
export default function ShoppingListsLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
</div>
);
}
+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>
);
}
@@ -0,0 +1,32 @@
import { Skeleton } from "@/components/ui/skeleton";
import { SquareTileSkeleton } from "@/components/shared/skeletons";
export default function UserProfileLoading() {
return (
<div className="max-w-4xl mx-auto space-y-10">
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
<Skeleton className="h-24 w-24 shrink-0 rounded-full" />
<div className="flex-1 space-y-3">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-64" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-6 w-24" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<SquareTileSkeleton key={i} />
))}
</div>
</div>
</div>
);
}
+219 -41
View File
@@ -1,6 +1,8 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { Lock } from "lucide-react";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { import {
db, db,
@@ -8,10 +10,13 @@ import {
recipes, recipes,
userFollows, userFollows,
userBlocks, userBlocks,
cookingHistory,
ratings,
eq, eq,
and, and,
desc, desc,
count, count,
isNotNull,
} from "@epicure/db"; } from "@epicure/db";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -19,8 +24,32 @@ import { FollowButton } from "@/components/social/follow-button";
import { BlockButton } from "@/components/social/block-button"; import { BlockButton } from "@/components/social/block-button";
import { MessageButton } from "@/components/social/message-button"; import { MessageButton } from "@/components/social/message-button";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
type Params = { params: Promise<{ username: string }> }; const PAGE_SIZE = 24;
type Params = {
params: Promise<{ username: string }>;
searchParams: Promise<{ page?: string; tab?: string; ppage?: string }>;
};
/** Consecutive-day streak (UTC calendar days) ending today or yesterday. */
function computeStreak(dates: Date[]): number {
const daySet = new Set(dates.map((d) => d.toISOString().slice(0, 10)));
const cursor = new Date();
cursor.setUTCHours(0, 0, 0, 0);
if (!daySet.has(cursor.toISOString().slice(0, 10))) {
cursor.setUTCDate(cursor.getUTCDate() - 1);
if (!daySet.has(cursor.toISOString().slice(0, 10))) return 0;
}
let streak = 0;
while (daySet.has(cursor.toISOString().slice(0, 10))) {
streak++;
cursor.setUTCDate(cursor.getUTCDate() - 1);
}
return streak;
}
export async function generateMetadata({ params }: Params) { export async function generateMetadata({ params }: Params) {
const { username } = await params; const { username } = await params;
@@ -28,8 +57,14 @@ export async function generateMetadata({ params }: Params) {
return { title: user ? `${user.name} (@${user.username})` : "Profile" }; return { title: user ? `${user.name} (@${user.username})` : "Profile" };
} }
export default async function UserProfilePage({ params }: Params) { export default async function UserProfilePage({ params, searchParams }: Params) {
const { username } = await params; const { username } = await params;
const { page: pageParam, tab: tabParam, ppage: ppageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const photoPage = Math.max(1, parseInt(ppageParam ?? "1", 10) || 1);
const photoOffset = (photoPage - 1) * PAGE_SIZE;
const activeTab = tabParam === "history" || tabParam === "photos" ? tabParam : "recipes";
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -52,7 +87,8 @@ export default async function UserProfilePage({ params }: Params) {
db.query.recipes.findMany({ db.query.recipes.findMany({
where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")), where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")),
orderBy: desc(recipes.createdAt), orderBy: desc(recipes.createdAt),
limit: 24, limit: PAGE_SIZE,
offset,
with: { with: {
photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 }, photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 },
}, },
@@ -62,6 +98,7 @@ export default async function UserProfilePage({ params }: Params) {
const followerCount = followerCountRow[0]?.count ?? 0; const followerCount = followerCountRow[0]?.count ?? 0;
const followingCount = followingCountRow[0]?.count ?? 0; const followingCount = followingCountRow[0]?.count ?? 0;
const recipeCount = recipeCountRow[0]?.count ?? 0; const recipeCount = recipeCountRow[0]?.count ?? 0;
const totalPages = Math.max(1, Math.ceil(recipeCount / PAGE_SIZE));
let isFollowing = false; let isFollowing = false;
let isBlocked = false; let isBlocked = false;
@@ -85,6 +122,95 @@ export default async function UserProfilePage({ params }: Params) {
isBlocked = !!blockRow; isBlocked = !!blockRow;
} }
// Private accounts hide their recipe grid from anyone who isn't the owner or an
// existing follower. This only affects what's rendered on this direct-link profile
// page — it does not change access to a specific recipe's own direct URL, and it
// does not gate new follow requests (following stays immediate, no approval step).
const isPrivateHidden = user.isPrivate && !isOwnProfile && !isFollowing;
// Cooking history and "cooked it" photo gallery are only meaningful (and visible) to the
// profile owner: history reveals private behavioral/timing patterns, and photos may be
// attached to reviews of recipes that aren't visible to other viewers (private recipes),
// so surfacing them to non-owners could leak the existence of content they can't access.
let historyData: HistoryData | null = null;
let photosData: PhotosData | null = null;
if (isOwnProfile) {
const [totalCookedRow, cookedDatesRows, lastCookedRow, recentEntries, totalPhotosRow, photoRows] =
await Promise.all([
db.select({ count: count() }).from(cookingHistory).where(eq(cookingHistory.userId, user.id)),
db.query.cookingHistory.findMany({
where: eq(cookingHistory.userId, user.id),
columns: { cookedAt: true },
orderBy: desc(cookingHistory.cookedAt),
limit: 3650,
}),
db.query.cookingHistory.findFirst({
where: eq(cookingHistory.userId, user.id),
orderBy: desc(cookingHistory.cookedAt),
with: { recipe: { columns: { id: true, title: true } } },
}),
db.query.cookingHistory.findMany({
where: eq(cookingHistory.userId, user.id),
orderBy: desc(cookingHistory.cookedAt),
limit: 20,
with: { recipe: { columns: { id: true, title: true } } },
}),
db
.select({ count: count() })
.from(ratings)
.where(and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey))),
db.query.ratings.findMany({
where: and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey)),
orderBy: desc(ratings.createdAt),
limit: PAGE_SIZE,
offset: photoOffset,
with: { recipe: { columns: { id: true, title: true } } },
}),
]);
const totalCooked = totalCookedRow[0]?.count ?? 0;
const streak = computeStreak(cookedDatesRows.map((r) => r.cookedAt));
const totalPhotos = totalPhotosRow[0]?.count ?? 0;
historyData = {
totalCooked,
streak,
lastCooked:
lastCookedRow && lastCookedRow.recipe
? {
recipeId: lastCookedRow.recipe.id,
recipeTitle: lastCookedRow.recipe.title,
cookedAt: lastCookedRow.cookedAt.toISOString(),
}
: null,
recentEntries: recentEntries
.filter((e) => e.recipe)
.map((e) => ({
id: e.id,
recipeId: e.recipe!.id,
recipeTitle: e.recipe!.title,
cookedAt: e.cookedAt.toISOString(),
servings: e.servings,
})),
};
photosData = {
items: photoRows
.filter((r) => r.recipe && r.photoKey)
.map((r) => ({
id: r.id,
recipeId: r.recipe!.id,
recipeTitle: r.recipe!.title,
photoKey: r.photoKey!,
createdAt: r.createdAt.toISOString(),
})),
page: photoPage,
totalPages: Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)),
total: totalPhotos,
};
}
const initials = user.name const initials = user.name
.split(" ") .split(" ")
.slice(0, 2) .slice(0, 2)
@@ -92,6 +218,78 @@ export default async function UserProfilePage({ params }: Params) {
.join("") .join("")
.toUpperCase(); .toUpperCase();
const recipesSection = isPrivateHidden ? (
<div className="text-center py-16 text-muted-foreground space-y-2">
<Lock className="mx-auto h-8 w-8" />
<p className="text-lg font-medium">This account is private</p>
<p className="text-sm">Follow @{user.username} to see their recipes.</p>
</div>
) : (
publicRecipes.length > 0 ? (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Recipes</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{publicRecipes.map((recipe) => {
const cover = recipe.photos[0];
return (
<Link
key={recipe.id}
href={`/recipes/${recipe.id}`}
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
>
<div className="relative aspect-square bg-muted overflow-hidden">
{cover ? (
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<RecipeCoverPlaceholder recipe={recipe} />
)}
</div>
<div className="p-2">
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
</div>
</Link>
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{page > 1 && (
<Link
href={`/u/${username}${page - 1 > 1 ? `?page=${page - 1}` : ""}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link
href={`/u/${username}?page=${page + 1}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">No public recipes yet.</p>
</div>
)
);
return ( return (
<div className="max-w-4xl mx-auto space-y-10"> <div className="max-w-4xl mx-auto space-y-10">
{/* Profile header */} {/* Profile header */}
@@ -104,7 +302,14 @@ export default async function UserProfilePage({ params }: Params) {
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div> <div>
<h1 className="text-2xl font-bold leading-tight">{user.name}</h1> <h1 className="text-2xl font-bold leading-tight flex items-center gap-2">
{user.name}
{isOwnProfile && user.isPrivate && (
<Badge variant="outline" className="gap-1 text-xs font-normal">
<Lock className="h-3 w-3" /> Private
</Badge>
)}
</h1>
<p className="text-muted-foreground text-sm">@{user.username}</p> <p className="text-muted-foreground text-sm">@{user.username}</p>
</div> </div>
{!isOwnProfile && session && ( {!isOwnProfile && session && (
@@ -120,7 +325,7 @@ export default async function UserProfilePage({ params }: Params) {
)} )}
</div> </div>
{user.bio && ( {user.bio && !isPrivateHidden && (
<p className="text-sm leading-relaxed max-w-prose">{user.bio}</p> <p className="text-sm leading-relaxed max-w-prose">{user.bio}</p>
)} )}
@@ -138,44 +343,17 @@ export default async function UserProfilePage({ params }: Params) {
</div> </div>
</div> </div>
{/* Recipe grid */} {/* Recipes / cooking history / photos */}
{publicRecipes.length > 0 ? ( {isOwnProfile && historyData && photosData ? (
<div className="space-y-4"> <ProfileTabs
<h2 className="text-xl font-semibold">Recipes</h2> username={username}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"> defaultTab={activeTab}
{publicRecipes.map((recipe) => { recipesContent={recipesSection}
const cover = recipe.photos[0]; history={historyData}
return ( photos={photosData}
<Link
key={recipe.id}
href={`/r/${recipe.id}`}
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
>
<div className="aspect-square bg-muted overflow-hidden">
{cover ? (
<img
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full 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"> recipesSection
🍽️
</div>
)}
</div>
<div className="p-2">
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
</div>
</Link>
);
})}
</div>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">No public recipes yet.</p>
</div>
)} )}
</div> </div>
); );
+6 -2
View File
@@ -25,7 +25,7 @@ export default function LoginPage() {
e.preventDefault(); e.preventDefault();
setUnverified(false); setUnverified(false);
setLoading(true); 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); setLoading(false);
if (error) { if (error) {
if (error.code === "EMAIL_NOT_VERIFIED") { if (error.code === "EMAIL_NOT_VERIFIED") {
@@ -33,7 +33,11 @@ export default function LoginPage() {
} else { } else {
toast.error(error.message ?? "Sign in failed"); 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"); 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 type { Metadata } from "next";
import { Badge } from "@/components/ui/badge"; 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 { 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 = {}; 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 }>) { 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["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.)" }; 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() { export default async function AdminAiConfigPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings(); const settings = await getAllSiteSettings();
const active = resolveProvider(settings); 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 ( return (
<div className="space-y-6"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1> <h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1>
<p className="text-muted-foreground text-sm mt-1"> <p className="text-muted-foreground text-sm mt-1">
Current AI provider settings. Override values in{" "} Provider keys, routing, and default models for AI generation.
<Link href="/admin/settings" className="text-primary underline-offset-4 hover:underline">
Site Settings
</Link>
.
</p> </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> </div>
<Card> <AdminSettingsForm group={SETTING_GROUP} settings={settings} />
<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>
<Card> <AdminDefaultModelForm
<CardHeader> initialValues={{
<CardTitle className="text-base">API Keys</CardTitle> DEFAULT_TEXT_PROVIDER: settings["DEFAULT_TEXT_PROVIDER"]?.value ?? null,
</CardHeader> DEFAULT_TEXT_MODEL: settings["DEFAULT_TEXT_MODEL"]?.value ?? null,
<CardContent> DEFAULT_CHAT_PROVIDER: settings["DEFAULT_CHAT_PROVIDER"]?.value ?? null,
{keyRows.map(({ key, label }) => { DEFAULT_CHAT_MODEL: settings["DEFAULT_CHAT_MODEL"]?.value ?? null,
const meta = settings[key]; DEFAULT_VISION_PROVIDER: settings["DEFAULT_VISION_PROVIDER"]?.value ?? null,
const present = !!meta?.value; DEFAULT_VISION_MODEL: settings["DEFAULT_VISION_MODEL"]?.value ?? null,
return ( DEFAULT_MEAL_PLAN_PROVIDER: settings["DEFAULT_MEAL_PLAN_PROVIDER"]?.value ?? null,
<div key={key} className="flex items-center justify-between py-2 border-b last:border-0"> DEFAULT_MEAL_PLAN_MODEL: settings["DEFAULT_MEAL_PLAN_MODEL"]?.value ?? null,
<div className="flex items-center gap-2"> }}
{present ? ( initialToolCallingEnabled={settings["AI_TOOL_CALLING_ENABLED"]?.value !== "false"}
<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>
</div> </div>
); );
} }
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { db, auditLogs, users, eq, desc } from "@epicure/db"; import { db, auditLogs, users, eq, desc } from "@epicure/db";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -10,6 +11,7 @@ interface PageProps {
} }
export default async function AdminAuditLogsPage({ searchParams }: PageProps) { export default async function AdminAuditLogsPage({ searchParams }: PageProps) {
await requireFullAdminPage();
const { page: pageParam } = await searchParams; const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10)); const page = Math.max(1, parseInt(pageParam ?? "1", 10));
const offset = (page - 1) * PAGE_SIZE; const offset = (page - 1) * PAGE_SIZE;
+132
View File
@@ -0,0 +1,132 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { db, users, auditLogs, count, eq, sql, like, desc } from "@epicure/db";
import { getAllSiteSettings, getSiteSetting } from "@/lib/site-settings";
import { isStripeTestMode } from "@/lib/stripe";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
const STRIPE_GROUP = {
title: "Stripe Connection",
description:
"Get keys from your Stripe Dashboard (Developers -> API keys). The webhook secret comes from " +
"the endpoint you configure there pointed at /api/webhooks/stripe (events: checkout.session.completed, " +
"customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, invoice.paid). " +
"Price/Product IDs per tier are set on the Tier Limits page, not here.",
keys: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"] as const,
};
export default async function AdminBillingPage() {
await requireFullAdminPage();
const [settings, secretKey] = await Promise.all([
getAllSiteSettings(),
getSiteSetting("STRIPE_SECRET_KEY"),
]);
const [subscriberCounts, pastDueUsers, recentBillingEvents] = await Promise.all([
db.select({ tier: users.tier, count: count() }).from(users).where(sql`${users.tier} != 'free'`).groupBy(users.tier),
db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.subscriptionStatus, "past_due"))
.limit(20),
db
.select({ id: auditLogs.id, action: auditLogs.action, targetId: auditLogs.targetId, metadata: auditLogs.metadata, createdAt: auditLogs.createdAt })
.from(auditLogs)
.where(like(auditLogs.action, "billing.%"))
.orderBy(desc(auditLogs.createdAt))
.limit(20),
]);
const proCount = subscriberCounts.find((r) => r.tier === "pro")?.count ?? 0;
const familyCount = subscriberCounts.find((r) => r.tier === "family")?.count ?? 0;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Billing</h1>
<p className="text-muted-foreground text-sm mt-1">
Stripe connection status, subscriber insights, and recent billing events. Price mapping per tier
lives on the <Link href="/admin/tiers" className="underline underline-offset-4">Tier Limits</Link> page.
</p>
</div>
<div className="flex items-center gap-2">
{secretKey ? (
<Badge variant={isStripeTestMode(secretKey) ? "secondary" : "default"}>
{isStripeTestMode(secretKey) ? "Test mode" : "Live mode"}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Not configured</Badge>
)}
<a
href="https://dashboard.stripe.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
Stripe Dashboard
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<AdminSettingsForm group={STRIPE_GROUP} settings={settings} />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">Pro subscribers</CardTitle></CardHeader>
<CardContent><p className="text-2xl font-bold">{proCount}</p></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">Family subscribers</CardTitle></CardHeader>
<CardContent><p className="text-2xl font-bold">{familyCount}</p></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm font-medium text-muted-foreground">Past due</CardTitle></CardHeader>
<CardContent><p className="text-2xl font-bold">{pastDueUsers.length}</p></CardContent>
</Card>
</div>
{pastDueUsers.length > 0 && (
<section className="rounded-xl border p-6 space-y-3">
<h2 className="font-semibold text-lg">Payments needing attention</h2>
<ul className="space-y-1.5">
{pastDueUsers.map((u) => (
<li key={u.id} className="flex items-center justify-between text-sm">
<span>{u.name} <span className="text-muted-foreground">({u.email})</span></span>
<Link href={`/admin/users/${u.id}`} className="text-primary hover:underline">View</Link>
</li>
))}
</ul>
</section>
)}
<section className="rounded-xl border p-6 space-y-3">
<h2 className="font-semibold text-lg">Recent billing events</h2>
{recentBillingEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<ul className="space-y-1.5">
{recentBillingEvents.map((e) => (
<li key={e.id} className="flex items-center justify-between text-sm border-b last:border-0 pb-1.5">
<span>
<span className="font-mono text-xs">{e.action}</span>
{e.targetId && (
<Link href={`/admin/users/${e.targetId}`} className="text-primary hover:underline ml-2">user</Link>
)}
</span>
<span className="text-muted-foreground text-xs">{e.createdAt.toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
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 async function AdminChangelogPage() {
await requireFullAdminPage();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Changelog</h1>
<p className="text-muted-foreground mt-1">Currently running v{APP_VERSION}. Edit apps/web/lib/changelog.ts to update.</p>
</div>
<ChangelogList />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
An unexpected error occurred in the admin panel. You can try again.
</p>
<Button onClick={() => reset()}>Try again</Button>
</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 type { Metadata } from "next";
import { listInvites } from "@/lib/invites"; import { listInvites } from "@/lib/invites";
import { InvitesManager } from "@/components/admin/invites-manager"; import { InvitesManager } from "@/components/admin/invites-manager";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export default async function AdminInvitesPage() { export default async function AdminInvitesPage() {
await requireFullAdminPage();
const invites = await listInvites(); const invites = await listInvites();
const appUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; const appUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
+26 -21
View File
@@ -1,30 +1,35 @@
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 Link from "next/link";
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag } from "lucide-react"; import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook, CreditCard } from "lucide-react";
import { cn } from "@/lib/utils"; 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 = [ const adminNav = [
{ href: "/admin", label: "Overview", icon: BarChart3 }, { href: "/admin", label: "Overview", icon: BarChart3, adminOnly: true },
{ href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/insights", label: "Insights", icon: TrendingUp, adminOnly: true },
{ href: "/admin/invites", label: "Invites", icon: Mail }, { href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/recipes", label: "Recipes", icon: BookOpen }, { href: "/admin/invites", label: "Invites", icon: Mail, adminOnly: true },
{ href: "/admin/reports", label: "Reports", icon: Flag }, { href: "/admin/recipes", label: "Recipes", icon: BookOpen, adminOnly: false },
{ href: "/admin/tiers", label: "Tier Limits", icon: Gauge }, { href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList }, { href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true },
{ href: "/admin/storage", label: "Storage", icon: HardDrive }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true },
{ href: "/admin/ai-config", label: "AI Config", icon: Bot }, { href: "/admin/billing", label: "Billing", icon: CreditCard, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings }, { 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 }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const role = await getStaffRole();
if (!session) redirect("/login"); if (!role) redirect("/recipes");
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id)); const visibleNav = role === "admin" ? adminNav : adminNav.filter((item) => !item.adminOnly);
if (dbUser?.role !== "admin") redirect("/recipes");
return ( return (
<div className="flex flex-col md:flex-row min-h-screen"> <div className="flex flex-col md:flex-row min-h-screen">
@@ -32,7 +37,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"> <div className="flex items-center justify-between gap-2 p-4 border-b font-semibold">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Shield className="h-4 w-4 text-destructive" /> <Shield className="h-4 w-4 text-destructive" />
Admin {role === "admin" ? "Admin" : "Moderator"}
</span> </span>
<Link <Link
href="/recipes" href="/recipes"
@@ -43,7 +48,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</Link> </Link>
</div> </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"> <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 <Link
key={href} key={href}
href={href} href={href}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
import { StatCardSkeleton } from "@/components/shared/skeletons";
export default function AdminLoading() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-32" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCardSkeleton key={i} />
))}
</div>
</div>
);
}
+79 -10
View File
@@ -1,51 +1,120 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { db } from "@epicure/db"; import { db } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos } from "@epicure/db"; import { users, recipes, userUsage, recipePhotos, ratings, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
import { count, eq, sql } from "@epicure/db"; import { count, eq, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, BookOpen, Sparkles, Image } 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 const metadata: Metadata = {};
export default async function AdminPage() { export default async function AdminPage() {
await requireFullAdminPage();
const currentMonth = new Date().toISOString().slice(0, 7); 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(users).then((r) => r[0]),
db.select({ count: count() }).from(recipes).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(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 db
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` }) .select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
.from(userUsage) .from(userUsage)
.where(eq(userUsage.month, currentMonth)) .where(eq(userUsage.month, currentMonth))
.then((r) => r[0]), .then((r) => r[0]),
db.select({ count: count() }).from(recipePhotos).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 = [ const stats = [
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users }, { 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: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image }, { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Overview</h1> <h1 className="text-2xl font-bold tracking-tight">Overview</h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(({ label, value, sub, icon: Icon }) => ( {stats.map(({ label, value, sub, unit, icon: Icon, href, highlight }) => {
<Card key={label}> const card = (
<Card className={highlight ? "border-destructive/50" : undefined}>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className={highlight ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground"} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value.toLocaleString()}</div> <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>} {sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent> </CardContent>
</Card> </Card>
))} );
return (
<div key={label}>
{href ? <Link href={href}>{card}</Link> : card}
</div> </div>
);
})}
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Version</CardTitle>
<History className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">v{APP_VERSION}</div>
<p className="text-xs text-muted-foreground mt-1">
Released {CHANGELOG[0]?.date} ·{" "}
<Link href="/admin/changelog" className="underline hover:text-foreground">
View changelog
</Link>
</p>
</CardContent>
</Card>
</div> </div>
); );
} }
+46 -6
View File
@@ -1,18 +1,31 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { db, recipes, users, eq, desc } from "@epicure/db"; import { db, recipes, users, eq, desc, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import Link from "next/link"; import Link from "next/link";
import { UnpublishRecipeButton } from "@/components/admin/unpublish-recipe-button";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
const PAGE_SIZE = 100;
const VISIBILITY_COLORS = { const VISIBILITY_COLORS = {
public: "default", public: "default",
unlisted: "outline", unlisted: "outline",
followers: "outline",
private: "secondary", private: "secondary",
} as const; } as const;
export default async function AdminRecipesPage() { interface PageProps {
const publicRecipes = await db searchParams: Promise<{ page?: string }>;
}
export default async function AdminRecipesPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [publicRecipes, totalRow] = await Promise.all([
db
.select({ .select({
id: recipes.id, id: recipes.id,
title: recipes.title, title: recipes.title,
@@ -25,7 +38,11 @@ export default async function AdminRecipesPage() {
.leftJoin(users, eq(recipes.authorId, users.id)) .leftJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public")) .where(eq(recipes.visibility, "public"))
.orderBy(desc(recipes.createdAt)) .orderBy(desc(recipes.createdAt))
.limit(200); .limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")),
]);
const total = totalRow[0]?.count ?? 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -36,7 +53,7 @@ export default async function AdminRecipesPage() {
</p> </p>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b bg-muted/50">
<tr> <tr>
@@ -45,12 +62,13 @@ export default async function AdminRecipesPage() {
<th className="px-4 py-3 text-left font-medium">Visibility</th> <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">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</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> </tr>
</thead> </thead>
<tbody> <tbody>
{publicRecipes.length === 0 ? ( {publicRecipes.length === 0 ? (
<tr> <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. No public recipes yet.
</td> </td>
</tr> </tr>
@@ -88,12 +106,34 @@ export default async function AdminRecipesPage() {
View View
</Link> </Link>
</td> </td>
<td className="px-4 py-3">
<UnpublishRecipeButton recipeId={recipe.id} />
</td>
</tr> </tr>
)) ))
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/recipes?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + publicRecipes.length < total && (
<Link
href={`/admin/recipes?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div> </div>
); );
} }
+39 -4
View File
@@ -1,11 +1,23 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { db, reports, users, eq, desc } from "@epicure/db"; import Link from "next/link";
import { db, reports, users, eq, desc, count } from "@epicure/db";
import { ReportsQueue } from "@/components/admin/reports-queue"; import { ReportsQueue } from "@/components/admin/reports-queue";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export default async function AdminReportsPage() { const PAGE_SIZE = 100;
const rows = await db
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function AdminReportsPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [rows, totalRow] = await Promise.all([
db
.select({ .select({
id: reports.id, id: reports.id,
targetType: reports.targetType, targetType: reports.targetType,
@@ -19,7 +31,11 @@ export default async function AdminReportsPage() {
.innerJoin(users, eq(reports.reporterId, users.id)) .innerJoin(users, eq(reports.reporterId, users.id))
.where(eq(reports.status, "pending")) .where(eq(reports.status, "pending"))
.orderBy(desc(reports.createdAt)) .orderBy(desc(reports.createdAt))
.limit(100); .limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")),
]);
const total = totalRow[0]?.count ?? 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -32,6 +48,25 @@ export default async function AdminReportsPage() {
<ReportsQueue <ReportsQueue
reports={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))} reports={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
/> />
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/reports?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + rows.length < total && (
<Link
href={`/admin/reports?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div> </div>
); );
} }
+23 -11
View File
@@ -2,28 +2,39 @@ import type { Metadata } from "next";
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings"; import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { SignupsToggle } from "@/components/admin/signups-toggle"; import { SignupsToggle } from "@/components/admin/signups-toggle";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
const SETTING_GROUPS = [ 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)", title: "Push Notifications (VAPID)",
description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys", 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, 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() { export default async function AdminSettingsPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings(); const settings = await getAllSiteSettings();
const signupsDisabled = await isSignupsDisabled(); const signupsDisabled = await isSignupsDisabled();
@@ -33,7 +44,8 @@ export default async function AdminSettingsPage() {
<h1 className="text-2xl font-bold tracking-tight">Site Settings</h1> <h1 className="text-2xl font-bold tracking-tight">Site Settings</h1>
<p className="text-muted-foreground text-sm mt-1"> <p className="text-muted-foreground text-sm mt-1">
Override .env values at runtime. DB values take precedence over environment variables. 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> </p>
</div> </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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HardDrive } from "lucide-react"; import { HardDrive } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export default async function AdminStoragePage() { export default async function AdminStoragePage() {
await requireFullAdminPage();
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([ const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]), db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db db
@@ -41,6 +43,7 @@ export default async function AdminStoragePage() {
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0); const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -51,7 +54,7 @@ export default async function AdminStoragePage() {
</p> </p>
</div> </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> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle> <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> <div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
</CardContent> </CardContent>
</Card> </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>
<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 type { Metadata } from "next";
import { db, tierDefinitions } from "@epicure/db"; import { db, tierDefinitions } from "@epicure/db";
import { TierLimitsForm } from "@/components/admin/tier-limits-form"; 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 const metadata: Metadata = {};
export default async function AdminTiersPage() { 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -19,6 +26,11 @@ export default async function AdminTiersPage() {
{tiers.map((tierDefinition) => ( {tiers.map((tierDefinition) => (
<TierLimitsForm key={tierDefinition.tier} tierDefinition={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> </div>
); );
} }
+14 -10
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { ResetUsageButton } from "@/components/admin/reset-usage-button";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -25,9 +27,11 @@ const ROLE_COLORS = {
const TIER_COLORS = { const TIER_COLORS = {
free: "secondary", free: "secondary",
pro: "default", pro: "default",
family: "default",
} as const; } as const;
export default async function AdminUserDetailPage({ params }: PageProps) { export default async function AdminUserDetailPage({ params }: PageProps) {
await requireFullAdminPage();
const { id } = await params; const { id } = await params;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); 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))) .where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
.limit(1); .limit(1);
const [recipeCountRow] = await db const [[recipeCountRow], storageUsedMb] = await Promise.all([
.select({ count: count() }) db.select({ count: count() }).from(recipes).where(eq(recipes.authorId, id)),
.from(recipes) getStorageUsedMb(id),
.where(eq(recipes.authorId, id)); ]);
const recentRecipes = await db const recentRecipes = await db
.select({ .select({
@@ -72,7 +76,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Avatar className="h-16 w-16"> <Avatar className="h-16 w-16">
<AvatarImage src={user.avatarUrl ?? ""} /> <AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
<AvatarFallback className="text-lg"> <AvatarFallback className="text-lg">
{user.name.slice(0, 2).toUpperCase()} {user.name.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
@@ -98,17 +102,17 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
<CardTitle className="text-base">Edit Role &amp; Tier</CardTitle> <CardTitle className="text-base">Edit Role &amp; Tier</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} /> <UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} currentIsDeveloper={user.isDeveloper} currentIsByokEnabled={user.isByokEnabled} />
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle> <CardTitle className="text-base">Usage &amp; Limits</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between text-sm"> <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> <span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@@ -117,7 +121,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Storage Used</span> <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>
<div className="pt-2"> <div className="pt-2">
<ResetUsageButton userId={user.id} /> <ResetUsageButton userId={user.id} />
+43 -6
View File
@@ -1,14 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { db } from "@epicure/db"; import { db, count } from "@epicure/db";
import { users } from "@epicure/db"; import { users } from "@epicure/db";
import { desc } from "@epicure/db"; import { desc } from "@epicure/db";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CreateUserDialog } from "@/components/admin/create-user-dialog"; import { CreateUserDialog } from "@/components/admin/create-user-dialog";
import Link from "next/link"; import Link from "next/link";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
const PAGE_SIZE = 100;
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
const ROLE_COLORS = { const ROLE_COLORS = {
user: "secondary", user: "secondary",
moderator: "outline", moderator: "outline",
@@ -18,14 +25,25 @@ const ROLE_COLORS = {
const TIER_COLORS = { const TIER_COLORS = {
free: "secondary", free: "secondary",
pro: "default", pro: "default",
family: "default",
} as const; } as const;
export default async function AdminUsersPage() { export default async function AdminUsersPage({ searchParams }: PageProps) {
const allUsers = await db await requireFullAdminPage();
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [allUsers, totalRow] = await Promise.all([
db
.select() .select()
.from(users) .from(users)
.orderBy(desc(users.createdAt)) .orderBy(desc(users.createdAt))
.limit(100); .limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(users),
]);
const total = totalRow[0]?.count ?? 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -33,7 +51,7 @@ export default async function AdminUsersPage() {
<h1 className="text-2xl font-bold tracking-tight">Users</h1> <h1 className="text-2xl font-bold tracking-tight">Users</h1>
<CreateUserDialog /> <CreateUserDialog />
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/50"> <thead className="border-b bg-muted/50">
<tr> <tr>
@@ -49,7 +67,7 @@ export default async function AdminUsersPage() {
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/admin/users/${user.id}`} className="flex items-center gap-3 group"> <Link href={`/admin/users/${user.id}`} className="flex items-center gap-3 group">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl ?? ""} /> <AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
<AvatarFallback className="text-xs"> <AvatarFallback className="text-xs">
{user.name.slice(0, 2).toUpperCase()} {user.name.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
@@ -74,6 +92,25 @@ export default async function AdminUsersPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/users?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + allUsers.length < total && (
<Link
href={`/admin/users?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div> </div>
); );
} }
+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 });
}
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, cookingHistory, eq, and, isNotNull, isNull } from "@epicure/db";
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;
// protected by a shared secret rather than user auth.
//
// For every cooking_history row tied to a batch-cook dish, checks whether it
// expires soon (see lib/leftover-match.ts) and hasn't already been reminded
// about, then sends one push + email and marks it reminded so it never fires
// twice for the same cooked dish.
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 candidates = await db.query.cookingHistory.findMany({
where: and(isNotNull(cookingHistory.batchDishId), isNull(cookingHistory.expiryReminderSentAt)),
with: {
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
recipe: { columns: { id: true, title: true } },
user: { columns: { id: true, email: true, locale: true } },
},
});
let sent = 0;
for (const log of candidates) {
if (!log.batchDish || !log.user) continue;
if (!isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays)) continue;
const messages = getMessages(log.user.locale);
const template = messages.notifications.detail.leftoverExpiring;
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([
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,
subject: title,
html: notificationEmailHtml(title, body, `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}${url}`),
}).catch((err) => {
console.error("[leftover-reminders] email failed", err);
})
: Promise.resolve(),
]);
await db.update(cookingHistory)
.set({ expiryReminderSentAt: new Date() })
.where(eq(cookingHistory.id, log.id));
sent++;
}
return NextResponse.json({ ok: true, checked: candidates.length, sent });
}
@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import {
db,
users,
recipes,
comments,
ratings,
userFollows,
favorites,
userNotificationPrefs,
eq,
and,
gte,
desc,
count,
sql,
} from "@epicure/db";
import { sendEmail, weeklyDigestHtml } from "@/lib/email";
// Internal cron endpoint — triggered by the `digest-cron` container on a weekly
// schedule (see compose.prod.yml). Not part of the public API surface;
// protected by a shared secret rather than user auth.
//
// 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;
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);
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
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)
.where(gte(userFollows.createdAt, weekAgo))
.groupBy(userFollows.followingId),
db
.select({ userId: recipes.authorId, n: count() })
.from(comments)
.innerJoin(recipes, eq(comments.recipeId, recipes.id))
.where(gte(comments.createdAt, weekAgo))
.groupBy(recipes.authorId),
db
.select({ userId: recipes.authorId, n: count() })
.from(ratings)
.innerJoin(recipes, eq(ratings.recipeId, recipes.id))
.where(gte(ratings.createdAt, weekAgo))
.groupBy(recipes.authorId),
db
.select({
id: recipes.id,
title: recipes.title,
favoriteCount: sql<number>`cast(count(${favorites.recipeId}) as int)`,
})
.from(recipes)
.leftJoin(
favorites,
and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, weekAgo))
)
.where(eq(recipes.visibility, "public"))
.groupBy(recipes.id)
.orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
.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]));
const trendingList = trending.map((r) => ({ id: r.id, title: r.title }));
let sent = 0;
let failed = 0;
for (const batch of chunk(recipients, CHUNK_SIZE)) {
const results = await Promise.allSettled(
batch.map((user) => {
const newFollowers = followerMap.get(user.id) ?? 0;
const newComments = commentMap.get(user.id) ?? 0;
const newRatings = ratingMap.get(user.id) ?? 0;
return sendEmail({
to: user.email,
subject: "Your weekly digest — Epicure",
html: weeklyDigestHtml({
newFollowers,
newComments,
newRatings,
trending: trendingList,
baseUrl,
}),
});
})
);
for (const r of results) {
if (r.status === "fulfilled") sent++;
else 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"; import { randomUUID } from "crypto";
const VALID_ROLES = ["user", "moderator", "admin"] as const; 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() { export async function GET() {
const { response } = await requireAdmin(); 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) { export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin(); const { session, response } = await requireAdmin({ allowModerator: true });
if (response) return response; if (response) return response;
const { id } = await params; 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"; import { db, reports, users, eq, desc } from "@epicure/db";
export async function GET() { export async function GET() {
const { response } = await requireAdmin(); const { response } = await requireAdmin({ allowModerator: true });
if (response) return response; if (response) return response;
const rows = await db const rows = await db
@@ -1,39 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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" } }; const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("next/headers", () => ({ vi.mock("@/lib/api-auth", () => ({
headers: vi.fn().mockResolvedValue(new Headers()), requireAdmin: vi.fn(),
}));
vi.mock("@/lib/auth/server", () => ({
auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("@/lib/site-settings", () => ({ vi.mock("@/lib/site-settings", () => ({
setSiteSetting: vi.fn().mockResolvedValue(undefined), setSiteSetting: vi.fn().mockResolvedValue(undefined),
})); }));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => { const { mockInsertValues } = vi.hoisted(() => ({
const mockSelectChain = { mockInsertValues: vi.fn().mockResolvedValue(undefined),
from: vi.fn().mockReturnThis(), }));
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({ vi.mock("@epicure/db", () => ({
db: { db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })), insert: vi.fn(() => ({ values: mockInsertValues })),
}, },
users: { id: "id", role: "role" },
auditLogs: {}, 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"); const { setSiteSetting } = await import("@/lib/site-settings");
import { PUT } from "../route"; import { PUT } from "../route";
@@ -47,21 +36,26 @@ function makeRequest(body: unknown) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never); vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
}); });
describe("PUT /api/v1/admin/settings", () => { describe("PUT /api/v1/admin/settings", () => {
it("returns 403 when caller is not an admin", async () => { 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" })); const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
it("returns 403 when there is no session", async () => { it("returns 401 when there is no session", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never); 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" })); 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 () => { it("updates allowed keys and writes an audit log", async () => {
+23 -15
View File
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers"; import { db, auditLogs } from "@epicure/db";
import { auth } from "@/lib/auth/server"; import { requireAdmin } from "@/lib/api-auth";
import { db, users, auditLogs, eq } from "@epicure/db";
import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings"; import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
@@ -14,33 +13,42 @@ const ALLOWED_KEYS: SiteSettingKey[] = [
"NEXT_PUBLIC_VAPID_PUBLIC_KEY", "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY", "VAPID_PRIVATE_KEY",
"SIGNUPS_DISABLED", "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",
"STRIPE_SECRET_KEY",
"STRIPE_PUBLISHABLE_KEY",
"STRIPE_WEBHOOK_SECRET",
]; ];
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) { export async function PUT(req: NextRequest) {
const session = await requireAdmin(); const { session, response } = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (response) return response;
const body = (await req.json()) as Record<string, string | null>; const body = (await req.json()) as Record<string, string | null>;
const changed: string[] = []; const changed: string[] = [];
for (const [key, value] of Object.entries(body)) { for (const [key, value] of Object.entries(body)) {
if (!ALLOWED_KEYS.includes(key as SiteSettingKey)) continue; 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); changed.push(key);
} }
if (changed.length > 0) { if (changed.length > 0) {
await db.insert(auditLogs).values({ await db.insert(auditLogs).values({
id: randomUUID(), id: randomUUID(),
userId: session.user.id, userId: session!.user.id,
action: "admin.settings.update", action: "admin.settings.update",
targetType: "site_settings", targetType: "site_settings",
metadata: JSON.stringify({ keys: changed }), 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) })),
}))
);
}
@@ -11,17 +11,20 @@ interface RouteContext {
const NUMERIC_FIELDS = ["maxRecipes", "aiCallsPerMonth", "storageMb", "maxPublicRecipes"] as const; const NUMERIC_FIELDS = ["maxRecipes", "aiCallsPerMonth", "storageMb", "maxPublicRecipes"] as const;
type NumericField = (typeof NUMERIC_FIELDS)[number]; type NumericField = (typeof NUMERIC_FIELDS)[number];
const STRIPE_FIELDS = ["stripeProductId", "stripePriceIdMonthly", "stripePriceIdYearly"] as const;
type StripeField = (typeof STRIPE_FIELDS)[number];
export async function PATCH(req: NextRequest, { params }: RouteContext) { export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin(); const { session, response } = await requireAdmin();
if (response) return response; if (response) return response;
const { tier } = await params; const { tier } = await params;
if (tier !== "free" && tier !== "pro") { if (tier !== "free" && tier !== "pro" && tier !== "family") {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
} }
const body = (await req.json()) as Partial<Record<NumericField, number>>; const body = (await req.json()) as Partial<Record<NumericField, number>> & Partial<Record<StripeField, string | null>>;
const updateData: Partial<Record<NumericField, number>> = {}; const updateData: Partial<Record<NumericField, number>> & Partial<Record<StripeField, string | null>> = {};
for (const field of NUMERIC_FIELDS) { for (const field of NUMERIC_FIELDS) {
const value = body[field]; const value = body[field];
@@ -32,6 +35,15 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
updateData[field] = value; updateData[field] = value;
} }
for (const field of STRIPE_FIELDS) {
const value = body[field];
if (value === undefined) continue;
if (value !== null && typeof value !== "string") {
return NextResponse.json({ error: `Invalid value for ${field}` }, { status: 400 });
}
updateData[field] = value?.trim() || null;
}
const [updated] = await db const [updated] = await db
.update(tierDefinitions) .update(tierDefinitions)
.set(updateData) .set(updateData)
+15 -7
View File
@@ -12,11 +12,11 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response; if (response) return response;
const { id } = await params; const { id } = await params;
const body = await req.json() as { role?: string; tier?: string }; const body = await req.json() as { role?: string; tier?: string; isDeveloper?: boolean; isByokEnabled?: boolean };
const { role, tier } = body; const { role, tier, isDeveloper, isByokEnabled } = body;
const validRoles = ["user", "moderator", "admin"] as const; 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])) { if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 }); return NextResponse.json({ error: "Invalid role" }, { status: 400 });
@@ -24,18 +24,26 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) { if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
} }
if (isDeveloper !== undefined && typeof isDeveloper !== "boolean") {
return NextResponse.json({ error: "Invalid isDeveloper" }, { status: 400 });
}
if (isByokEnabled !== undefined && typeof isByokEnabled !== "boolean") {
return NextResponse.json({ error: "Invalid isByokEnabled" }, { 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; isByokEnabled: boolean; updatedAt: Date }> = {
updatedAt: new Date(), updatedAt: new Date(),
}; };
if (role) updateData.role = role as "user" | "moderator" | "admin"; 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;
if (isByokEnabled !== undefined) updateData.isByokEnabled = isByokEnabled;
const [updated] = await db const [updated] = await db
.update(users) .update(users)
.set(updateData) .set(updateData)
.where(eq(users.id, id)) .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, isByokEnabled: users.isByokEnabled });
if (!updated) { if (!updated) {
return NextResponse.json({ error: "User not found" }, { status: 404 }); return NextResponse.json({ error: "User not found" }, { status: 404 });
@@ -48,7 +56,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
action: "admin.user.update", action: "admin.user.update",
targetType: "user", targetType: "user",
targetId: id, targetId: id,
metadata: JSON.stringify({ role, tier }), metadata: JSON.stringify({ role, tier, isDeveloper, isByokEnabled }),
createdAt: new Date(), createdAt: new Date(),
}); });
@@ -19,9 +19,12 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { id } = await params; const { id } = await params;
const month = currentMonth(); 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 const [updated] = await db
.update(userUsage) .update(userUsage)
.set({ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 }) .set({ aiCallsUsed: 0 })
.where(and(eq(userUsage.userId, id), eq(userUsage.month, month))) .where(and(eq(userUsage.userId, id), eq(userUsage.month, month)))
.returning(); .returning();
@@ -35,5 +38,5 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
createdAt: new Date(), 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"; import { APIError } from "better-auth";
const VALID_ROLES = ["user", "moderator", "admin"] as const; 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) { export async function POST(req: NextRequest) {
const { session, response } = await requireAdmin(); 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);
}

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