Compare commits
134 Commits
4f36ce24b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb99faf655 | |||
| f0632cce95 | |||
| 592c86f8d4 | |||
| 35d4f3d055 | |||
| c8ee743458 | |||
| 4c3880e07f | |||
| e3f2cf6834 | |||
| 513e4a904a | |||
| 83a1381052 | |||
| e78e24e40d | |||
| 3aaa12910a | |||
| 9ba2996022 | |||
| 5f270dee08 | |||
| b17984fbef | |||
| 274c50c2f6 | |||
| c5e1643d39 | |||
| cced962bff | |||
| 7626f1b496 | |||
| 623e5bcd34 | |||
| f6975e98a9 | |||
| f6214e60df | |||
| 6ec9b65c24 | |||
| 1ee3ec70ba | |||
| 2f18462548 | |||
| 37739699f9 | |||
| 6eb759dd43 | |||
| a6e3dc999b | |||
| cf7cc6c885 | |||
| 44df734f1c | |||
| 9da57dd1d0 | |||
| b1f745da66 | |||
| e8c687e53a | |||
| 5403a06348 | |||
| 8f83a1eaae | |||
| 1b72135958 | |||
| 43088759cf | |||
| 6502307340 | |||
| e6a5e1e6ab | |||
| b4dd29893d | |||
| 1bfd03c42e | |||
| 520d992de4 | |||
| 51e6722f4c | |||
| 955c4bd9dd | |||
| edcde8b34a | |||
| 2f3ba14093 | |||
| 12c2ec213a | |||
| 811d4cad42 | |||
| 9ea1f90ec4 | |||
| 8dccd8898b | |||
| e71c978e52 | |||
| 2a256a8943 | |||
| e4ad092751 | |||
| 634f8245e1 | |||
| f2d423c8d7 | |||
| ef18712ced | |||
| 4e9e23c080 | |||
| c8f4b50ef3 | |||
| 21a3622e6c | |||
| 458b5f2b07 | |||
| 9eecdbac3c | |||
| 982d4e3264 | |||
| 7d290c5b1f | |||
| c31ab8771a | |||
| c5a8f94b26 | |||
| 25e624f618 | |||
| 77c739960d | |||
| 23babd4ee9 | |||
| d8dc0aa465 | |||
| 31ff7b9ac0 | |||
| f0340859fa | |||
| 5763bd3318 | |||
| 1577b8de01 | |||
| bd3d8c88f0 | |||
| b330583dd3 | |||
| af92b8cc71 | |||
| 3042d289a0 | |||
| 6f11dc07fd | |||
| 894784afda | |||
| affa6f5c3d | |||
| d8bc077f47 | |||
| 21c541e115 | |||
| b730dfac41 | |||
| 6ad2731751 | |||
| 45adb023b9 | |||
| 1237330a0c | |||
| 8ef97da2c2 | |||
| 3e71bd29a2 | |||
| 6c4fc93aab | |||
| 6096e49008 | |||
| a08588cf85 | |||
| 38516bff63 | |||
| c16342a9b9 | |||
| acc93de708 | |||
| 4e5f45a7e5 | |||
| c21157fc93 | |||
| 95a4dccce8 | |||
| 6513bfa6ee | |||
| 00ca8b9d68 | |||
| aa67868b96 | |||
| a144052e46 | |||
| 6efabeab8a | |||
| f0397740d7 | |||
| 2ee99ea463 | |||
| 79cdb8cd04 | |||
| 6dd48b5c25 | |||
| 2beb23b360 | |||
| b1f4bba6dd | |||
| 70eb565eec | |||
| 4960dfc7c6 | |||
| 6818f10fc4 | |||
| 7c63388618 | |||
| 36e707b934 | |||
| 13b8de0e0c | |||
| 257908da7f | |||
| e117533f6f | |||
| d107bce92e | |||
| 5d351f88b0 | |||
| b1995522ee | |||
| da48d0ecef | |||
| adaa837564 | |||
| 9549684254 | |||
| 96ef5b8379 | |||
| 0062220d8e | |||
| b2f2274673 | |||
| adc88e63b0 | |||
| 8ec11e9463 | |||
| 2ffa05e4cf | |||
| 9a448ef34d | |||
| eed57cd10b | |||
| b69f5845c3 | |||
| e678e21ee8 | |||
| 4d5269aced | |||
| e78c959c49 | |||
| 5a9e306357 |
@@ -57,6 +57,18 @@ SMTP_FROM=Epicure <noreply@epicure.app>
|
|||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Gitea (optional — in-app support tickets open an issue here if all three are set)
|
||||||
|
GITEA_URL=
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_REPO=owner/repo
|
||||||
|
# Set as the webhook secret on the Gitea repo's webhook config (issues + issue_comment
|
||||||
|
# events) to sync issue close/reopen/comments back into Epicure support tickets.
|
||||||
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# USDA FoodData Central (optional, free — get a key at https://fdc.nal.usda.gov/api-key-signup)
|
||||||
|
# Improves recipe nutrition estimates with real per-ingredient data instead of AI-only guesses.
|
||||||
|
USDA_API_KEY=
|
||||||
|
|
||||||
# Stripe (optional — webhook stub only)
|
# Stripe (optional — webhook stub only)
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -44,4 +44,4 @@ coverage/
|
|||||||
# Misc
|
# Misc
|
||||||
.claude/
|
.claude/
|
||||||
project_epicure.md
|
project_epicure.md
|
||||||
PLAN.md
|
plans/
|
||||||
|
|||||||
+649
-6
@@ -2,7 +2,650 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-12
|
## 0.71.0 — 2026-07-22 10:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New "developer access" permission, admin-toggled per user (Admin → Users), gates webhooks, self-serve API keys, and BYOK AI provider keys — previously available to every logged-in user with no gating at all. Anyone who already had a webhook, API key, or BYOK key configured keeps access automatically.
|
||||||
|
|
||||||
|
## 0.70.0 — 2026-07-22 09:30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipe nutrition estimation now looks up real per-ingredient data from USDA FoodData Central (for ingredients whose quantity/unit converts to grams — e.g. "200g flour", "2 tbsp oil") and only asks AI to estimate whatever's left (count-based units like "2 cloves", or ingredients with no USDA match). Configure `USDA_API_KEY` in Admin → Settings (free, no partnership needed) — unset, it falls back to the previous fully-AI-estimated behavior exactly as before.
|
||||||
|
|
||||||
|
## 0.69.0 — 2026-07-22 09:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nutrition page gets a Trend tab alongside the daily Diary: a 7/30/90-day calorie chart plus daily protein/carbs/fat averages, computed from the same cooking history the diary already tracks.
|
||||||
|
|
||||||
|
## 0.68.0 — 2026-07-21 10:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Meal plans can now be shared with a read-only anonymous public link (Share → Public link), same idea as shopping lists' public link but view-only, plus a QR code you can screenshot or print to hand someone the week's plan.
|
||||||
|
|
||||||
|
## 0.67.0 — 2026-07-21 09:30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Epicure now registers as an OS share target (Chromium/Android, once installed): share a recipe link from another app's share sheet and it opens straight into the URL-import flow, pre-filled and auto-imported. Safari has no equivalent API — Chromium/Android only, same restriction as the install-prompt banner.
|
||||||
|
|
||||||
|
## 0.66.0 — 2026-07-21 09:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Moderator role is now wired up: moderators get access to Admin → Reports (view/resolve) and Admin → Recipes (view + unpublish a public recipe as a takedown action). Every other admin section stays admin-only, enforced server-side on each page, not just hidden from the nav.
|
||||||
|
- Push notifications now actually display. The service worker had no `push` event listener at all, so incoming push messages never showed anything — fixed, plus a `notificationclick` handler that focuses an existing tab or opens one at the notification's target URL.
|
||||||
|
|
||||||
|
## 0.65.3 — 2026-07-21 01:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed a stale `app/favicon.ico` left over from the original create-next-app scaffold (the default Next.js/Vercel triangle logo) — Next's file-based routing served it as `/favicon.ico` regardless of the app icon configured via `icon.svg`, so some browsers showed the placeholder logo instead of Epicure's icon no matter what cache state you were in. The SVG icon is now the only favicon source.
|
||||||
|
|
||||||
|
## 0.65.2 — 2026-07-21 01:10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The "Save offline" button on the recipe page was a labeled outline button, inconsistent with the icon-only ghost + tooltip style every other recipe action button (print, share, export) uses. Now matches.
|
||||||
|
|
||||||
|
## 0.65.1 — 2026-07-21 01:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The install banner now shows an iOS Safari-specific instructional variant ("Tap Share, then Add to Home Screen") instead of silently doing nothing — Safari never fires the event the regular Install button depends on, so it had no install guidance at all before this.
|
||||||
|
|
||||||
|
## 0.65.0 — 2026-07-21 00:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Save recipes for offline use: a "Save offline" button on any recipe pins it in the service worker cache so it opens with no connection. The offline fallback page now lists your saved (and recently visited) recipes instead of being a dead end.
|
||||||
|
- Marking a batch-cook dish as cooked while offline no longer just fails — it's queued and synced automatically once you're back online (via Background Sync where supported, otherwise as soon as the app detects a connection).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The service worker's network-first pages never actually populated its cache on success, so the offline page's claim that "recently visited recipes are available" wasn't true. It does now.
|
||||||
|
|
||||||
|
## 0.64.0 — 2026-07-21 00:30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The web app manifest was never actually linked into the page (pointed at `/manifest.json`, but the real route is `/manifest.webmanifest`) and its icons referenced PNGs that don't exist in the repo — both silently broke install eligibility on Chrome/Android. Manifest now links correctly and icons point at the existing SVGs.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added apple-mobile-web-app meta tags and a `viewport-fit=cover` safe-area setting for iOS home-screen installs.
|
||||||
|
- Added an install-app banner (Chrome/Edge/Android only, via the standard `beforeinstallprompt` event) so the PWA install prompt is a first-class, dismissible UI element instead of relying on the browser's own icon.
|
||||||
|
|
||||||
|
## 0.63.0 — 2026-07-21 00:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support tickets now sync two-way with their linked Gitea issue: closing/reopening the issue in Gitea updates the ticket's status, and comments on either side (Epicure or Gitea) show up in both places. Configure a webhook secret (`GITEA_WEBHOOK_SECRET`) on the Gitea repo's webhook, subscribed to Issues and Issue Comment events, pointed at `/api/webhooks/gitea`.
|
||||||
|
|
||||||
|
## 0.62.0 — 2026-07-20 23:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings → Features: new Chatbots toggle — hides both the recipe cooking-chat panel and the recipe-list cooking assistant when off.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Feature toggles (Settings → Features) were only respected by the mobile hamburger menu — the desktop horizontal nav still showed every item regardless of your saved preferences. Both now read the same visibility list.
|
||||||
|
|
||||||
|
## 0.61.0 — 2026-07-20 23:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings → Notifications now has an email toggle for every category alongside the existing push toggle, plus a Weekly Digest toggle to opt out of the weekly email summary entirely.
|
||||||
|
- Admin → Webhooks: site-wide ops webhooks (new signups, support tickets, reports filed) — separate from the existing per-user webhooks, for Slack/Discord-style ops alerting.
|
||||||
|
- Settings → Features: hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from your own navigation. Cosmetic only — hidden pages stay reachable by direct link.
|
||||||
|
|
||||||
|
## 0.60.0 — 2026-07-20 22:15
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Closed a race in recipe/storage tier-limit checks — two concurrent requests near the cap could both pass a live count check and both write, exceeding the lifetime limit. Now locked per-user inside the same transaction as the write.
|
||||||
|
- Fixed four AI recipe-creation routes (photo import, idea generation, batch-cook, translate-to-new-draft) that had no recipe-limit check at all, letting any tier create unlimited recipes through them.
|
||||||
|
- Added missing per-user rate limits to 5 AI endpoints (adapt, drinks, pairings, translate, variations).
|
||||||
|
- Search page now checks for a session server-side, matching every other page (defense-in-depth — the underlying API was already public by design and the edge proxy already blocked unauthenticated access).
|
||||||
|
- Upgraded drizzle-orm to fix a SQL-identifier-escaping vulnerability, and overrode two transitive dependencies (esbuild, postcss) with known CVEs — `pnpm audit` is now clean.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Admin settings endpoint now uses the shared admin-auth check instead of a local duplicate.
|
||||||
|
- Documented two admin support-ticket endpoints that were missing from the OpenAPI spec.
|
||||||
|
|
||||||
|
## 0.59.0 — 2026-07-20 21:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Admin: chatbot now has its own default-model setting, separate from generic text generation — the general assistant and per-recipe Q&A chat can be pointed at a different model than recipe generation.
|
||||||
|
- Admin: tool calling (the chatbot's inline recipe/shopping-list drafting) can be toggled off — useful for a local/Ollama model that doesn't reliably support it, so the bot falls back to plain text answers instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Simplified the admin AI Configuration page — it showed the same provider keys and routing settings twice (once read-only, once as an edit form). Merged into one section.
|
||||||
|
|
||||||
|
## 0.58.0 — 2026-07-20 21:00
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipe count and storage usage were incorrectly reset every month, same as AI calls — a user near their recipe/storage cap got fresh room on the 1st even though nothing was deleted. Only AI calls are meant to be monthly; recipes and storage are now lifetime totals derived live from actual data (recipes, photos, avatar), so deleting something is itself what frees up quota.
|
||||||
|
- Removing your avatar from Settings silently did nothing — the client sent a field the API didn't recognize, so the update was a no-op.
|
||||||
|
|
||||||
|
## 0.57.2 — 2026-07-20 20:15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The previous chatbot fix only caught the model spelling a recipe out as prose. The more common case: a short reply that claims a draft exists ("here's a draft, check below") without ever calling the tool, so nothing renders. Now also detects recipe-creation intent from the user's own message and forces the tool call in that case too.
|
||||||
|
|
||||||
|
## 0.57.1 — 2026-07-20 20:00
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Chatbot sometimes answered "create a recipe" requests with the recipe spelled out as prose instead of the actual draft card (weaker/free-tier models occasionally ignore the tool-calling instruction). Now detects that shape and forces one retry with the tool call required, so the draft card still appears.
|
||||||
|
|
||||||
|
## 0.57.0 — 2026-07-20 09:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Cooking assistant, fullscreen on desktop, now has a persistent conversation sidebar on the left (like ChatGPT/Claude) instead of a dropdown menu. Mobile and the normal drawer view keep the dropdown.
|
||||||
|
|
||||||
|
## 0.56.0 — 2026-07-20 09:10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Step timers in the recipe editor now take a unit (seconds/minutes/hours) instead of forcing everyone to do the math into seconds. Editing an existing recipe shows the timer in whichever unit divides evenly into what's stored.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ingredient list on the recipe page didn't line up — the quantity column only had a minimum width, so a longer value pushed that row's ingredient name further right than the others. Switched to a shared grid column so every row's name starts at the same spot.
|
||||||
|
|
||||||
|
## 0.55.3 — 2026-07-19 19:00
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Admin Insights page's actual crash, found from server logs: the page (a server component) was passing plain formatter functions as props into its chart components (client components) — React can't serialize functions across that boundary. Replaced with a plain string prop ("day" vs "month") formatted inside the client component instead.
|
||||||
|
|
||||||
|
## 0.55.2 — 2026-07-19 18:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Admin Insights page could 500 the whole panel if any one of its six aggregate queries failed — replaced a non-standard positional GROUP BY with the expression-repeat pattern used everywhere else in the codebase, and made the six queries independent (Promise.allSettled) so one failing degrades that one chart instead of crashing the page.
|
||||||
|
|
||||||
|
## 0.55.1 — 2026-07-19 18:05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Chatbot's rename/delete buttons for a conversation were hover-only, so they never appeared on mobile (no hover state on touch). Now always visible.
|
||||||
|
|
||||||
|
## 0.55.0 — 2026-07-19 17:50
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New Admin > Insights page — signups and recipes-created over the last 30 days, users by tier, recipes by visibility, monthly AI usage, and support tickets by status. Charts are hand-built (hover tooltips, table-view fallback, colorblind-safe palette), no new dependency.
|
||||||
|
|
||||||
|
## 0.54.1 — 2026-07-19 17:25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Public collections are now viewable by logged-out visitors at /c/{id} (no account needed), same as public recipes at /r/{id} — the collection's QR code/print link and the new "view publicly" icon on the collection page both point here. Followers-only collections check the visitor's follow status the same way followers-only recipes do.
|
||||||
|
|
||||||
|
## 0.54.0 — 2026-07-19 17:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Collections now have the same visibility options as recipes — private, followers-only, unlisted, or public — replacing the old public/private toggle. Fork, favorite, and viewing a collection all respect the new followers-only option the same way recipes do.
|
||||||
|
- Collection PDF exports now include a QR code linking back to the collection, same as recipe PDFs.
|
||||||
|
|
||||||
|
## 0.53.1 — 2026-07-19 16:10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Drag-reorder in a collection now animates live as you drag instead of only updating on drop, and the grip handle is now visible instead of an invisible hover target.
|
||||||
|
- "Edit" was untranslated everywhere it's used (missing from both languages, not just French).
|
||||||
|
- Recipes generated via "Generate meal", weekly meal plan, or "Adapt recipe" never had their language recorded — this made the Translate button appear even though the recipe was already in your language. Fixed at the source for all three.
|
||||||
|
- The Translate dialog itself was hardcoded in English regardless of app language — now fully translated.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipe tags now show on the recipe detail page.
|
||||||
|
- Collection header actions are now icon-only with tooltips, matching the recipe page.
|
||||||
|
- Searching collections now also matches recipes inside them.
|
||||||
|
- Explore links to "Discover collections" next to its tabs.
|
||||||
|
|
||||||
|
## 0.53.0 — 2026-07-19 15:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Collections got a big upgrade: drag-and-drop reorder recipes, search collections and search within a collection, edit name/description/tags/private notes, delete with a choice to also delete the recipes (only ones you own), and tags/labels on collections themselves.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Collection cards showed the wrong recipe count (capped at 1) — now a real count. Cards also got a visual refresh (photo collage preview) and collection detail pages now use the same recipe card as the main Recipes page.
|
||||||
|
|
||||||
|
## 0.52.1 — 2026-07-19 14:10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Steps with no timer sometimes showed a stray "0" on the recipe page, cook mode, and print views — a step whose timer happened to be stored as 0 (rather than empty) hit a JS truthiness quirk where `0 && ...` renders the literal 0 instead of nothing.
|
||||||
|
|
||||||
|
## 0.52.0 — 2026-07-19 13:55
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Generate a complete meal from a collection — pick a theme and 2+ courses (starter, main, side, dessert, drink), AI generates one coherent, matching recipe per course and adds them all to that collection in one go.
|
||||||
|
|
||||||
|
## 0.51.7 — 2026-07-19 13:35
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Account dropdown menu: "View profile" and "Settings" now have icons like every other item in the menu, and there's a new separator grouping the account/support links apart from the theme switcher.
|
||||||
|
|
||||||
|
## 0.51.6 — 2026-07-19 13:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Image attachments on support tickets now embed inline in the Gitea issue (rendered preview) instead of just a plain link. Also fixed the admin "Create Gitea issue" retry action, which was dropping attachments entirely and only ever sent the ticket description.
|
||||||
|
|
||||||
|
## 0.51.5 — 2026-07-19 13:05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Gitea issue creation was sending label names ("bug", "enhancement") where Gitea's API expects numeric label IDs, failing with a 422 on every attempt. Now looks up the repo's actual label IDs by name first — issues still get created (just unlabeled) if the repo doesn't have matching labels yet.
|
||||||
|
|
||||||
|
## 0.51.4 — 2026-07-19 12:50
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Gitea issue creation failures now log the real network-level cause server-side (DNS/connection/timeout, not just "fetch failed") and surface a more specific message in the admin support view's tooltip.
|
||||||
|
|
||||||
|
## 0.51.3 — 2026-07-19 12:35
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Admin Settings' Gitea Integration section now spells out exactly what each field expects and what token scope to grant, instead of a one-line hint.
|
||||||
|
|
||||||
|
## 0.51.2 — 2026-07-19 12:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipe cover placeholder icons now use a solid color instead of a transparent one — the transparency darkened wherever an icon's own strokes overlapped, showing up as an uneven blotch on icons like the chef's hat or croissant.
|
||||||
|
|
||||||
|
## 0.51.1 — 2026-07-19 12:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- More icon choices for custom recipe covers — dish icons went from 16 to 29 (banana, grape, citrus, hamburger, popcorn, bean, vegan, utensils, leaf, shrimp, cooking pot, candy, nut), drinks got milk.
|
||||||
|
|
||||||
|
## 0.51.0 — 2026-07-19 11:40
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pick your own recipe cover color + icon in the recipe editor (below Photos) — used whenever that recipe has no photo. Also toned down the auto-generated placeholder colors from the previous release, which were too saturated next to real cover photos.
|
||||||
|
|
||||||
|
## 0.50.2 — 2026-07-19 10:15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipes without a cover photo now get a varied gradient + food/drink icon instead of the same flat emoji everywhere — deterministic per recipe, so it stays stable across reloads. Applied to the Recipes grid/list, Explore/collections cards, and public profile pages.
|
||||||
|
|
||||||
|
## 0.50.1 — 2026-07-19 09:30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Password sign-in for 2FA-enabled accounts could silently bounce back to the login page with no error shown — the app was pushing straight to /recipes on any non-error sign-in response, racing the SDK's own redirect to the 2FA code screen and sometimes losing. Now waits for that case explicitly.
|
||||||
|
|
||||||
|
## 0.50.0 — 2026-07-18 14:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Per-tier feature toggles, managed from Admin > Tier Limits. Recipe variations, drink pairing, and meal pairing can each be disabled for a tier (e.g. Free) — the buttons stay visible but show an upgrade prompt instead of running, and the API rejects the call server-side either way.
|
||||||
|
|
||||||
|
## 0.49.1 — 2026-07-18 13:10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support form now accepts attachments — screenshots or files (images, PDF, text, JSON, zip; up to 5 files, 10MB each). They're linked in the confirmation email's Gitea issue and shown as thumbnails on your ticket history and in the admin support view.
|
||||||
|
|
||||||
|
## 0.49.0 — 2026-07-18 12:30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New Support page — report a bug, share a suggestion, or ask a question right from the app. Submissions email you a confirmation and (if an admin has configured a Gitea repo in Settings) automatically open a tracked issue. Admins get a Support section in the admin panel to triage tickets and retry issue creation.
|
||||||
|
|
||||||
|
## 0.48.2 — 2026-07-18 11:15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Marketing site's language switcher no longer uses emoji flags (inconsistent rendering across OS/browsers) — now inline SVG flags in a proper dropdown menu. Home page's feature grid also got a 9th highlight (personalized recommendations) so the last row isn't left half-empty.
|
||||||
|
|
||||||
|
## 0.48.1 — 2026-07-18 11:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Marketing site: language switcher (flag toggle, EN/FR) and a dark-mode toggle in the header — both work for logged-out visitors, no account needed. Features/Home now also cover cook mode, recipe variations, personalized recommendations, and ingredient substitution. Signup CTAs now say "Signups closed" instead of "Get started free" when signups are disabled.
|
||||||
|
|
||||||
|
## 0.48.0 — 2026-07-18 09:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Public marketing pages — Home, Features, About, Privacy, Terms, Contact — visible to logged-out visitors at the root domain, plus a sitemap and robots.txt. Logged-in users hitting "/" still land straight in the app, unchanged.
|
||||||
|
|
||||||
|
Note: Privacy and Terms are structural drafts, not lawyer-reviewed (see `VITRINE_PLAN.md` §4). Pricing page intentionally not included yet (waiting on Stripe Checkout per `STRIPE_PLAN.md`).
|
||||||
|
|
||||||
|
## 0.47.0 — 2026-07-18 00:30
|
||||||
|
|
||||||
|
Renamed the "Team" billing tier to "Family" (free/pro/family) — same limits, same admin editing, just the name. Existing Team users keep their tier/limits unaffected; the DB enum value itself was renamed in place (no data migration needed).
|
||||||
|
|
||||||
|
## 0.46.2 — 2026-07-17 19:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipe editor's ingredient and step rows were cramped on mobile — 4 inline fields plus a timer/delete button squeezed into one row with no real wrap plan. Both now stack sanely on narrow screens (name/quantity/unit on one line, note/timer + delete on the next) and stay a single row on larger screens.
|
||||||
|
|
||||||
|
## 0.46.1 — 2026-07-17 19:05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Asking the cooking assistant to create a recipe would often just get you the recipe typed out in the chat instead of the createRecipe draft card — the instruction telling the model to use the tool was a soft suggestion, not a hard rule. Made it explicit: full recipes must go through the tool, never as plain-text ingredients/steps in the reply.
|
||||||
|
|
||||||
|
## 0.46.0 — 2026-07-17 18:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The cooking assistant can now also propose adding items to a shopping list ("add that to my shopping list") — pick an existing list or name a new one, then confirm. Same no-action-without-confirmation pattern as recipe creation, going through the same endpoints the manual "Add to shopping list" button already uses.
|
||||||
|
|
||||||
|
Note: generateMealPlan is still not built as a chat tool — the existing meal-plan-generation endpoint generates and saves in one step with its own input shape (week/preferences, not a specific plan to save), so it doesn't fit the draft-then-confirm pattern the other two tools use without a larger change to that endpoint.
|
||||||
|
|
||||||
|
## 0.45.0 — 2026-07-17 17:30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The cooking assistant can now propose creating a recipe when you explicitly ask it to ("make me a recipe for X", "write that down") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.
|
||||||
|
|
||||||
|
Note: this is the first step of chatbot-driven actions — only recipe creation for now. Adding to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.
|
||||||
|
|
||||||
|
## 0.44.1 — 2026-07-17 17:00
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Every recipe page was broken (server error) since 0.41.0 — the new followers-visibility check referenced the user_follows table incorrectly inside a query the ORM rewrites in a way that silently produces invalid SQL for cross-table references. Fixed by using a literal table reference instead.
|
||||||
|
|
||||||
|
## 0.44.0 — 2026-07-17 16:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New "Team" billing tier, above Pro — higher AI-call, recipe, and storage limits, editable from Admin > Tiers like the existing tiers. (Moderator/admin remain separate account roles, unrelated to billing tier — unchanged by this.)
|
||||||
|
|
||||||
|
## 0.43.0 — 2026-07-17 15:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The cooking assistant now supports multiple named conversations — start a new one, rename, or delete from the conversations menu in the chat header. Auto-titled from your first question; older single-thread history stays as-is but isn't shown in the new list.
|
||||||
|
|
||||||
|
## 0.42.0 — 2026-07-17 15:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipe editor now has "Regenerate with AI" — describe what to change (e.g. "make it spicier", "swap in chicken") and the draft you're editing updates in place, no new recipe created. You still need to save.
|
||||||
|
|
||||||
|
## 0.41.0 — 2026-07-17 14:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New recipe visibility option: "Followers only" — visible to people who follow you, hidden from public browsing, search, and anonymous share links. Available in the recipe editor, bulk visibility actions, and the Recipes filter.
|
||||||
|
|
||||||
|
Note: public profile pages and search results don't yet surface a viewer's followers-only recipes from that author — those remain reachable only via the recipe's direct link or the Following feed.
|
||||||
|
|
||||||
|
## 0.40.0 — 2026-07-17 14:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- AI Settings now shows a usage panel — AI calls, recipes created, and storage used this month against your tier's limits, with progress bars.
|
||||||
|
|
||||||
|
## 0.39.0 — 2026-07-17 13:30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Adapting/varying a recipe with AI could fail silently (no error shown) on a network hiccup, and could save a byte-for-byte duplicate recipe when the AI's output didn't actually differ from the original — both flows now surface real errors and skip saving when nothing changed. AI-adapted recipes also now show up in recipe history (Forked from…) like forks always have.
|
||||||
|
|
||||||
|
## 0.38.0 — 2026-07-17 13:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The cooking assistant and per-recipe chat can now expand to fullscreen — a new toggle in the chat header.
|
||||||
|
|
||||||
|
## 0.37.0 — 2026-07-17 12:30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Generating a recipe from a photo now uses two separate AI steps — a vision model recognizes what's in the picture, then a text model writes the recipe from that — instead of one model doing both, so each step can use the model actually suited to it.
|
||||||
|
|
||||||
|
## 0.36.0 — 2026-07-17 12:00
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Explore and the Activity Feed are now one page — Explore has Discover/Following/For You tabs, so browsing and following recipes no longer live in two separate places.
|
||||||
|
- Explore's recipe cards (search results, trending, recently added, following, for you) now use the same cover-photo card as the Recipes page, instead of the old text-only search card.
|
||||||
|
|
||||||
|
## 0.35.0 — 2026-07-14 18:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Recipe editor now lets you enter nutrition values by hand (calories, protein, carbs, fat, fiber, sodium per serving) instead of relying only on the AI estimate — the nutrition panel shows a "Manually entered" badge and swaps to "Estimate with AI instead" when manual data is present.
|
||||||
|
|
||||||
|
## 0.34.0 — 2026-07-14 18:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Admin overview now shows growth (new users/recipes this week), engagement (recipes cooked this week), a moderation queue count, storage used, and platform-integration counts (webhooks, API keys) — not just totals.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The admin Signups toggle was inverted — switching it "on" used to disable signups. It now reads naturally: on means open.
|
||||||
|
- AI provider keys and default-model settings have moved from Site Settings to AI Config, alongside the rest of the AI setup.
|
||||||
|
|
||||||
|
## 0.33.1 — 2026-07-14 17:45
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- URL-imported recipes never recorded what language they were actually written in, so the Translate button couldn't tell whether translation was needed — the importer now detects the source page's language, and photo import records the language it always writes in.
|
||||||
|
|
||||||
|
## 0.33.0 — 2026-07-14 17:35
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AI recipe generation/import now auto-detects drinks vs dishes** — generate, generate-from-idea, URL import, and photo import all classify the result and skip cook time for drinks, defaulting to dish whenever it's ambiguous.
|
||||||
|
- **Drink recipes get their own default icon** (🍹 instead of 🍽️) wherever a recipe has no cover photo.
|
||||||
|
- **Explore now has a Type filter** (dish/drink), alongside My Recipes' existing one.
|
||||||
|
|
||||||
|
## 0.32.0 — 2026-07-14 17:10
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Fixed a stored-XSS hole in public recipe pages' embedded structured data, and tightened the production Content-Security-Policy so script injection like it can't execute even if a similar bug slips in again.
|
||||||
|
- Avatar photos now require proof you actually own the upload — previously any URL was accepted, including another user's uploaded photo key.
|
||||||
|
- Changing your password now signs out every other active session, and so does resetting a forgotten password.
|
||||||
|
- Fixed a rate-limit bypass on public share-link throttling caused by trusting a spoofable header.
|
||||||
|
- Webhook signing secrets are now encrypted at rest, matching how API keys and other secrets are already stored.
|
||||||
|
- Login/2FA/password-reset rate limiting now shares Redis instead of quietly resetting per server instance.
|
||||||
|
- Photo/avatar uploads are now size-capped by the storage server itself, not just a number the client could lie about.
|
||||||
|
- A private recipe's title could leak via its page's browser-tab title to any signed-in user who had the link — fixed to respect the same visibility rules as the page itself.
|
||||||
|
- Blocking/unblocking a user is now rate-limited, matching follow/unfollow.
|
||||||
|
- AI calls made with your own API key (Settings → AI Keys) no longer count against your monthly AI-call limit.
|
||||||
|
|
||||||
|
## 0.31.0 — 2026-07-14 16:35
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Recipes can now be marked as a Drink/cocktail** instead of a dish — the recipe form hides irrelevant food-only fields (prep/cook time, difficulty, batch cooking) for drinks, and you can filter your recipe list by type. Drink recipes also no longer show the food/drink pairing suggestion buttons on their own page.
|
||||||
|
|
||||||
|
## 0.30.1 — 2026-07-14 16:20
|
||||||
|
|
||||||
|
Internal: fixed test mocks left stale by the earlier API-key auth-widening pass (recipes/meal-plans/shopping-lists route tests) — no user-facing change.
|
||||||
|
|
||||||
|
## 0.30.0 — 2026-07-14 16:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Admins can now set the site-wide default AI provider/model** per use case (text generation, photo/vision, meal-plan generation) from Settings → Admin, applied whenever a user hasn't picked their own model preference or brought their own API key.
|
||||||
|
|
||||||
|
## 0.29.0 — 2026-07-14 15:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Photo recipe import now respects your app language** — previously it defaulted to English (or whatever language was in the photo) instead of matching your locale like other AI generation features.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Importing a photo with no recognizable recipe used to open the recipe editor anyway with empty/garbage fields — it now shows a clear error instead.
|
||||||
|
|
||||||
|
## 0.28.3 — 2026-07-14 14:55
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Copying an unlisted recipe's share link wrongly warned "this link won't work until the recipe is Public" — unlisted links work fine (that's the point of unlisted); the warning now only shows for actually-private recipes.
|
||||||
|
|
||||||
|
## 0.28.2 — 2026-07-14 14:45
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipe cards on Explore and Search cramped the title next to the difficulty badge, cutting titles off early — the title now gets its own full-width line, and the grid gains a medium-viewport column step matching the Recipes page.
|
||||||
|
|
||||||
|
## 0.28.1 — 2026-07-14 14:25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Cooking mode's keyboard/voice shortcuts panel could overflow and overlap the surrounding screen in French (longer translated labels) — now scrolls internally and wraps instead of overflowing.
|
||||||
|
|
||||||
|
## 0.28.0 — 2026-07-14 14:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meal plan and shopping list header buttons now match the recipe page's icon+tooltip style** — Share, Generate shopping list, Shopping lists, Print, Export to calendar, Send to grocery delivery all collapsed to icon-only with a tooltip, instead of crowding the header with text labels.
|
||||||
|
- **Week-switch arrows on the meal plan now sit right next to the week date**, instead of at the far end of the button row.
|
||||||
|
|
||||||
|
## 0.27.2 — 2026-07-14 13:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Dietary tag chips, the recipe tag-adding form, the "Edit recipe" title, and the meal-plan nutrition-goals bar were all hardcoded in English regardless of app language — now translated.
|
||||||
|
|
||||||
|
## 0.27.1 — 2026-07-14 12:05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- API Reference (`/docs`) schemas corrected against the actual code across the whole API — recipes (batch-cook fields, tags, photos were undocumented), comments, ratings, meal plans, shopping lists, pantry, feed, and several missing error responses. A webhook-redelivery route also returned a malformed error body — now matches the rest of the API's `{ error: string }` convention.
|
||||||
|
|
||||||
|
## 0.27.0 — 2026-07-14 11:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **API keys now work on recipes, collections, meal plans, shopping lists, pantry, feed, and AI generation** — previously most of the API only accepted a session cookie, so keys couldn't actually be used for most endpoints. Account/credential-adjacent routes (profile, AI provider keys, webhooks, notifications, messages, admin) intentionally stay session-only.
|
||||||
|
|
||||||
|
## 0.26.0 — 2026-07-14 10:20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **API Reference (`/docs`) now covers the full API**: went from 14 documented endpoints to all ~100 — pantry, meal plans, shopping lists, collections, users/social, AI generation, webhooks, conversations, notifications, and admin routes are now all documented with request/response schemas.
|
||||||
|
|
||||||
|
## 0.25.1 — 2026-07-14 09:40
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- API key "Last used" only showed a date — now shows date and time.
|
||||||
|
|
||||||
|
## 0.25.0 — 2026-07-14 09:36
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Gravatar is now opt-in, off by default** — previously every account without a custom photo automatically had its email hashed and sent to gravatar.com. Turn it on in Settings → Profile if you want it.
|
||||||
|
|
||||||
|
## 0.24.1 — 2026-07-14 09:24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- My Recipes search result count showed a literal "{count}" instead of the number, in every locale.
|
||||||
|
|
||||||
|
## 0.24.0 — 2026-07-14 08:08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **My Recipes search now matches ingredients and tags too**, not just title/description — same improvement shipped for Explore/Search earlier.
|
||||||
|
- **QR code on printed recipes**: a public or unlisted recipe's printout now includes a scannable code linking back to the online version.
|
||||||
|
|
||||||
|
## 0.23.0 — 2026-07-13 23:06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.
|
||||||
|
|
||||||
|
## 0.22.0 — 2026-07-13 22:49
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **QR code on printed shopping lists**: a public list's printout now includes a scannable code linking back to the live version — pairs with public-editable links, so someone at the store can scan and check items off in real time.
|
||||||
|
|
||||||
|
## 0.21.0 — 2026-07-13 22:34
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Rating stars on recipe cards** — average rating and review count now show on Search and Explore results, not just the recipe detail page.
|
||||||
|
|
||||||
|
## 0.20.0 — 2026-07-13 22:26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Duplicate your own recipe** — same one-click copy used for forking others' recipes, now also on your own, for making variations without touching version history.
|
||||||
|
|
||||||
|
## 0.19.0 — 2026-07-13 22:21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Export your meal plan to a calendar file** — download a week's meals as a .ics file to import into Google/Apple/Outlook calendar.
|
||||||
|
|
||||||
|
## 0.18.0 — 2026-07-13 22:16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Live updates on shared meal plans**: entries added, removed, or cleared by a collaborator now appear automatically, both on the owner's own week view and everyone else's shared view.
|
||||||
|
|
||||||
|
## 0.17.0 — 2026-07-13 22:06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Clear a day or the whole meal-plan week** in one click instead of removing each meal individually.
|
||||||
|
|
||||||
|
## 0.16.0 — 2026-07-13 21:59
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **@mention autocomplete in comments**: type "@" and a name to search and insert a mention, instead of typing the exact username blind.
|
||||||
|
|
||||||
|
## 0.15.0 — 2026-07-13 21:54
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Search now matches ingredients and tags**, not just title/description — "zaatar" now finds a recipe that uses it even if the title doesn't mention it.
|
||||||
|
- **Tag filter chips on Explore**: click a popular tag to narrow results instead of typing it.
|
||||||
|
|
||||||
|
## 0.14.0 — 2026-07-13 21:47
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Change your username** in Settings — previously auto-assigned at signup with no way to change it.
|
||||||
|
|
||||||
|
## 0.13.1 — 2026-07-13 17:25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- People search never found anyone, for anyone — there was no signup or settings flow that ever set a username, and search (along with profiles and follow) requires one. Every account now gets one automatically on signup; existing accounts were backfilled.
|
||||||
|
|
||||||
|
## 0.13.0 — 2026-07-13 15:37
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Explore search now finds people and recipes together**: one search bar, one query — no more separate, hard-to-find people tab.
|
||||||
|
|
||||||
|
## 0.12.1 — 2026-07-13 13:25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Changelog entries showed literal `**bold**` markup instead of rendering it.
|
||||||
|
|
||||||
|
## 0.12.0 — 2026-07-13 12:52
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Public shopping list links can now allow editing**: turn on "Allow editing" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.
|
||||||
|
|
||||||
|
## 0.11.0 — 2026-07-13 12:27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Live updates on shared shopping lists**: items checked, added, recategorized, or reordered by a collaborator now appear automatically, without a manual refresh.
|
||||||
|
|
||||||
|
## 0.10.0 — 2026-07-13 11:55
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Bulk tag editing**: add or remove tags across several selected recipes at once.
|
||||||
|
- **Bulk export**: export several selected recipes to a single Markdown file.
|
||||||
|
- **Move recipes between collections**: select recipes inside a collection to move them to another collection or remove them from this one.
|
||||||
|
|
||||||
|
## 0.9.6 — 2026-07-13 09:31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Rate limiting on public routes**: sign-in/sign-up now throttle after 3 attempts per 10s per IP; public recipe/shopping-list share links (`/r/`, `/s/`) throttle after 60 requests/min per IP.
|
||||||
|
|
||||||
|
## 0.9.5 — 2026-07-13 09:20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Explore page's "Recipe ideas" input got squeezed to a couple of visible characters on mobile — buttons now stay icon-only below `sm:`.
|
||||||
|
- The per-recipe chat button could overlap the private-notes save button on short recipe pages.
|
||||||
|
|
||||||
|
## 0.9.4 — 2026-07-13 09:03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- "New recipe" wrapped to its own line on mobile (especially French) instead of sharing a row with Generate/Import — icon-only below that breakpoint now.
|
||||||
|
- The view-toggle/Select row floated flush right with a large dead gap on the left — now left-aligned outside select mode.
|
||||||
|
|
||||||
|
## 0.9.3 — 2026-07-13 08:38
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Recipes page wasted a lot of vertical space above the recipe list on mobile — Sort/Filter now sit alongside Search on one row instead of forcing an extra one.
|
||||||
|
|
||||||
|
## 0.9.2 — 2026-07-13 07:14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Explore page's "Recipe ideas" banner buttons clashed with its violet gradient background — restyled to match.
|
||||||
|
|
||||||
|
## 0.9.1 — 2026-07-13 06:59
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Destructive confirm buttons (clear chat history, discard recipe changes) had unreadable black text on a red background — a missing CSS variable.
|
||||||
|
|
||||||
|
## 0.9.0 — 2026-07-12 23:28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Clear chat history**: a trash-icon button in both chatbots clears that conversation's saved history.
|
||||||
|
- Chat history now auto-expires after 90 days (daily cleanup job).
|
||||||
|
|
||||||
|
## 0.8.0 — 2026-07-12 22:38
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Read-only API key scoping**: create keys that can only read (GET), not create/edit/delete anything.
|
||||||
|
- **AI chat history**: both chatbots now remember past conversations across sessions, and let you search across everything you've ever asked.
|
||||||
|
|
||||||
|
## 0.7.1 — 2026-07-12 21:36
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Cooking-assistant button on the homepage sat too high.
|
||||||
|
- The chatbots would answer honestly if asked what AI model they run on — now always identify as Epicure.
|
||||||
|
|
||||||
|
## 0.7.0 — 2026-07-12 19:36
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **General cooking-question chatbot** on the recipes homepage — ask technique/substitution/timing questions not tied to a specific recipe.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- API keys always showed "never used" — middleware was blocking every Bearer-key request before it ever reached the code that verifies the key and records usage.
|
||||||
|
- The webhooks documentation page had no way back to webhooks settings.
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-07-12 19:09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Granular push-notification settings**: turn individual categories on/off (follows, comments, replies, reactions, ratings, mentions, leftover reminders, shared shopping list updates) instead of all-or-nothing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The push-notification toggle in Settings always showed as off, even when notifications were genuinely still enabled — it never checked the actual browser subscription state.
|
||||||
|
- The API Reference page (`/docs`) was blank — Content-Security-Policy blocked the script it needs to render.
|
||||||
|
|
||||||
|
## 0.5.1 — 2026-07-12 19:05
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Recipe/review photo uploads: a stolen storage key from another user's public recipe or review could be submitted as your own, causing that user's photo to be deleted from storage when the recipe was later edited or deleted. Both routes now check the key was actually issued to you for that recipe.
|
||||||
|
- AI URL-import could be redirected to internal/private network addresses via a crafted 3xx response, and was separately vulnerable to DNS-rebinding between validation and fetch. Both the import and outgoing webhook delivery now resolve the host once and pin the connection to that address, re-validating on every redirect hop.
|
||||||
|
- Comment moderation and monthly AI/recipe/storage quota checks trusted a session field that can lag up to 5 minutes behind a role or tier change — both now re-check the current value in the database.
|
||||||
|
|
||||||
|
## 0.5.0 — 2026-07-12 18:37
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Push notifications for shared shopping lists** — get pinged when someone else checks off or adds items.
|
- **Push notifications for shared shopping lists** — get pinged when someone else checks off or adds items.
|
||||||
@@ -12,7 +655,7 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
|
|||||||
- Weekly meal-plan nutrition coverage compared a week's total intake against a daily goal (reading ~7x too high) — now compares a proper daily average, with a per-day breakdown and a flag for meals missing nutrition data.
|
- 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.
|
- Meal-plan week navigation could resolve the wrong Monday depending on server timezone.
|
||||||
|
|
||||||
## 0.4.0 — 2026-07-12
|
## 0.4.0 — 2026-07-12 16:13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Profile pictures**: upload a custom photo, or get a Gravatar-backed avatar automatically — no more generic initials by default.
|
- **Profile pictures**: upload a custom photo, or get a Gravatar-backed avatar automatically — no more generic initials by default.
|
||||||
@@ -22,7 +665,7 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Batch-cook recipes shifted left in compact view when they had no time set or a longer dish/time label.
|
- Batch-cook recipes shifted left in compact view when they had no time set or a longer dish/time label.
|
||||||
|
|
||||||
## 0.3.1 — 2026-07-12
|
## 0.3.1 — 2026-07-12 15:08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- A free-text "describe what you're after" field for batch-cooking generation, matching the standard recipe generator.
|
- A free-text "describe what you're after" field for batch-cooking generation, matching the standard recipe generator.
|
||||||
@@ -32,17 +675,17 @@ All notable changes to Epicure are documented here. This file is mirrored in-app
|
|||||||
- The bulk-select action bar on the recipe list could show a horizontal scrollbar on desktop even though there was room to grow.
|
- 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.
|
- Recipes imported from a URL never actually saved their source URL — the "Source: ..." link on the recipe page was dead code until now.
|
||||||
|
|
||||||
## 0.3.0 — 2026-07-12
|
## 0.3.0 — 2026-07-12 14:46
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **Manually create and edit batch-cook recipes**: the recipe form now has a batch-cook toggle, an editable dish list (storage/reheat guidance per dish), and per-step dish tagging — not just AI-generated ones anymore. Editing an existing batch-cook recipe no longer silently strips its dish data.
|
||||||
|
|
||||||
## 0.2.2 — 2026-07-12
|
## 0.2.2 — 2026-07-12 13:54
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- Recipe photo thumbnails always tried to load from `localhost:9000` in production, tripping CSP/mixed-content blocks in every browser — the public storage URL wasn't reaching client-side code.
|
||||||
|
|
||||||
## 0.2.1 — 2026-07-12
|
## 0.2.1 — 2026-07-12 13:21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Recipe photos were lost after saving, and multi-file uploads only kept the last file selected.
|
- Recipe photos were lost after saving, and multi-file uploads only kept the last file selected.
|
||||||
|
|||||||
@@ -44,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`.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -76,5 +76,6 @@ RUN apk add --no-cache curl
|
|||||||
COPY cron/crontab /etc/crontabs/root
|
COPY cron/crontab /etc/crontabs/root
|
||||||
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
|
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-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh
|
||||||
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh
|
COPY cron/run-chat-cleanup.sh /usr/local/bin/run-chat-cleanup.sh
|
||||||
|
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh /usr/local/bin/run-chat-cleanup.sh
|
||||||
CMD ["crond", "-f", "-l", "2"]
|
CMD ["crond", "-f", "-l", "2"]
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Epicure Feature Audit
|
||||||
|
|
||||||
|
Code-verified inventory of what exists in the app today, by domain. Written 2026-07-21 after a full read of the routes/schema/components listed under "key files" for each entry — not guessed from names. **Keep this file updated whenever a feature is added or changes what it does** (see standing rule below).
|
||||||
|
|
||||||
|
Status legend: **Exists** (fully working) · **Partial** (works but with a real limitation, noted) · **Missing** (confirmed absent by grep/read, not just "didn't find it").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Recipes & AI
|
||||||
|
|
||||||
|
**AI stack**: Vercel AI SDK, provider-agnostic (`apps/web/lib/ai/factory.ts`) — OpenAI, Anthropic, OpenRouter, Ollama (local). Key resolution order: user's own BYOK key → admin default per use-case → admin's raw provider key → env var. Structured outputs always `generateObject` + Zod; chat features use `generateText` with optional tool-calling.
|
||||||
|
|
||||||
|
| Feature | Status | Description | Key files |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
|
||||||
|
| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership | `apps/web/app/api/v1/recipes/[id]/fork/route.ts` |
|
||||||
|
| Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` |
|
||||||
|
| **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` |
|
||||||
|
| Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` |
|
||||||
|
| Generate from idea/title | Exists | Text-only generation from just a title | `apps/web/app/api/v1/ai/generate-from-idea` |
|
||||||
|
| Recipe ideas (browsing) | Exists | 6 idea cards from an optional prompt, not persisted | `apps/web/app/api/v1/ai/recipe-ideas` |
|
||||||
|
| Adapt (exclude ingredients / constraints) | Exists | Persists as new recipe + variation link, unless AI reports unchanged | `apps/web/app/api/v1/ai/adapt/[id]` |
|
||||||
|
| Suggest variations | Exists | Ideas only, not persisted; gated by tier feature flag `recipe_variations` | `apps/web/app/api/v1/ai/variations/[id]` |
|
||||||
|
| Meal / drink pairings | Exists | Gated by tier feature flags `meal_pairing` / `drink_pairing` | `apps/web/app/api/v1/ai/{pairings,drinks}/[id]` |
|
||||||
|
| Translate recipe | Exists | Saves as a new private recipe, keeps original quantities/units/timers | `apps/web/app/api/v1/ai/translate/[id]` |
|
||||||
|
| Batch-cook generator | Exists | One recipe, multiple dishes, per-dish fridge/freezer guidance | `apps/web/app/api/v1/ai/batch-cook/generate` |
|
||||||
|
| Nutrition estimation | Exists | AI-estimated, cached on the recipe row; author-triggered | `apps/web/app/api/v1/recipes/[id]/nutrition` |
|
||||||
|
| Scale servings | Exists | Model-driven, not naive linear math (handles "1 egg" etc.) | `apps/web/app/api/v1/ai/scale` |
|
||||||
|
| Substitute ingredient | Exists | | `apps/web/app/api/v1/ai/substitute` |
|
||||||
|
| Recipe chat / cooking chat | Exists | Recipe-grounded Q&A, and a general assistant with tool-calling (`createRecipe`, `addToShoppingList`) | `apps/web/app/api/v1/ai/{recipe-chat,cooking-chat}` |
|
||||||
|
| Themed meal generator | Exists | Multi-course meal into a collection | `apps/web/app/api/v1/ai/generate-meal` |
|
||||||
|
| Recipe media: photos | Exists | S3-compatible storage, counts against tier storage limit | `packages/db/src/schema/recipes.ts` (`recipePhotos`) |
|
||||||
|
| **Recipe video** | **Missing** | No video field/upload path anywhere in schema or components | — |
|
||||||
|
| Collections | Exists | Shared/collaborative (`collection_members`, viewer/editor roles), fork-a-collection, explore page, reorder | `apps/web/app/api/v1/collections/**` |
|
||||||
|
| Tags, difficulty, dietary labels | Exists | Dietary schema has 7 tags; search only exposes 4 of them as filters (`vegan/vegetarian/glutenFree/dairyFree`) | `packages/db/src/schema/recipes.ts` |
|
||||||
|
| Visibility (private/unlisted/public/followers) | Exists, one flagged gap | Enforced almost everywhere as public-vs-not-private; the `/r/[id]` anonymous share route's own code comment flags that `followers`-only recipes may be reachable without a follow-relationship check | `apps/web/app/r/[id]/page.tsx` |
|
||||||
|
| Print views | Exists | Recipe, collection, meal plan, shopping list, pantry all have print routes | `apps/web/app/print/**` |
|
||||||
|
| Markdown export | Exists | Single recipe, bulk (up to 100), meal plan | `apps/web/lib/markdown/**` |
|
||||||
|
| Cooking mode | Exists | Step timers (multiple parallel), **voice control** (Web Speech API, EN/FR commands), wake-lock, batch-cook step grouping | `apps/web/components/cooking-mode/cooking-mode.tsx` |
|
||||||
|
| Search | Exists | Title/description/ingredient/tag substring match + filters (difficulty, type, max time, dietary, exact tags) | `apps/web/app/api/v1/search` |
|
||||||
|
| "What can I cook" (pantry-matched) | Exists | Deterministic ingredient-overlap scorer against the user's own recipes, not AI | `apps/web/lib/pantry-match.ts`, `/recipes/can-cook` |
|
||||||
|
| Explore / trending | Exists | Purely favorite-count-driven (7-day window) + recency — no editorial curation | `apps/web/app/(app)/explore` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Meal planning, pantry, shopping, nutrition
|
||||||
|
|
||||||
|
| Feature | Status | Description | Key files |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Weekly calendar meal plan | Exists | Mon–Sun grid, manual CRUD + bulk-set-week | `apps/web/app/(app)/meal-plan`, `apps/web/app/api/v1/meal-plans/[weekStart]/**` |
|
||||||
|
| AI weekly meal-plan generator | Exists | Pantry-aware, dietary-aware, nudged by nutrition goals; one real recipe per entry | `apps/web/app/api/v1/ai/meal-plan/generate` |
|
||||||
|
| Meal plan sharing | Exists | `meal_plan_members` viewer/editor roles for collaborators, plus an anonymous read-only public link (`isPublic` flag, mirrors shopping lists' but view-only — no public-editable mode) at `/m/[id]`, with a QR code generated client-side for the link | `apps/web/lib/meal-plan-access.ts`, `apps/web/app/m/[id]/page.tsx`, `apps/web/components/meal-plan/share-meal-plan-button.tsx` |
|
||||||
|
| **Calendar export (.ics)** | **Exists** | Real ICS builder with per-meal-type wall-clock times | `apps/web/app/api/v1/meal-plans/[weekStart]/export/ics` |
|
||||||
|
| Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` |
|
||||||
|
| Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
|
||||||
|
| Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` |
|
||||||
|
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link mode (`isPublic`/`publicEditable`) | `packages/db/src/schema/meal-planning.ts` |
|
||||||
|
| List collaboration | Partial | Short-polling, not websocket/push — explicitly documented as such in code | `apps/web/components/meal-plan/shopping-list-view.tsx` |
|
||||||
|
| Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize | `apps/web/lib/grocery-categories.ts` |
|
||||||
|
| Grocery delivery integration | **Partial / stub** | Generic export payload works; Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
|
||||||
|
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
|
||||||
|
| Pantry manual CRUD | Exists | | `apps/web/app/api/v1/pantry/**` |
|
||||||
|
| Auto-deduct pantry on cook | Exists, one nuance | Name+unit matching; for batch-cook recipes, deduction fires once (not re-scaled) on the first dish marked cooked | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` |
|
||||||
|
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions | `apps/web/components/pantry/expiring-soon-suggestions.tsx` |
|
||||||
|
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
|
||||||
|
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
|
||||||
|
| Nutrition goals | Exists | | `apps/web/app/api/v1/users/me/nutrition-goals` |
|
||||||
|
| Nutrition diary | Exists | Single-day diary view, plus a Trend tab (7/30/90-day calorie line chart + daily macro averages) — the same endpoint switches modes via a `range` query param | `apps/web/app/api/v1/users/me/nutrition-diary`, `apps/web/components/nutrition/nutrition-trend.tsx` |
|
||||||
|
| USDA nutrition-facts database | Exists | `estimateNutrition` now looks up each ingredient in USDA FoodData Central (SR Legacy + Survey (FNDDS) datasets) after converting its quantity/unit to grams, sums the matches, and only falls back to AI estimation for ingredients that couldn't be converted (count-based units like "2 cloves") or had no USDA match — including every ingredient, transparently, when `USDA_API_KEY` isn't configured. Barcode scan (pantry) still only IDs product names via Open Food Facts, unrelated to this. | `apps/web/lib/usda.ts`, `apps/web/lib/ingredient-grams.ts`, `apps/web/lib/ai/features/estimate-nutrition.ts` |
|
||||||
|
| Batch cooking | Exists | Shared prep steps grouped by which dish they serve, per-dish fridge/freezer countdown independent of the whole-batch pantry deduction | `apps/web/components/recipe/batch-cook-*.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Social, messaging, notifications
|
||||||
|
|
||||||
|
| Feature | Status | Description | Key files |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Follow / unfollow | Exists | One-way, no approval step | `apps/web/app/api/v1/users/[username]/follow` |
|
||||||
|
| Block | Exists | Hides comments, blocks messaging both ways | `apps/web/lib/blocks.ts` |
|
||||||
|
| Private accounts | Exists | Hides recipe grid from non-followers; excluded from "for you" unless already followed | `apps/web/app/(app)/u/[username]/page.tsx` |
|
||||||
|
| Profile page | Exists | Cooking-history streak stats, "cooked it" photo gallery (owner-visible) | `apps/web/app/(app)/u/[username]` |
|
||||||
|
| Ratings/reviews, comments (1-level threaded), reactions, @mentions | Exists | | `packages/db/src/schema/social.ts` |
|
||||||
|
| Favorites | Exists | Drives "for you" ranking and trending | `apps/web/app/api/v1/recipes/[id]/favorite` |
|
||||||
|
| Direct messaging | Exists, 1:1 by design | No group messaging (deliberate — unique-pair constraint at schema level) | `packages/db/src/schema/messaging.ts` |
|
||||||
|
| Read receipts | Partial | Thread-level `lastReadAt` only — no per-message delivery/seen state | `packages/db/src/schema/messaging.ts` |
|
||||||
|
| Following feed | Exists | | `apps/web/app/api/v1/feed` |
|
||||||
|
| "For You" personalized feed | Exists | Tag/dietary preference profile from favorites + high ratings | `apps/web/lib/for-you-ranking.ts` |
|
||||||
|
| Trending feed / explore | Exists | Favorite-count driven, no editorial curation, **no separate "featured" surface** | `apps/web/app/api/v1/feed/trending` |
|
||||||
|
| Notification categories | Exists | 8 categories, independent push + email toggle each, plus email-only weekly digest | `packages/db/src/schema/users.ts` (`userNotificationPrefs`) |
|
||||||
|
| Push notifications | Exists | Web Push + VAPID, end-to-end, real trigger call sites (not just plumbing) | `apps/web/lib/push.ts` |
|
||||||
|
| Push display + click-through | Exists | `sw.js` previously had no `push` listener at all — push messages never displayed. Now has both `push` (calls `showNotification`) and `notificationclick` (focuses an existing tab on the payload's `url`, or opens one) | `apps/web/public/sw.js` |
|
||||||
|
| Email notifications + weekly digest cron | Exists | | `apps/web/lib/notifications.ts`, `apps/web/app/api/internal/cron/weekly-digest` |
|
||||||
|
| Reports/moderation | Exists | Report → admin webhook → admin queue → resolve (reviewed/dismissed) + audit log | `apps/web/app/api/v1/reports`, `apps/web/app/api/v1/admin/reports` |
|
||||||
|
| Moderator role | Exists | Moderators get `/admin/reports` (view/resolve) and `/admin/recipes` (view + unpublish takedown); every other `/admin/*` page redirects them out via `requireFullAdminPage()`. Comment deletion already allowed moderator before this. | `apps/web/lib/require-admin-page.ts`, `apps/web/app/admin/layout.tsx`, `apps/web/app/api/v1/admin/recipes/[id]/route.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin, billing, ops, integrations
|
||||||
|
|
||||||
|
| Feature | Status | Description | Key files |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tiers (free/pro/family) + limits | Exists | Recipe count, AI calls/month, storage, public-recipe count; TOCTOU-safe via advisory lock + transaction | `apps/web/lib/tiers.ts` |
|
||||||
|
| Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` |
|
||||||
|
| **Stripe checkout session creation** | **Missing** | No code anywhere creates a Checkout Session or exposes an "Upgrade" flow — the webhook consumer exists but nothing produces the event it consumes | — |
|
||||||
|
| **Self-serve billing portal** | **Missing** | No Stripe customer portal; tier changes today only happen via direct admin edit at `/admin/users/[id]` | — |
|
||||||
|
| Admin dashboard | Exists | 13 sections: overview, insights/analytics, users, invites, recipe moderation, reports, support, tiers, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/**` |
|
||||||
|
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
|
||||||
|
| **Developer permission** (new, 2026-07-22) | Exists | A fourth, orthogonal access dimension — `users.isDeveloper`, admin-toggled in `admin/users/[id]`, gates user webhooks + self-serve API keys + BYOK. Previously all three had zero gating (any logged-in user, any tier). Existing users with a webhook/API key/BYOK key were grandfathered in by the migration. Deliberately a single `hasDeveloperAccess()` function so a future subscription-tier auto-grant is a one-line change, not a redesign. | `apps/web/lib/permissions.ts`, `apps/web/lib/api-auth.ts` (`requireDeveloper`) |
|
||||||
|
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
|
||||||
|
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
|
||||||
|
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
|
||||||
|
| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest, requires developer access | `apps/web/app/api/v1/api-keys` |
|
||||||
|
| Public OpenAPI spec + docs page | Exists | Scalar-rendered at `/docs`, spec at `/api/v1/openapi.json` | `apps/web/lib/openapi.ts` |
|
||||||
|
| i18n | Partial | Only `en` and `fr` — no other locales | `apps/web/messages/*.json` |
|
||||||
|
| 2FA | Exists | TOTP + backup codes + OTP, passwordless-account-compatible | Better Auth `twoFactor` plugin |
|
||||||
|
| Social login | Exists | Google always, GitHub/Discord/generic OIDC if configured | `apps/web/lib/auth/server.ts` |
|
||||||
|
| Rate limiting | Exists | Two layers: Better Auth's Redis-backed built-in limiter (tighter on 2FA verify endpoints) + a general sliding-window limiter for AI/API routes | `apps/web/lib/rate-limit.ts` |
|
||||||
|
| Invite-gated signup | Exists | Signups can be globally disabled, invite-only fallback; first-ever user auto-promoted to admin | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. PWA, offline, mobile
|
||||||
|
|
||||||
|
| Feature | Status | Description | Key files |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Installability | Exists | Manifest + SVG icons (no raster/PNG fallback), Chromium `beforeinstallprompt` button, **iOS Safari gets a static "tap Share" instructional banner** (no programmatic install API exists on Safari — Apple restriction, not fixable) | `apps/web/app/manifest.ts`, `apps/web/components/pwa/install-prompt.tsx` |
|
||||||
|
| Service worker | Exists | Cache-first for `/cook` pages, network-first-with-cache-populate elsewhere, `/api/*` bypassed | `apps/web/public/sw.js` |
|
||||||
|
| Background Sync | Exists | Chromium via native Background Sync API; Safari/Firefox (no support at all) via an `online`-event fallback reading the same IndexedDB queue | `apps/web/lib/offline-queue.ts`, `apps/web/public/sw.js` |
|
||||||
|
| Offline save-for-later | Exists | Per-recipe explicit pin + IndexedDB-tracked list shown on the `/offline` fallback page | `apps/web/lib/offline-db.ts`, `apps/web/components/recipe/save-offline-button.tsx` |
|
||||||
|
| Offline scope | Partial | Recipe viewing + one write path (mark-cooked). **Shopping lists and meal plan have no offline/queue coverage** | — |
|
||||||
|
| Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` |
|
||||||
|
| Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` |
|
||||||
|
| Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` |
|
||||||
|
| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target` → `/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` |
|
||||||
|
| Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — |
|
||||||
|
| Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real gaps (confirmed absent, worth considering)
|
||||||
|
|
||||||
|
Ranked roughly by likely value:
|
||||||
|
|
||||||
|
1. **Stripe checkout + billing portal** — the webhook consumer is production-ready but nothing produces the checkout event or lets a user self-serve upgrade/cancel/view invoices. Biggest gap if monetizing seriously.
|
||||||
|
2. **Recipe video** — no support at all.
|
||||||
|
3. ~~Nutrition trend/history view~~ — closed 2026-07-22 (7/30/90-day calorie chart + macro averages).
|
||||||
|
4. ~~USDA/nutrition-database lookup~~ — closed 2026-07-22 (per-ingredient, gram-convertible ingredients only; AI fills the rest).
|
||||||
|
5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
|
||||||
|
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
|
||||||
|
7. ~~Anonymous public link for meal plans~~ — closed 2026-07-21 (read-only, with a QR code).
|
||||||
|
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live).
|
||||||
|
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
|
||||||
|
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
|
||||||
|
|
||||||
|
## Deliberate design choices (not gaps)
|
||||||
|
- Direct messaging is 1:1 only — enforced at the schema level, not a missing feature.
|
||||||
|
- Feature toggles (`userFeaturePrefs`) are cosmetic nav-hiding only, never access control — separate from tier-based feature flags.
|
||||||
|
- No native app — PWA-only is the stated architecture, covered well by the offline/install/push work already shipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standing rule
|
||||||
|
|
||||||
|
**Every time a feature ships, update this file in the same change** — add/update its row, move it out of "Real gaps" if it closes one. Bug fixes to existing features don't need an entry unless they change what the feature does.
|
||||||
-127
@@ -1,127 +0,0 @@
|
|||||||
# Audit fixes handoff (2026-07-09) — for next session (Sonnet 5)
|
|
||||||
|
|
||||||
A full 4-agent audit (bugs / UI-UX / backend consistency / feature gaps) was run, then fixes were fanned out to parallel agents. Session token limit killed 3 fix agents mid-flight. This file records: what's DONE, what's PARTIAL/UNVERIFIED, what's NOT STARTED. Nothing is committed. (Prior resolved backlog lives in TODO.md — don't confuse the two.)
|
|
||||||
|
|
||||||
**First step for next session:** `pnpm typecheck && pnpm lint`, then verify the PARTIAL items below by reading `git diff` before doing anything else.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## âś… DONE (in working tree, uncommitted, reported complete by agents)
|
|
||||||
|
|
||||||
### AI tier-quota bypass (critical — money leak)
|
|
||||||
- `apps/web/app/api/v1/ai/recipe-ideas/route.ts` — `generateObject` wrapped in `withAiQuota`.
|
|
||||||
- `apps/web/app/api/v1/ai/recipe-chat/route.ts` — `generateText` wrapped in `withAiQuota` (also maps provider errors).
|
|
||||||
- `apps/web/app/api/v1/ai/meal-plan/generate/route.ts` — `withAiQuota` added; recipe tier limit now charged per inserted draft recipe via `checkAndIncrementTierLimit(..., "recipe")` loop with refund (`incrementUsage(userId, "recipe", -charged)`) on `TierLimitError`; returns same 403 shape as `recipes/route.ts` POST.
|
|
||||||
- `apps/web/app/api/v1/recipes/[id]/nutrition/route.ts` — POST now author-only (was: any authed user could burn paid AI calls on public recipes), rate-limited (`rl:ai:${userId}`, 10/60), wrapped in `withAiQuota`. GET unchanged.
|
|
||||||
|
|
||||||
### Webhooks (security + drift)
|
|
||||||
- `apps/web/lib/webhooks.ts` — SSRF fix: `dispatchWebhook` re-runs `validateWebhookUrl` per delivery (DNS re-resolution, private/link-local/loopback/metadata ranges rejected); `redirect: "manual"` on fetch (3xx = failed delivery); validation failure recorded as delivery failure (statusCode 0). Exported `WEBHOOK_EVENTS` as const array; `WebhookEvent` type derived from it.
|
|
||||||
- `apps/web/app/api/v1/webhooks/route.ts` + `[id]/route.ts` — drifted local `VALID_EVENTS` (4 events) removed; both use `z.enum(WEBHOOK_EVENTS)` (7 events). Users can now subscribe to `meal_plan.updated`, `shopping_list.completed`, `comment.added`.
|
|
||||||
- Leftover: `recipe.published` subscribable but never dispatched. Either dispatch on private→public transition in `recipes/[id]/route.ts` PUT, or drop the event.
|
|
||||||
|
|
||||||
### DB schema + migration (generated, NOT applied)
|
|
||||||
- `packages/db/src/schema/recipes.ts` — indexes on `recipe_ingredients.recipe_id`, `recipe_steps.recipe_id`.
|
|
||||||
- `packages/db/src/schema/meal-planning.ts` — indexes on `meal_plans.user_id`, `meal_plan_entries.meal_plan_id`, `pantry_items.user_id`; UNIQUE `(user_id, week_start)` on meal_plans; UNIQUE `(meal_plan_id, day, meal_type)` on meal_plan_entries (verified intentional: entries route delete-then-insert + meal-planner.tsx `.find()` = one entry per slot).
|
|
||||||
- `packages/db/src/schema/social.ts` — `comments.parentId` now self-FK with `onDelete: "cascade"` (AnyPgColumn pattern).
|
|
||||||
- Migration generated: `packages/db/src/migrations/0023_medical_dark_beast.sql` (1 FK + 5 indexes + 2 unique indexes).
|
|
||||||
- ⚠️ **Before `pnpm db:migrate`, clean existing data or migration fails:**
|
|
||||||
1. Dangling parents: `UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments);`
|
|
||||||
2. Duplicate `(user_id, week_start)` meal_plans → merge/delete dupes.
|
|
||||||
3. Duplicate `(meal_plan_id, day, meal_type)` entries → keep newest, delete rest.
|
|
||||||
|
|
||||||
### Recipe route transactions
|
|
||||||
- `apps/web/app/api/v1/recipes/[id]/route.ts` PUT — snapshot/version-bump moved after Zod validation, inside the update transaction (invalid body no longer creates phantom snapshots).
|
|
||||||
- `apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts` — pre-restore snapshot moved inside the restore transaction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ PARTIAL / UNVERIFIED — agent died mid-work, verify diffs first
|
|
||||||
|
|
||||||
1. **`recipes/[id]/route.ts` DELETE — S3 object cleanup.** Agent died MID-EDIT here ("Now bug B — DELETE with S3 cleanup"). File is modified — check whether DELETE now collects photo storage keys and calls `deleteObject` from `apps/web/lib/storage.ts` (which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (check `packages/db/src/schema/recipes.ts` for key vs full-URL columns), `deleteObject` per key in try/catch — storage failure must NOT fail the API response.
|
|
||||||
2. **Optimistic buttons — all 4 files edited, agent died before reporting. Review each diff:**
|
|
||||||
- `apps/web/components/social/favorite-button.tsx` — should be: optimistic flip, rollback + error toast (was: await-then-flip, silent fail).
|
|
||||||
- `apps/web/components/collections/collection-star-button.tsx` — same pattern.
|
|
||||||
- `apps/web/components/social/follow-button.tsx` — optimistic flip, keep existing error toast.
|
|
||||||
- `apps/web/components/meal-plan/shopping-list-view.tsx` — toggleItem: keep optimistic update, ADD rollback on `!res.ok`/throw with functional setState (rollback must not clobber other items' newer toggles).
|
|
||||||
- If toasts reference new i18n keys: neither `apps/web/messages/en.json` nor `fr.json` was modified — add keys or the UI breaks.
|
|
||||||
3. **`apps/web/components/shared/skeletons.tsx` — created, but ZERO loading.tsx/error.tsx/not-found.tsx files exist yet.** Agent died after the shared building blocks. Verify the file, then do item 5 below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❌ NOT STARTED — Wave 1 remainder
|
|
||||||
|
|
||||||
4. **Upload presign size + storage quota** (`apps/web/app/api/v1/upload/presign/route.ts`, `apps/web/lib/storage.ts`): validates content-type only, no size limit; `"storage"` LimitKey in `lib/tiers.ts` is dead code. Fix: require `fileSize` (bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and add `fileSize` there.
|
|
||||||
5. **Route-level UI states** (reuse `components/shared/skeletons.tsx`):
|
|
||||||
- `app/(app)/error.tsx` ("use client", reset() button), `app/(app)/not-found.tsx`, `app/(app)/loading.tsx`
|
|
||||||
- Per-route loading.tsx mirroring real layouts: recipes, feed, collections, meal-plan, recipes/[id], u/[username], search, explore, shopping-lists, pantry
|
|
||||||
- `app/admin/error.tsx` + `app/admin/loading.tsx`
|
|
||||||
- Root `app/error.tsx` + `app/not-found.tsx` (dependency-light; may render outside providers)
|
|
||||||
|
|
||||||
## ❌ NOT STARTED — Wave 2 (planned batches, disjoint file sets)
|
|
||||||
|
|
||||||
6. **Auth hardening:**
|
|
||||||
- No `middleware.ts` exists (CLAUDE.md claims otherwise). Add root middleware guarding `(app)` + `admin` (Better Auth session cookie). Unguarded pages today: `/recipes/new`, `/explore`, `/search`, `/settings/webhooks/docs`.
|
|
||||||
- `lib/api-auth.ts:76` — rate limit only on API-key branch of `requireSessionOrApiKey`; cookie-session branch (~103) skips it. Apply to both.
|
|
||||||
- `lib/tiers.ts:41` — `if (!tierDef) return;` = unknown/custom tier gets NO limits. Deny instead (or fall back to free limits).
|
|
||||||
- Admin role staleness: `lib/api-auth.ts:19` trusts `session.user.role` (5-min cookieCache, `auth/server.ts:78-81`). Make `requireAdmin` query DB role.
|
|
||||||
- `app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118` — self-leave unreachable: plan lookup scoped to owner, members get 404, `isSelf` branch dead. Lookup must also match membership.
|
|
||||||
- `app/api/v1/conversations/[id]/messages/route.ts:26-31` — `asc + limit(200)`: conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); update `components/social/message-thread.tsx`. Also add `aria-label` to its icon-only send button (~112).
|
|
||||||
- `lib/rate-limit.ts:23` — off-by-one: allows limit+1 requests.
|
|
||||||
- `app/api/webhooks/stripe/route.ts` — no event-ID dedup (replay within tolerance re-runs tier update).
|
|
||||||
7. **Pagination:**
|
|
||||||
- `app/(app)/recipes/page.tsx:47` — findMany with NO limit, loads every recipe.
|
|
||||||
- `app/(app)/feed/page.tsx:46` — cap 40, no pagination; trending/for-you single batch. Also `components/feed/feed-page-content.tsx:86,112` — `.catch(() => {})` renders network failure as empty state; add error branch + retry.
|
|
||||||
- `app/(app)/u/[username]/page.tsx:55` — 24 recipes then dead end.
|
|
||||||
- `app/api/v1/recipes/[id]/comments/route.ts:28-43` — unbounded; paginate + update `components/social/comments-section.tsx` (also no error branch ~218; comment delete ~134 has no confirm).
|
|
||||||
- `app/api/v1/pantry/route.ts:17-22` — unbounded.
|
|
||||||
- `app/admin/users/page.tsx:28,36` — limit(100) no next-page; table lacks `overflow-x-auto`. Same for other admin tables.
|
|
||||||
- Reuse `total/limit/offset` pattern from `components/search/search-page-content.tsx:29-34`.
|
|
||||||
8. **Nav/theme/confirms:**
|
|
||||||
- `components/layout/nav.tsx:110` — `<AvatarImage src="" alt="" />` hardcoded; render session user image. Add "View profile" link to dropdown.
|
|
||||||
- Dark mode wired (`components/providers.tsx:20`, `.dark` palette) but NO toggle. Add Sun/Moon in nav dropdown (`next-themes` setTheme).
|
|
||||||
- Confirm consistency: comment delete (`comments-section.tsx:134`) + pantry delete (`pantry-manager.tsx:55`) fire instantly; `recipes-grid.tsx:296`, `block-button.tsx:21`, `invites-manager.tsx:49` use `window.confirm`. Standardize on AlertDialog (pattern: `delete-recipe-button.tsx:62`).
|
|
||||||
9. **Forms/a11y/images/URLs:**
|
|
||||||
- `components/recipe/recipe-form.tsx` — no unsaved-changes guard; only title validated, empty ingredients/steps silently dropped (~:132,149,158).
|
|
||||||
- Canonical URL split: cards → `/recipes/[id]` (`recipe-card.tsx:42`, `recipes-grid.tsx:218`) vs `/r/[id]` (`feed-page-content.tsx:60`, `u/[username]/page.tsx:151`, `search-page-content.tsx:50`). Use `/recipes/[id]` in-app, keep `/r/[id]` for public share.
|
|
||||||
- 4+ recipe-card implementations (recipe-card.tsx; GridCard/ListRow/CompactRow in recipes-grid.tsx; inline in feed-page-content.tsx:33 + search-page-content.tsx:42; tiles in u/[username]/page.tsx:149) — consolidate where cheap.
|
|
||||||
- a11y: `aria-label` on 28 `size="icon"` buttons; meaningful `alt` on recipe images (`recipes/[id]/page.tsx:378`, `can-cook-content.tsx:39`, `expiring-soon-suggestions.tsx:39`, `cooked-it-review.tsx:149,209`); `alt` on AvatarImage everywhere; color-only signalling on difficulty badges + pantry expiry (`pantry-manager.tsx:96`) — add icon/text.
|
|
||||||
- Zero `next/image` (12 raw `<img>`) — migrate grids/heroes, width/height set, remotePatterns for MinIO/S3 host in next.config.
|
|
||||||
10. **api-types/OpenAPI:**
|
|
||||||
- `packages/api-types` 100% orphaned — zero imports; every route inlines Zod. Wire recipes routes to it (align drift first) or delete the package + update CLAUDE.md.
|
|
||||||
- `apps/web/lib/openapi.ts` — ~20 of 90 routes documented; recipes list documented `page/limit` but real is `limit/offset` returning `{data, limit, offset}` (`recipes/route.ts:48-64`).
|
|
||||||
- Error shape: `ai-keys/route.ts:35` returns `{error: <flatten object>}` vs `{error: string}` elsewhere.
|
|
||||||
11. **Misc low:**
|
|
||||||
- `app/api/v1/search/route.ts:72` — escape `%`/`_` in ilike; `feed/trending/route.ts:6` — parseInt NaN reaches `.limit()`.
|
|
||||||
- `app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts:17` — body cast without Zod.
|
|
||||||
- `app/api/v1/meal-plans/[weekStart]/entries/route.ts:48` — `recipeId` unvalidated → FK violation 500 (sibling shared route validates; copy it).
|
|
||||||
- ~~Recipe usage counter monthly + never decremented on delete~~ — **decided**: keep as-is, `maxRecipes` means "creations per month," deleting a recipe does not free up quota.
|
|
||||||
- `lib/ai/resolve-user-key.ts:18-23,44-62` — BYOK decrypt failures silently fall back to platform key; surface error.
|
|
||||||
- API keys: no expiry/scopes; all of a user's keys share one rate bucket (`api-auth.ts:79`).
|
|
||||||
|
|
||||||
## Final phase
|
|
||||||
- Clean dev-DB dupes (see migration warning) → `pnpm db:migrate`.
|
|
||||||
- `pnpm typecheck && pnpm lint && pnpm build`, fix fallout.
|
|
||||||
- Smoke: favorite/star/follow, shopping toggle rollback (network off in devtools), AI 403 at quota, webhook to private IP rejected, recipe delete removes MinIO objects.
|
|
||||||
|
|
||||||
## New features backlog (NOT fixes — not yet greenlit)
|
|
||||||
"S" items mostly wire existing orphaned infra:
|
|
||||||
1. Push+email for ALL notification types (S) — `createNotification` writes rows; only comments route calls `sendPushNotification` (`lib/push.ts`).
|
|
||||||
2. Personal recipe notes UI (S) — `recipeNotes` table exists (`recipes.ts:94`), zero API/UI.
|
|
||||||
3. Cooking history page (S/M) — `cookingHistory` written by `/cooked`, never read. Profile tab: counts, last-cooked, streaks.
|
|
||||||
4. Notifications inbox page (S) — API + bell exist.
|
|
||||||
5. Pantry-aware shopping lists (S).
|
|
||||||
6. Unit conversion from `users.unitPref` (M).
|
|
||||||
7. Nutrition daily diary (M).
|
|
||||||
8. Weekly digest email (M, needs cron).
|
|
||||||
9. Recipe fork/clone via `recipeVariations` (S).
|
|
||||||
10. GDPR data export (S/M). 11. Pantry barcode/photo scan (M).
|
|
||||||
12. "Cooked it" photo gallery (M).
|
|
||||||
13. Meal-plan generation targeting nutrition goals (M).
|
|
||||||
|
|
||||||
## Gotchas (from project memory)
|
|
||||||
- Import drizzle operators (`eq, and, or, desc, sql, count`…) from `@epicure/db`, NEVER `drizzle-orm` directly in apps/web (dual-instance bug).
|
|
||||||
- shadcn Button: NO `asChild` prop — use `buttonVariants()` + `<Link>`.
|
|
||||||
- `session.user.tier` is `string` — cast `as "free" | "pro"` at call sites.
|
|
||||||
- `requireSessionOrApiKey` takes `NextRequest`; `requireSession` doesn't.
|
|
||||||
- Better Auth `changeEmail`/`changePassword`: call on `authClient` object, don't destructure.
|
|
||||||
- New user-facing strings go in BOTH `apps/web/messages/en.json` and `fr.json`.
|
|
||||||
@@ -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`).
|
|
||||||
@@ -2,13 +2,20 @@ 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, UtensilsCrossed } 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 { EmptyState } from "@/components/shared/empty-state";
|
||||||
@@ -26,16 +33,19 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
const m = getMessages((session.user as { locale?: string }).locale);
|
const 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">
|
||||||
@@ -43,40 +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>}
|
||||||
|
{col.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{col.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
|
{recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {m.recipe.visibility[col.visibility]}
|
||||||
</p>
|
</p>
|
||||||
|
{isOwner && col.notes && (
|
||||||
|
<div className="mt-2 flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground max-w-2xl">
|
||||||
|
<StickyNote className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<p className="whitespace-pre-wrap">{col.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<TooltipProvider>
|
||||||
{col.recipes.length > 0 && (
|
<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" }))}>
|
<>
|
||||||
<Printer className="h-4 w-4" />
|
<Tooltip>
|
||||||
{m.collections.exportPdf}
|
<TooltipTrigger render={
|
||||||
</Link>
|
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
<ExportMarkdownButton
|
<Printer className="h-4 w-4" />
|
||||||
markdown={collectionToMarkdown({
|
</Link>
|
||||||
name: col.name,
|
} />
|
||||||
description: col.description,
|
<TooltipContent>{m.collections.exportPdf}</TooltipContent>
|
||||||
recipes: col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : [])),
|
</Tooltip>
|
||||||
})}
|
<ExportMarkdownButton
|
||||||
filename={col.name}
|
markdown={collectionToMarkdown({
|
||||||
|
name: col.name,
|
||||||
|
description: col.description,
|
||||||
|
recipes: recipeList,
|
||||||
|
})}
|
||||||
|
filename={col.name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isOwner && <GenerateMealDialog collectionId={id} />}
|
||||||
|
{isOwner && <ShareCollectionButton collectionId={id} />}
|
||||||
|
{isOwner && (
|
||||||
|
<EditCollectionDialog
|
||||||
|
collectionId={id}
|
||||||
|
initialName={col.name}
|
||||||
|
initialDescription={col.description}
|
||||||
|
initialNotes={col.notes}
|
||||||
|
initialTags={col.tags}
|
||||||
|
initialVisibility={col.visibility}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
{isOwner && <DeleteCollectionDialog collectionId={id} />}
|
||||||
{isOwner && <ShareCollectionButton collectionId={id} />}
|
{col.visibility === "public" && (
|
||||||
{!isOwner && col.isPublic && (
|
<Tooltip>
|
||||||
<ForkCollectionButton collectionId={id} />
|
<TooltipTrigger render={
|
||||||
)}
|
<Link href={`/c/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
</div>
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
<TooltipContent>{m.recipe.viewPublicly}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
|
||||||
|
<ForkCollectionButton collectionId={id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{col.recipes.length === 0 ? (
|
{recipeList.length === 0 ? (
|
||||||
<EmptyState icon={UtensilsCrossed} title={m.collections.emptyCollection} compact />
|
<EmptyState icon={UtensilsCrossed} title={m.collections.emptyCollection} compact />
|
||||||
|
) : isOwner ? (
|
||||||
|
<CollectionRecipesGrid collectionId={id} recipes={recipeList} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}];
|
||||||
|
}),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,38 +13,61 @@ 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({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; tab?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { q, tab } = await searchParams;
|
const { q } = await searchParams;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const 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(
|
||||||
@@ -59,29 +84,48 @@ export default async function ExplorePage({
|
|||||||
|
|
||||||
// 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(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
|
.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),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<ExplorePageContent
|
<ExplorePageContent
|
||||||
trending={trending}
|
trending={trending}
|
||||||
recent={recent}
|
recent={recent}
|
||||||
initialQuery={q ?? ""}
|
initialQuery={q ?? ""}
|
||||||
initialTab={tab === "people" ? "people" : "recipes"}
|
popularTags={popularTags}
|
||||||
|
followedCount={followedCount}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +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, userFollows, eq } from "@epicure/db";
|
|
||||||
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));
|
|
||||||
|
|
||||||
return <FeedPageContent followedCount={followedRows.length} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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, userNutritionGoals, 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 { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
|
||||||
@@ -110,37 +111,67 @@ export default async function MealPlanPage({
|
|||||||
|
|
||||||
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} />
|
||||||
<NewShoppingListButton
|
<NewShoppingListButton
|
||||||
defaultWeekStart={weekStart}
|
defaultWeekStart={weekStart}
|
||||||
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
|
defaultName={formatMessage(msgs.mealPlan.shoppingListWeekName, { week: label })}
|
||||||
/>
|
/>
|
||||||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
<Tooltip>
|
||||||
<ShoppingCart className="h-4 w-4" />
|
<TooltipTrigger render={
|
||||||
{msgs.mealPlan.shoppingLists}
|
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.mealPlan.shoppingLists}>
|
||||||
</Link>
|
<ShoppingCart className="h-4 w-4" />
|
||||||
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
</Link>
|
||||||
<Printer className="h-4 w-4" />
|
} />
|
||||||
{msgs.common.print}
|
<TooltipContent>{msgs.mealPlan.shoppingLists}</TooltipContent>
|
||||||
</Link>
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={
|
||||||
|
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={msgs.common.print}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
<TooltipContent>{msgs.common.print}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<ExportMarkdownButton
|
<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} hasNutritionGoals={hasNutritionGoals} />
|
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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 { NutritionDiary } from "@/components/nutrition/nutrition-diary";
|
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";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -17,7 +19,18 @@ export default async function NutritionDiaryPage() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<NutritionDiary />
|
<Tabs defaultValue="diary" className="gap-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="diary">{m.nutritionDiary.tabDiary}</TabsTrigger>
|
||||||
|
<TabsTrigger value="trend">{m.nutritionDiary.tabTrend}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="diary">
|
||||||
|
<NutritionDiary />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="trend">
|
||||||
|
<NutritionTrend />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }> };
|
||||||
|
|
||||||
@@ -28,10 +29,13 @@ export default async function EditRecipePage({ params }: Params) {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -45,18 +49,37 @@ 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) => {
|
||||||
id: step.id,
|
// Show the largest unit that divides evenly into the stored seconds,
|
||||||
instruction: step.instruction,
|
// so editing a 90-minute braise shows "90 min" rather than "5400 sec".
|
||||||
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
|
const seconds = step.timerSeconds ?? 0;
|
||||||
appliesTo: step.appliesTo ?? [],
|
const timer = seconds > 0 && seconds % 3600 === 0
|
||||||
})),
|
? { value: String(seconds / 3600), unit: "hours" as const }
|
||||||
|
: seconds > 0 && seconds % 60 === 0
|
||||||
|
? { value: String(seconds / 60), unit: "minutes" as const }
|
||||||
|
: { value: seconds > 0 ? String(seconds) : "", unit: "seconds" as const };
|
||||||
|
return {
|
||||||
|
id: step.id,
|
||||||
|
instruction: step.instruction,
|
||||||
|
timerSeconds: timer.value,
|
||||||
|
timerUnit: timer.unit,
|
||||||
|
appliesTo: step.appliesTo ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
photos: recipe.photos.map((photo) => ({
|
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,
|
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) => ({
|
dishes: recipe.batchDishes.map((dish) => ({
|
||||||
id: dish.id,
|
id: dish.id,
|
||||||
name: dish.name,
|
name: dish.name,
|
||||||
@@ -71,7 +94,7 @@ export default async function EditRecipePage({ params }: Params) {
|
|||||||
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} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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";
|
||||||
@@ -12,6 +12,7 @@ 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 { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
|
||||||
@@ -19,7 +20,8 @@ import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
|||||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
||||||
import { and, eq, or, count, inArray, desc } from "@epicure/db";
|
import { and, eq, count, desc } from "@epicure/db";
|
||||||
|
import { visibleToViewer } from "@/lib/visibility";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { 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";
|
||||||
@@ -34,22 +36,31 @@ import { CommentsSection } from "@/components/social/comments-section";
|
|||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { cn, stripMarkdown } 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 { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
|
||||||
import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes";
|
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) {
|
||||||
@@ -62,11 +73,11 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
const DIETARY_LABELS = m.recipe.dietary;
|
const DIETARY_LABELS = m.recipe.dietary;
|
||||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
||||||
|
|
||||||
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
|
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags, featurePrefs] = await Promise.all([
|
||||||
db.query.recipes.findFirst({
|
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) },
|
||||||
@@ -92,10 +103,19 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
orderBy: desc(cookingHistory.cookedAt),
|
orderBy: desc(cookingHistory.cookedAt),
|
||||||
columns: { batchDishId: true, cookedAt: true },
|
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>();
|
const dishCookedAtMap = new Map<string, string>();
|
||||||
@@ -120,7 +140,10 @@ 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">
|
||||||
@@ -163,10 +186,10 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||||
{!recipe.isBatchCook && (
|
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
||||||
<>
|
<>
|
||||||
<MealPairingButton recipeId={id} />
|
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
|
||||||
<DrinkPairingButton recipeId={id} />
|
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{recipe.visibility === "public" && (
|
{recipe.visibility === "public" && (
|
||||||
@@ -218,9 +241,11 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
timerSeconds: s.timerSeconds,
|
timerSeconds: s.timerSeconds,
|
||||||
order: s.order,
|
order: s.order,
|
||||||
}))}
|
}))}
|
||||||
|
locked={locked.variations}
|
||||||
/>
|
/>
|
||||||
{!isOwner && <ForkRecipeButton recipeId={id} />}
|
<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({
|
||||||
@@ -332,6 +357,14 @@ 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 */}
|
||||||
@@ -386,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -406,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
|
||||||
@@ -478,7 +511,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
|
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
|
||||||
|
|
||||||
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
|
{featurePrefs.chatbots && <RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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 { getMessages } from "@/lib/i18n/server";
|
||||||
|
import { getFeaturePrefs } from "@/lib/feature-prefs";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -21,8 +23,26 @@ type SearchParams = Promise<{
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
page?: string;
|
page?: string;
|
||||||
batchCook?: 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),
|
||||||
@@ -38,31 +58,49 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
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 m = getMessages((session.user as { locale?: string }).locale);
|
||||||
|
const featurePrefs = await getFeaturePrefs(session.user.id);
|
||||||
|
|
||||||
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = await searchParams;
|
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
|
||||||
|
const sharedUrl = extractSharedUrl({ url, text });
|
||||||
const query = (q ?? "").trim().slice(0, 200);
|
const 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 page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
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 batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
|
||||||
|
const recipeTypeFilter = recipeType === "dish" || recipeType === "drink" ? recipeType : undefined;
|
||||||
|
|
||||||
|
// Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
|
||||||
|
const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
|
|
||||||
const where = and(
|
const where = and(
|
||||||
eq(recipes.authorId, session.user.id),
|
eq(recipes.authorId, session.user.id),
|
||||||
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
|
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([
|
const [userRecipes, totalRow] = await Promise.all([
|
||||||
@@ -97,13 +135,14 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
if (difficultyFilter) params.set("difficulty", difficultyFilter);
|
if (difficultyFilter) params.set("difficulty", difficultyFilter);
|
||||||
if (tagFilter) params.set("tag", tagFilter);
|
if (tagFilter) params.set("tag", tagFilter);
|
||||||
if (batchCookFilter) params.set("batchCook", batchCookFilter);
|
if (batchCookFilter) params.set("batchCook", batchCookFilter);
|
||||||
|
if (recipeTypeFilter) params.set("recipeType", recipeTypeFilter);
|
||||||
if (p > 1) params.set("page", String(p));
|
if (p > 1) params.set("page", String(p));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return qs ? `/recipes?${qs}` : "/recipes";
|
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">
|
<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>
|
<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">
|
<Link href="/recipes/favorites" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
|
||||||
@@ -118,9 +157,11 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
initialDifficulty={difficultyFilter ?? ""}
|
initialDifficulty={difficultyFilter ?? ""}
|
||||||
initialTag={tagFilter ?? ""}
|
initialTag={tagFilter ?? ""}
|
||||||
initialBatchCook={batchCookFilter ?? ""}
|
initialBatchCook={batchCookFilter ?? ""}
|
||||||
|
initialRecipeType={recipeTypeFilter ?? ""}
|
||||||
|
sharedUrl={sharedUrl}
|
||||||
/>
|
/>
|
||||||
<RecipesEmptyState query={query} count={total} />
|
<RecipesEmptyState query={query} count={total} />
|
||||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${page}`} recipes={recipesWithFavorites} />
|
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
@@ -139,6 +180,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{featurePrefs.chatbots && <CookingAssistantPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? ""} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, isDeveloper: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
|
||||||
|
db.query.tierDefinitions.findFirst({ where: eq(tierDefinitions.tier, dbUser?.tier ?? "free") }),
|
||||||
|
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
|
||||||
|
getRecipeCount(session.user.id),
|
||||||
|
getStorageUsedMb(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const asLimit = (n: number | undefined) => (n === undefined || n === UNLIMITED ? null : n);
|
||||||
|
|
||||||
|
const usageMetrics = [
|
||||||
|
{
|
||||||
|
label: m.settings.usage.aiCalls,
|
||||||
|
used: usage?.aiCallsUsed ?? 0,
|
||||||
|
limit: asLimit(tierDef?.aiCallsPerMonth),
|
||||||
|
unlimitedLabel: m.settings.usage.unlimited,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.settings.usage.recipes,
|
||||||
|
used: recipeCount,
|
||||||
|
limit: asLimit(tierDef?.maxRecipes),
|
||||||
|
unlimitedLabel: m.settings.usage.unlimited,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.settings.usage.storage,
|
||||||
|
used: storageUsedMb,
|
||||||
|
limit: asLimit(tierDef?.storageMb),
|
||||||
|
unit: " MB",
|
||||||
|
unlimitedLabel: m.settings.usage.unlimited,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
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>
|
||||||
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
|
{dbUser?.isDeveloper ? (
|
||||||
|
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
|
||||||
|
) : (
|
||||||
|
<DeveloperLockedNotice message={m.settings.developerLockedNotice} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border p-6 space-y-4">
|
<section className="rounded-xl border p-6 space-y-4">
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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 { getMessages } from "@/lib/i18n/server";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -14,15 +15,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 }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
|
||||||
.select({
|
|
||||||
id: apiKeys.id,
|
const keys = dbUser?.isDeveloper
|
||||||
name: apiKeys.name,
|
? await db
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
.select({
|
||||||
createdAt: apiKeys.createdAt,
|
id: apiKeys.id,
|
||||||
})
|
name: apiKeys.name,
|
||||||
.from(apiKeys)
|
scope: apiKeys.scope,
|
||||||
.where(eq(apiKeys.userId, session.user.id));
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.userId, session.user.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -41,14 +47,18 @@ export default async function ApiKeysPage() {
|
|||||||
{m.settings.apiKeysPage.docsLink}
|
{m.settings.apiKeysPage.docsLink}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<ApiKeysManager
|
{!dbUser?.isDeveloper && <DeveloperLockedNotice message={m.settings.developerLockedNotice} />}
|
||||||
initialKeys={keys.map((k) => ({
|
{dbUser?.isDeveloper && (
|
||||||
id: k.id,
|
<ApiKeysManager
|
||||||
name: k.name,
|
initialKeys={keys.map((k) => ({
|
||||||
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
|
id: k.id,
|
||||||
createdAt: k.createdAt.toISOString(),
|
name: k.name,
|
||||||
}))}
|
scope: k.scope,
|
||||||
/>
|
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
|
||||||
|
createdAt: k.createdAt.toISOString(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ export default async function SettingsLayout({ children }: { children: React.Rea
|
|||||||
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 }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
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 +19,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 isDeveloper={dbUser?.isDeveloper ?? false} />
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, isPrivate: true, hasCustomAvatar: true, avatarUrl: true },
|
columns: { bio: true, privateBio: true, isPrivate: true, hasCustomAvatar: true, avatarUrl: true, username: true, useGravatar: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +26,8 @@ export default async function SettingsPage() {
|
|||||||
privateBio: dbUser?.privateBio ?? null,
|
privateBio: dbUser?.privateBio ?? null,
|
||||||
isPrivate: dbUser?.isPrivate ?? false,
|
isPrivate: dbUser?.isPrivate ?? false,
|
||||||
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
|
hasCustomAvatar: dbUser?.hasCustomAvatar ?? false,
|
||||||
|
username: dbUser?.username ?? null,
|
||||||
|
useGravatar: dbUser?.useGravatar ?? false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 { getMessages } from "@/lib/i18n/server";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -12,16 +13,20 @@ 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 }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
|
||||||
.select({
|
|
||||||
id: webhooks.id,
|
const rows = dbUser?.isDeveloper
|
||||||
url: webhooks.url,
|
? await db
|
||||||
events: webhooks.events,
|
.select({
|
||||||
active: webhooks.active,
|
id: webhooks.id,
|
||||||
createdAt: webhooks.createdAt,
|
url: webhooks.url,
|
||||||
})
|
events: webhooks.events,
|
||||||
.from(webhooks)
|
active: webhooks.active,
|
||||||
.where(eq(webhooks.userId, session.user.id));
|
createdAt: webhooks.createdAt,
|
||||||
|
})
|
||||||
|
.from(webhooks)
|
||||||
|
.where(eq(webhooks.userId, session.user.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -32,15 +37,18 @@ export default async function WebhooksPage() {
|
|||||||
{m.settings.webhooksPage.description}
|
{m.settings.webhooksPage.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WebhooksManager
|
{!dbUser?.isDeveloper && <DeveloperLockedNotice message={m.settings.developerLockedNotice} />}
|
||||||
initialWebhooks={rows.map((w) => ({
|
{dbUser?.isDeveloper && (
|
||||||
id: w.id,
|
<WebhooksManager
|
||||||
url: w.url,
|
initialWebhooks={rows.map((w) => ({
|
||||||
events: w.events,
|
id: w.id,
|
||||||
active: w.active,
|
url: w.url,
|
||||||
createdAt: w.createdAt.toISOString(),
|
events: w.events,
|
||||||
}))}
|
active: w.active,
|
||||||
/>
|
createdAt: w.createdAt.toISOString(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopp
|
|||||||
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 { 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";
|
||||||
@@ -48,13 +49,20 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
|
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<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} initialIsPublic={list.isPublic} />}
|
{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} />
|
||||||
<Printer className="h-4 w-4" />
|
)}
|
||||||
{m.common.print}
|
<Tooltip>
|
||||||
</Link>
|
<TooltipTrigger render={
|
||||||
|
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))} aria-label={m.common.print}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
} />
|
||||||
|
<TooltipContent>{m.common.print}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<ExportMarkdownButton
|
<ExportMarkdownButton
|
||||||
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
|
markdown={shoppingListToMarkdown({ name: list.name, items: list.items })}
|
||||||
filename={list.name}
|
filename={list.name}
|
||||||
@@ -63,6 +71,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
|
<ShoppingListActionsMenu listId={id} name={list.name} redirectAfterDeleteTo="/shopping-lists" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<ShoppingListView
|
<ShoppingListView
|
||||||
listId={id}
|
listId={id}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ 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";
|
import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
@@ -247,9 +248,7 @@ export default async function UserProfilePage({ params, searchParams }: Params)
|
|||||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
|
<RecipeCoverPlaceholder recipe={recipe} />
|
||||||
🍽️
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ChangelogList } from "@/components/shared/changelog-list";
|
import { ChangelogList } from "@/components/shared/changelog-list";
|
||||||
import { APP_VERSION } from "@/lib/changelog";
|
import { APP_VERSION } from "@/lib/changelog";
|
||||||
|
import { requireFullAdminPage } from "@/lib/require-admin-page";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
export default function AdminChangelogPage() {
|
export default async function AdminChangelogPage() {
|
||||||
|
await requireFullAdminPage();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import { db, users, eq } from "@epicure/db";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History } from "lucide-react";
|
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { 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/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true },
|
||||||
{ href: "/admin/settings", label: "Settings", icon: Settings },
|
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true },
|
||||||
{ href: "/admin/changelog", label: "Changelog", icon: History },
|
{ 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">
|
||||||
@@ -33,7 +36,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<div className="flex items-center justify-between gap-2 p-4 border-b font-semibold">
|
<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"
|
||||||
@@ -44,7 +47,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}
|
||||||
|
|||||||
+68
-17
@@ -1,52 +1,103 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
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, History } from "lucide-react";
|
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
|
||||||
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
|
import { 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 = (
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<Card className={highlight ? "border-destructive/50" : undefined}>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||||
</CardHeader>
|
<Icon className={highlight ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground"} />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
<CardContent>
|
||||||
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
<div className={highlight ? "text-2xl font-bold text-destructive" : "text-2xl font-bold"}>
|
||||||
</CardContent>
|
{value.toLocaleString()}{unit ? ` ${unit}` : ""}
|
||||||
</Card>
|
</div>
|
||||||
))}
|
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={label}>
|
||||||
|
{href ? <Link href={href}>{card}</Link> : card}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { db, recipes, users, eq, desc, count } 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 = {};
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ 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;
|
||||||
|
|
||||||
@@ -60,12 +62,13 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
|
|||||||
<th className="px-4 py-3 text-left font-medium">Visibility</th>
|
<th className="px-4 py-3 text-left font-medium">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>
|
||||||
@@ -103,6 +106,9 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
|
|||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<UnpublishRecipeButton recipeId={recipe.id} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -98,17 +102,17 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
|
|||||||
<CardTitle className="text-base">Edit Role & Tier</CardTitle>
|
<CardTitle className="text-base">Edit Role & 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} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
|
<CardTitle className="text-base">Usage & 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} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 = {};
|
||||||
|
|
||||||
@@ -24,9 +25,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 AdminUsersPage({ searchParams }: PageProps) {
|
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||||
|
await requireFullAdminPage();
|
||||||
const { page: pageParam } = await searchParams;
|
const { page: pageParam } = await searchParams;
|
||||||
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
|||||||
@@ -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's involved, unlike the per-user webhooks under Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdminWebhooksManager initialWebhooks={webhooks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { db, chatMessages, lt } from "@epicure/db";
|
||||||
|
|
||||||
|
// Internal cron endpoint — triggered daily by a cron container (see
|
||||||
|
// compose.prod.yml / cron/crontab). Not part of the public API surface;
|
||||||
|
// protected by a shared secret rather than user auth.
|
||||||
|
//
|
||||||
|
// AI chat history (both the per-recipe chat and the general cooking
|
||||||
|
// assistant) has no size cap and no per-user retention setting — this just
|
||||||
|
// deletes anything past a fixed retention window so the table doesn't grow
|
||||||
|
// unbounded.
|
||||||
|
|
||||||
|
const RETENTION_DAYS = 90;
|
||||||
|
|
||||||
|
function isAuthorized(req: NextRequest): boolean {
|
||||||
|
const secret = process.env["CRON_SECRET"];
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const header = req.headers.get("authorization");
|
||||||
|
if (!header?.startsWith("Bearer ")) return false;
|
||||||
|
const provided = header.slice("Bearer ".length);
|
||||||
|
|
||||||
|
const a = Buffer.from(provided);
|
||||||
|
const b = Buffer.from(secret);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
const deleted = await db.delete(chatMessages).where(lt(chatMessages.createdAt, cutoff)).returning({ id: chatMessages.id });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, deleted: deleted.length });
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { sendEmail, notificationEmailHtml } from "@/lib/email";
|
|||||||
import { sendPushNotification } from "@/lib/push";
|
import { sendPushNotification } from "@/lib/push";
|
||||||
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||||
|
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
|
||||||
|
|
||||||
// Internal cron endpoint — triggered daily by a cron container (see
|
// Internal cron endpoint — triggered daily by a cron container (see
|
||||||
// compose.prod.yml / cron/crontab). Not part of the public API surface;
|
// compose.prod.yml / cron/crontab). Not part of the public API surface;
|
||||||
@@ -53,11 +54,14 @@ export async function POST(req: NextRequest) {
|
|||||||
const title = messages.notifications.pushTitle.leftoverExpiring;
|
const title = messages.notifications.pushTitle.leftoverExpiring;
|
||||||
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
|
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
|
||||||
const url = `/recipes/${log.recipe.id}`;
|
const url = `/recipes/${log.recipe.id}`;
|
||||||
|
const pushEnabled = await isNotificationCategoryEnabled(log.user.id, "leftoverExpiring");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
|
pushEnabled
|
||||||
console.error("[leftover-reminders] push failed", err);
|
? sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
|
||||||
}),
|
console.error("[leftover-reminders] push failed", err);
|
||||||
|
})
|
||||||
|
: Promise.resolve(),
|
||||||
log.user.email
|
log.user.email
|
||||||
? sendEmail({
|
? sendEmail({
|
||||||
to: log.user.email,
|
to: log.user.email,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ratings,
|
ratings,
|
||||||
userFollows,
|
userFollows,
|
||||||
favorites,
|
favorites,
|
||||||
|
userNotificationPrefs,
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
gte,
|
gte,
|
||||||
@@ -21,10 +22,11 @@ import { sendEmail, weeklyDigestHtml } from "@/lib/email";
|
|||||||
// schedule (see compose.prod.yml). Not part of the public API surface;
|
// schedule (see compose.prod.yml). Not part of the public API surface;
|
||||||
// protected by a shared secret rather than user auth.
|
// protected by a shared secret rather than user auth.
|
||||||
//
|
//
|
||||||
// Computes, for every user: new followers / new comments / new ratings on
|
// Computes, for every opted-in user: new followers / new comments / new
|
||||||
// their recipes in the last 7 days, plus a site-wide top-3 trending list, and
|
// ratings on their recipes in the last 7 days, plus a site-wide top-3
|
||||||
// emails a summary. Sends to all users (all users have a non-null email) —
|
// trending list, and emails a summary. Excludes users who turned off
|
||||||
// there's no per-user opt-out preference yet; out of scope for this pass.
|
// "Weekly digest" in Settings → Notifications (userNotificationPrefs.weeklyDigestEmail,
|
||||||
|
// default true — no row means opted in).
|
||||||
|
|
||||||
const CHUNK_SIZE = 20;
|
const CHUNK_SIZE = 20;
|
||||||
|
|
||||||
@@ -56,8 +58,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
|
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
|
||||||
|
|
||||||
const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([
|
const [allUsers, optedOutRows, followerRows, commentRows, ratingRows, trending] = await Promise.all([
|
||||||
db.select({ id: users.id, email: users.email }).from(users),
|
db.select({ id: users.id, email: users.email }).from(users),
|
||||||
|
db
|
||||||
|
.select({ userId: userNotificationPrefs.userId })
|
||||||
|
.from(userNotificationPrefs)
|
||||||
|
.where(eq(userNotificationPrefs.weeklyDigestEmail, false)),
|
||||||
db
|
db
|
||||||
.select({ userId: userFollows.followingId, n: count() })
|
.select({ userId: userFollows.followingId, n: count() })
|
||||||
.from(userFollows)
|
.from(userFollows)
|
||||||
@@ -92,6 +98,9 @@ export async function POST(req: NextRequest) {
|
|||||||
.limit(3),
|
.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 followerMap = new Map(followerRows.map((r) => [r.userId, r.n]));
|
||||||
const commentMap = new Map(commentRows.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 ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n]));
|
||||||
@@ -100,7 +109,7 @@ export async function POST(req: NextRequest) {
|
|||||||
let sent = 0;
|
let sent = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const batch of chunk(allUsers, CHUNK_SIZE)) {
|
for (const batch of chunk(recipients, CHUNK_SIZE)) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
batch.map((user) => {
|
batch.map((user) => {
|
||||||
const newFollowers = followerMap.get(user.id) ?? 0;
|
const newFollowers = followerMap.get(user.id) ?? 0;
|
||||||
@@ -127,5 +136,5 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed });
|
return NextResponse.json({ ok: true, totalUsers: allUsers.length, optedOut: optedOut.size, sent, failed });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { getFeatureFlagMatrix, setFeatureFlag, FEATURE_KEYS, TIERS } from "@/lib/feature-flags";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const matrix = await getFeatureFlagMatrix();
|
||||||
|
return NextResponse.json(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateBody = z.object({
|
||||||
|
featureKey: z.enum(FEATURE_KEYS as [string, ...string[]]),
|
||||||
|
tier: z.enum(TIERS as [string, ...string[]]),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const { session, response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = (await req.json()) as unknown;
|
||||||
|
const parsed = UpdateBody.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setFeatureFlag(
|
||||||
|
parsed.data.featureKey as (typeof FEATURE_KEYS)[number],
|
||||||
|
parsed.data.tier as (typeof TIERS)[number],
|
||||||
|
parsed.data.enabled,
|
||||||
|
session!.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,39 @@ 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",
|
||||||
];
|
];
|
||||||
|
|
||||||
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) })),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
const { role, tier } = body;
|
const { role, tier, isDeveloper } = 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,22 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro"; updatedAt: Date }> = {
|
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; isDeveloper: boolean; updatedAt: Date }> = {
|
||||||
updatedAt: new Date(),
|
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;
|
||||||
|
|
||||||
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 });
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
@@ -48,7 +52,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
action: "admin.user.update",
|
action: "admin.user.update",
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
metadata: JSON.stringify({ role, tier }),
|
metadata: JSON.stringify({ role, tier, isDeveloper }),
|
||||||
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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, adminWebhooks, adminWebhookDeliveries, eq, and } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { dispatchAdminWebhook, type AdminWebhookEvent } from "@/lib/admin-webhooks";
|
||||||
|
|
||||||
|
const Schema = z.object({ deliveryId: z.string().uuid() });
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const hook = await db.query.adminWebhooks.findFirst({ where: eq(adminWebhooks.id, id), columns: { id: true } });
|
||||||
|
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = Schema.safeParse(await req.json());
|
||||||
|
if (!body.success) return NextResponse.json({ error: "Validation error", issues: body.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
const delivery = await db.query.adminWebhookDeliveries.findFirst({
|
||||||
|
where: and(eq(adminWebhookDeliveries.id, body.data.deliveryId), eq(adminWebhookDeliveries.webhookId, id)),
|
||||||
|
});
|
||||||
|
if (!delivery) return NextResponse.json({ error: "Delivery not found" }, { status: 404 });
|
||||||
|
|
||||||
|
void dispatchAdminWebhook(delivery.event as AdminWebhookEvent, (delivery.payload ?? {}) as object);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, adminWebhooks, eq } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||||
|
import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks";
|
||||||
|
|
||||||
|
const UpdateWebhookBody = z.object({
|
||||||
|
url: z.string().min(1).max(2048).optional(),
|
||||||
|
events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await db.delete(adminWebhooks).where(eq(adminWebhooks.id, id));
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = UpdateWebhookBody.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.url) {
|
||||||
|
const ssrfError = await validateWebhookUrl(parsed.data.url);
|
||||||
|
if (ssrfError) return NextResponse.json({ error: ssrfError }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<{ url: string; events: string[]; active: boolean }> = {};
|
||||||
|
if (parsed.data.url !== undefined) updates.url = parsed.data.url;
|
||||||
|
if (parsed.data.events !== undefined) updates.events = parsed.data.events;
|
||||||
|
if (parsed.data.active !== undefined) updates.active = parsed.data.active;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(adminWebhooks).set(updates).where(eq(adminWebhooks.id, id));
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.select({
|
||||||
|
id: adminWebhooks.id,
|
||||||
|
url: adminWebhooks.url,
|
||||||
|
events: adminWebhooks.events,
|
||||||
|
active: adminWebhooks.active,
|
||||||
|
createdAt: adminWebhooks.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminWebhooks)
|
||||||
|
.where(eq(adminWebhooks.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return NextResponse.json(updated[0]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, adminWebhooks } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||||
|
import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks";
|
||||||
|
import { encrypt } from "@/lib/encrypt";
|
||||||
|
|
||||||
|
const CreateWebhookBody = z.object({
|
||||||
|
url: z.string().min(1).max(2048),
|
||||||
|
events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: adminWebhooks.id,
|
||||||
|
url: adminWebhooks.url,
|
||||||
|
events: adminWebhooks.events,
|
||||||
|
active: adminWebhooks.active,
|
||||||
|
createdAt: adminWebhooks.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminWebhooks);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = CreateWebhookBody.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssrfError = await validateWebhookUrl(parsed.data.url);
|
||||||
|
if (ssrfError) {
|
||||||
|
return NextResponse.json({ error: ssrfError }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(32).toString("hex");
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(adminWebhooks).values({
|
||||||
|
id,
|
||||||
|
createdById: session!.user.id,
|
||||||
|
url: parsed.data.url,
|
||||||
|
events: parsed.data.events,
|
||||||
|
secret: encrypt(secret),
|
||||||
|
active: true,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ id, url: parsed.data.url, events: parsed.data.events, secret, active: true, createdAt: now.toISOString() },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db, userAiKeys, eq, and } from "@epicure/db";
|
import { db, userAiKeys, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireDeveloper } from "@/lib/api-auth";
|
||||||
|
|
||||||
type Params = { params: Promise<{ provider: string }> };
|
type Params = { params: Promise<{ provider: string }> };
|
||||||
|
|
||||||
export async function DELETE(_req: Request, { params }: Params) {
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireDeveloper();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const { provider } = await params;
|
const { provider } = await params;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, userAiKeys, eq, and } from "@epicure/db";
|
import { db, userAiKeys, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireDeveloper } from "@/lib/api-auth";
|
||||||
import { encrypt } from "@/lib/encrypt";
|
import { encrypt } from "@/lib/encrypt";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const PostSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireDeveloper();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const keys = await db.query.userAiKeys.findMany({
|
const keys = await db.query.userAiKeys.findMany({
|
||||||
@@ -25,7 +25,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireDeveloper();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
|
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "@epicure/db";
|
import { and, eq } from "@epicure/db";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
||||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
import { isRecipeUnchanged } from "@/lib/recipe-diff";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
excludeIngredients: z.array(z.string()).default([]),
|
excludeIngredients: z.array(z.string()).default([]),
|
||||||
@@ -18,11 +21,14 @@ const Schema = z.object({
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${userId}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
const recipe = await db.query.recipes.findFirst({
|
const recipe = await db.query.recipes.findFirst({
|
||||||
where: and(eq(recipes.id, id)),
|
where: and(eq(recipes.id, id)),
|
||||||
@@ -50,7 +56,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
adaptRecipe(
|
adaptRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -64,58 +72,83 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
extraConstraints: parsed.data.extraConstraints,
|
extraConstraints: parsed.data.extraConstraints,
|
||||||
},
|
},
|
||||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||||
(session!.user as { locale?: string }).locale ?? "en"
|
locale
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const adapted = result.data;
|
const adapted = result.data;
|
||||||
|
|
||||||
|
// The AI sometimes "adapts" a recipe into something byte-for-byte
|
||||||
|
// identical (e.g. it decides the constraints are already satisfied) —
|
||||||
|
// don't persist a duplicate recipe/variation when nothing actually changed.
|
||||||
|
if (isRecipeUnchanged(recipe, adapted)) {
|
||||||
|
return NextResponse.json({ id: recipe.id, adaptationNotes: adapted.adaptationNotes, unchanged: true });
|
||||||
|
}
|
||||||
|
|
||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
await tx.insert(recipes).values({
|
await db.transaction(async (tx) => {
|
||||||
id: newId,
|
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
authorId: userId,
|
await tx.insert(recipes).values({
|
||||||
title: adapted.title,
|
id: newId,
|
||||||
description: adapted.description,
|
authorId: userId,
|
||||||
baseServings: adapted.baseServings,
|
title: adapted.title,
|
||||||
visibility: "private",
|
description: adapted.description,
|
||||||
difficulty: adapted.difficulty,
|
baseServings: adapted.baseServings,
|
||||||
prepMins: adapted.prepMins,
|
visibility: "private",
|
||||||
cookMins: adapted.cookMins,
|
language: locale,
|
||||||
dietaryTags: adapted.dietaryTags ?? {},
|
difficulty: adapted.difficulty,
|
||||||
aiGenerated: true,
|
prepMins: adapted.prepMins,
|
||||||
createdAt: now,
|
cookMins: adapted.cookMins,
|
||||||
updatedAt: now,
|
dietaryTags: adapted.dietaryTags ?? {},
|
||||||
|
aiGenerated: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adapted.ingredients.length > 0) {
|
||||||
|
await tx.insert(recipeIngredients).values(
|
||||||
|
adapted.ingredients.map((ing, i) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recipeId: newId,
|
||||||
|
rawName: ing.rawName,
|
||||||
|
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
||||||
|
unit: ing.unit,
|
||||||
|
note: ing.note,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adapted.steps.length > 0) {
|
||||||
|
await tx.insert(recipeSteps).values(
|
||||||
|
adapted.steps.map((step, i) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recipeId: newId,
|
||||||
|
instruction: step.instruction,
|
||||||
|
timerSeconds: step.timerSeconds,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(recipeVariations).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
parentRecipeId: recipe.id,
|
||||||
|
childRecipeId: newId,
|
||||||
|
description: adapted.adaptationNotes,
|
||||||
|
aiGenerated: true,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
if (adapted.ingredients.length > 0) {
|
if (err instanceof TierLimitError) {
|
||||||
await tx.insert(recipeIngredients).values(
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
adapted.ingredients.map((ing, i) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
recipeId: newId,
|
|
||||||
rawName: ing.rawName,
|
|
||||||
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
|
||||||
unit: ing.unit,
|
|
||||||
note: ing.note,
|
|
||||||
order: i,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 });
|
||||||
if (adapted.steps.length > 0) {
|
}
|
||||||
await tx.insert(recipeSteps).values(
|
|
||||||
adapted.steps.map((step, i) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
recipeId: newId,
|
|
||||||
instruction: step.instruction,
|
|
||||||
timerSeconds: step.timerSeconds,
|
|
||||||
order: i,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps, recipeBatchDishes } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps, recipeBatchDishes } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { generateBatchCook } from "@/lib/ai/features/generate-batch-cook";
|
import { generateBatchCook } from "@/lib/ai/features/generate-batch-cook";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
import { parseQuantity } from "@/lib/parse-quantity";
|
import { parseQuantity } from "@/lib/parse-quantity";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
dinners: z.number().int().min(0).max(6).default(4),
|
dinners: z.number().int().min(0).max(6).default(4),
|
||||||
@@ -19,7 +20,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -37,13 +38,13 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
const [configResult, privateBio] = await Promise.all([
|
const [configResult, privateBio] = await Promise.all([
|
||||||
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
|
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "text")),
|
||||||
getUserPrivateBio(userId),
|
getUserPrivateBio(userId),
|
||||||
]);
|
]);
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const config = configResult.data;
|
const config = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateBatchCook(
|
generateBatchCook(
|
||||||
{
|
{
|
||||||
dinners: parsed.data.dinners,
|
dinners: parsed.data.dinners,
|
||||||
@@ -55,14 +56,16 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
{ ...config, userContext: privateBio ?? undefined },
|
{ ...config, userContext: privateBio ?? undefined },
|
||||||
locale
|
locale
|
||||||
)
|
), { skipQuota: config.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const plan = result.data;
|
const plan = result.data;
|
||||||
|
|
||||||
const recipeId = crypto.randomUUID();
|
const recipeId = crypto.randomUUID();
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
await tx.insert(recipes).values({
|
await tx.insert(recipes).values({
|
||||||
id: recipeId,
|
id: recipeId,
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
@@ -117,7 +120,13 @@ export async function POST(req: NextRequest) {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: recipeId });
|
return NextResponse.json({ id: recipeId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, chatMessages, eq, and, isNull, desc, asc, ilike } from "@epicure/db";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
recipeId: z.string().uuid().optional(),
|
||||||
|
conversationId: z.string().uuid().optional(),
|
||||||
|
// "general" restricts to the homepage cooking assistant (recipeId null);
|
||||||
|
// omit both recipeId and scope to search across everything.
|
||||||
|
scope: z.enum(["general"]).optional(),
|
||||||
|
q: z.string().max(200).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = Schema.safeParse({
|
||||||
|
recipeId: searchParams.get("recipeId") ?? undefined,
|
||||||
|
conversationId: searchParams.get("conversationId") ?? undefined,
|
||||||
|
scope: searchParams.get("scope") ?? undefined,
|
||||||
|
q: searchParams.get("q") ?? undefined,
|
||||||
|
limit: searchParams.get("limit") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { recipeId, conversationId, scope, q, limit } = parsed.data;
|
||||||
|
|
||||||
|
const conditions = [eq(chatMessages.userId, session!.user.id)];
|
||||||
|
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
|
||||||
|
else if (conversationId) conditions.push(eq(chatMessages.conversationId, conversationId));
|
||||||
|
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
|
||||||
|
if (q?.trim()) conditions.push(ilike(chatMessages.content, `%${q.trim()}%`));
|
||||||
|
|
||||||
|
const rows = await db.query.chatMessages.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: q?.trim() ? [desc(chatMessages.createdAt)] : [asc(chatMessages.createdAt)],
|
||||||
|
limit,
|
||||||
|
with: { recipe: { columns: { id: true, title: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
role: r.role,
|
||||||
|
content: r.content,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
recipeId: r.recipeId,
|
||||||
|
recipeTitle: r.recipe?.title ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteSchema = z.object({
|
||||||
|
recipeId: z.string().uuid().optional(),
|
||||||
|
scope: z.enum(["general"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// No `q` here on purpose — clearing is "this whole conversation" (or
|
||||||
|
// everything), not "every message matching a search term".
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = DeleteSchema.safeParse({
|
||||||
|
recipeId: searchParams.get("recipeId") ?? undefined,
|
||||||
|
scope: searchParams.get("scope") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { recipeId, scope } = parsed.data;
|
||||||
|
|
||||||
|
const conditions = [eq(chatMessages.userId, session!.user.id)];
|
||||||
|
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
|
||||||
|
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
|
||||||
|
|
||||||
|
await db.delete(chatMessages).where(and(...conditions));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, aiConversations, eq, and } from "@epicure/db";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const RenameSchema = z.object({ title: z.string().max(100).nullable() });
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = RenameSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiConversations)
|
||||||
|
.set({ title: parsed.data.title?.trim() || null, updatedAt: new Date() })
|
||||||
|
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ id: updated.id, title: updated.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, { params }: Params) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(aiConversations)
|
||||||
|
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
|
||||||
|
.returning({ id: aiConversations.id });
|
||||||
|
|
||||||
|
if (!deleted) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, aiConversations, eq, desc } from "@epicure/db";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const rows = await db.query.aiConversations.findMany({
|
||||||
|
where: eq(aiConversations.userId, session!.user.id),
|
||||||
|
orderBy: desc(aiConversations.updatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(aiConversations).values({ id, userId: session!.user.id, title: null, createdAt: now, updatedAt: now });
|
||||||
|
|
||||||
|
return NextResponse.json({ id, title: null, createdAt: now.toISOString(), updatedAt: now.toISOString() }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { generateText, stepCountIs } from "ai";
|
||||||
|
import { db, chatMessages, aiConversations, sql } from "@epicure/db";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||||
|
import { resolveModel } from "@/lib/ai/factory";
|
||||||
|
import { isAiToolCallingEnabled } from "@/lib/site-settings";
|
||||||
|
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
||||||
|
import { createRecipeTool } from "@/lib/ai/tools/create-recipe-tool";
|
||||||
|
import { addToShoppingListTool } from "@/lib/ai/tools/add-to-shopping-list-tool";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
question: z.string().min(1).max(500),
|
||||||
|
conversationId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
|
// Forcing toolChoice to a specific tool makes some providers return the tool
|
||||||
|
// call with no accompanying text at all (their "forced function calling" is
|
||||||
|
// content-free by design) — the chat needs some reply, so fall back to this
|
||||||
|
// rather than showing an empty assistant bubble.
|
||||||
|
const DRAFT_FALLBACK_TEXT: Record<string, string> = {
|
||||||
|
en: "Here's a draft — check it below and confirm if it looks right.",
|
||||||
|
fr: "Voici un brouillon — vérifie-le ci-dessous et confirme si ça te convient.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Weaker/free-tier models sometimes ignore the system prompt's "call the
|
||||||
|
* tool, don't write it out" rule and answer with the recipe spelled out as
|
||||||
|
* a numbered/bulleted list instead. That's the exact shape the tool call
|
||||||
|
* itself would have captured structurally — a reliable enough signal to
|
||||||
|
* self-correct with a forced-tool retry rather than surfacing prose. */
|
||||||
|
function looksLikeUnstructuredRecipe(text: string): boolean {
|
||||||
|
const listLines = text.split("\n").filter((line) => /^\s*(\d+[.)]|[-*•])\s+\S/.test(line));
|
||||||
|
return listLines.length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The above only catches the model spelling the whole recipe out. A second,
|
||||||
|
// more common failure with weaker models: it gives a short reply that even
|
||||||
|
// *claims* to have drafted something ("here's a draft, check below") without
|
||||||
|
// ever calling the tool — so nothing renders below it. Rather than guess from
|
||||||
|
// that claim (fragile across models/phrasing), detect intent from the user's
|
||||||
|
// own message instead: same noun+verb pattern the system prompt itself uses
|
||||||
|
// as its createRecipe examples, checked in whichever of the two supported
|
||||||
|
// locales the request is in.
|
||||||
|
const RECIPE_INTENT: Record<string, { noun: RegExp; verb: RegExp }> = {
|
||||||
|
en: { noun: /\brecipe\b/i, verb: /\b(create|make|generate|give|write|save|invent)\b/i },
|
||||||
|
fr: { noun: /\brecettes?\b/i, verb: /\b(cr[ée]e?r?|fais|donne|[ée]cri[st]|note[rz]?|sauvegard\w*|enregistr\w*|invente\w*|g[ée]n[èe]re\w*)\b/i },
|
||||||
|
};
|
||||||
|
|
||||||
|
function looksLikeRecipeCreationRequest(question: string, locale: string): boolean {
|
||||||
|
const pattern = RECIPE_INTENT[locale] ?? RECIPE_INTENT["en"]!;
|
||||||
|
return pattern.noun.test(question) && pattern.verb.test(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = Schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const [configResult, privateBio, toolCallingEnabled] = await Promise.all([
|
||||||
|
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "chat")),
|
||||||
|
getUserPrivateBio(session!.user.id),
|
||||||
|
isAiToolCallingEnabled(),
|
||||||
|
]);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const aiConfig = configResult.data;
|
||||||
|
const model = resolveModel(aiConfig);
|
||||||
|
const bioContext = buildUserBioContext(privateBio);
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
|
const toolsInstructions = toolCallingEnabled
|
||||||
|
? `\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.`
|
||||||
|
: "";
|
||||||
|
const system = `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${toolsInstructions}${bioContext}`;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
|
generateText(
|
||||||
|
toolCallingEnabled
|
||||||
|
? {
|
||||||
|
model,
|
||||||
|
system,
|
||||||
|
prompt: parsed.data.question,
|
||||||
|
tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool },
|
||||||
|
stopWhen: stepCountIs(3),
|
||||||
|
}
|
||||||
|
: { model, system, prompt: parsed.data.question }
|
||||||
|
), { skipQuota: aiConfig.isByok }
|
||||||
|
);
|
||||||
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
let final = result.data;
|
||||||
|
|
||||||
|
// Weaker/free-tier models sometimes ignore the MUST-call-the-tool rule
|
||||||
|
// above — either by spelling the recipe out in prose, or (more common,
|
||||||
|
// and easy to miss) giving a short reply that *claims* a draft exists
|
||||||
|
// without ever calling the tool, so nothing actually renders below it.
|
||||||
|
// Catch both: the prose-list shape, or the user's own message clearly
|
||||||
|
// asking for a recipe in the first place. Rather than surface either
|
||||||
|
// failure, force the tool call on one extra pass — same system+prompt,
|
||||||
|
// just no longer letting the model opt out. Skipped entirely when tool
|
||||||
|
// calling is turned off admin-side (e.g. a local model that can't
|
||||||
|
// reliably call tools) — there's no tool to force in that case.
|
||||||
|
if (toolCallingEnabled && final.toolCalls.length === 0 && (looksLikeUnstructuredRecipe(final.text) || looksLikeRecipeCreationRequest(parsed.data.question, locale))) {
|
||||||
|
try {
|
||||||
|
const forced = await generateText({
|
||||||
|
model,
|
||||||
|
system,
|
||||||
|
prompt: parsed.data.question,
|
||||||
|
// Same tool set as the first pass (just forcing createRecipe via
|
||||||
|
// toolChoice below) — keeps the result type identical to `result.data`
|
||||||
|
// so `final` can hold either without a type mismatch.
|
||||||
|
tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool },
|
||||||
|
toolChoice: { type: "tool", toolName: "createRecipe" },
|
||||||
|
stopWhen: stepCountIs(1),
|
||||||
|
});
|
||||||
|
final = { ...forced, text: forced.text || (DRAFT_FALLBACK_TEXT[locale] ?? DRAFT_FALLBACK_TEXT["en"]!) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[cooking-chat] forced-tool retry failed, keeping prose answer", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { conversationId } = parsed.data;
|
||||||
|
const proposedRecipe = final.toolCalls.find((c) => c.toolName === "createRecipe")?.input;
|
||||||
|
const proposedShoppingList = final.toolCalls.find((c) => c.toolName === "addToShoppingList")?.input;
|
||||||
|
|
||||||
|
void db.insert(chatMessages).values([
|
||||||
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question },
|
||||||
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "assistant", content: final.text },
|
||||||
|
]).catch((err) => console.error("[cooking-chat] failed to persist chat history", err));
|
||||||
|
|
||||||
|
if (conversationId) {
|
||||||
|
// Bumps updatedAt (for the conversation list's sort order) and, only if
|
||||||
|
// this is the conversation's first message, auto-titles it from the
|
||||||
|
// opening question — so users aren't left staring at "Untitled" entries;
|
||||||
|
// they can still rename it later.
|
||||||
|
void db.execute(sql`
|
||||||
|
UPDATE ${aiConversations}
|
||||||
|
SET updated_at = now(), title = COALESCE(title, ${parsed.data.question.slice(0, 60)})
|
||||||
|
WHERE ${aiConversations.id} = ${conversationId} AND ${aiConversations.userId} = ${session!.user.id}
|
||||||
|
`).catch((err) => console.error("[cooking-chat] failed to touch conversation", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ answer: final.text, proposedRecipe, proposedShoppingList });
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "@epicure/db";
|
import { and, eq } from "@epicure/db";
|
||||||
import { db, recipes } from "@epicure/db";
|
import { db, recipes } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
|
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
|
||||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
count: z.number().int().min(1).max(6).default(4),
|
count: z.number().int().min(1).max(6).default(4),
|
||||||
@@ -17,9 +19,24 @@ const Schema = z.object({
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requireFeatureEnabled(session!.user.id, "drink_pairing");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FeatureDisabledError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const recipe = await db.query.recipes.findFirst({
|
const recipe = await db.query.recipes.findFirst({
|
||||||
@@ -43,7 +60,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestDrinks(
|
suggestDrinks(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -55,7 +72,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
parsed.data.count,
|
parsed.data.count,
|
||||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||||
(session!.user as { locale?: string }).locale ?? "en"
|
(session!.user as { locale?: string }).locale ?? "en"
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||||
import { parseQuantity } from "@/lib/parse-quantity";
|
import { parseQuantity } from "@/lib/parse-quantity";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
@@ -17,7 +19,7 @@ const Schema = z.object({
|
|||||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -32,59 +34,76 @@ export async function POST(req: NextRequest) {
|
|||||||
const privateBio = await getUserPrivateBio(session!.user.id);
|
const privateBio = await getUserPrivateBio(session!.user.id);
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const configResult = await resolveAiConfigOrError(() =>
|
||||||
|
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
|
||||||
|
);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateRecipe(parsed.data.title, {
|
generateRecipe(parsed.data.title, {
|
||||||
provider: parsed.data.provider,
|
...aiConfig,
|
||||||
model: parsed.data.model,
|
|
||||||
userContext: privateBio ?? undefined,
|
userContext: privateBio ?? undefined,
|
||||||
language: LANG[locale] ?? "English",
|
language: LANG[locale] ?? "English",
|
||||||
})
|
}), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const recipe = result.data;
|
const recipe = result.data;
|
||||||
|
|
||||||
const recipeId = crypto.randomUUID();
|
const recipeId = crypto.randomUUID();
|
||||||
|
|
||||||
await db.insert(recipes).values({
|
try {
|
||||||
id: recipeId,
|
await db.transaction(async (tx) => {
|
||||||
authorId: session!.user.id,
|
await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
title: recipe.title,
|
|
||||||
description: recipe.description ?? null,
|
|
||||||
baseServings: recipe.baseServings,
|
|
||||||
prepMins: recipe.prepMins ?? null,
|
|
||||||
cookMins: recipe.cookMins ?? null,
|
|
||||||
difficulty: recipe.difficulty ?? null,
|
|
||||||
visibility: "private",
|
|
||||||
aiGenerated: true,
|
|
||||||
language: locale,
|
|
||||||
dietaryTags: recipe.dietaryTags ?? {},
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (recipe.ingredients?.length) {
|
await tx.insert(recipes).values({
|
||||||
await db.insert(recipeIngredients).values(
|
id: recipeId,
|
||||||
recipe.ingredients.map((ing, i) => ({
|
authorId: session!.user.id,
|
||||||
id: crypto.randomUUID(),
|
title: recipe.title,
|
||||||
recipeId,
|
description: recipe.description ?? null,
|
||||||
rawName: ing.rawName,
|
baseServings: recipe.baseServings,
|
||||||
quantity: parseQuantity(ing.quantity) ?? null,
|
recipeType: recipe.recipeType,
|
||||||
unit: ing.unit ?? null,
|
prepMins: recipe.prepMins ?? null,
|
||||||
note: ing.note ?? null,
|
cookMins: recipe.cookMins ?? null,
|
||||||
order: i,
|
difficulty: recipe.difficulty ?? null,
|
||||||
}))
|
visibility: "private",
|
||||||
);
|
aiGenerated: true,
|
||||||
}
|
language: locale,
|
||||||
|
dietaryTags: recipe.dietaryTags ?? {},
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
if (recipe.steps?.length) {
|
if (recipe.ingredients?.length) {
|
||||||
await db.insert(recipeSteps).values(
|
await tx.insert(recipeIngredients).values(
|
||||||
recipe.steps.map((step, i) => ({
|
recipe.ingredients.map((ing, i) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
recipeId,
|
recipeId,
|
||||||
instruction: step.instruction,
|
rawName: ing.rawName,
|
||||||
timerSeconds: step.timerSeconds ?? null,
|
quantity: parseQuantity(ing.quantity) ?? null,
|
||||||
order: i,
|
unit: ing.unit ?? null,
|
||||||
}))
|
note: ing.note ?? null,
|
||||||
);
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.steps?.length) {
|
||||||
|
await tx.insert(recipeSteps).values(
|
||||||
|
recipe.steps.map((step, i) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recipeId,
|
||||||
|
instruction: step.instruction,
|
||||||
|
timerSeconds: step.timerSeconds ?? null,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: recipeId });
|
return NextResponse.json({ id: recipeId });
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, recipes, recipeIngredients, recipeSteps, collections, collectionRecipes, eq, and } from "@epicure/db";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||||
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal";
|
||||||
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
collectionId: z.string().min(1),
|
||||||
|
theme: z.string().min(1).max(200),
|
||||||
|
courses: z.array(z.enum(MEAL_COURSES)).min(2).max(6).refine((c) => new Set(c).size === c.length, "Duplicate courses"),
|
||||||
|
servings: z.number().int().min(1).max(20).default(4),
|
||||||
|
dietaryPrefs: z.string().max(200).optional(),
|
||||||
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = Schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session!.user.id;
|
||||||
|
const tier = session!.user.tier as "free" | "pro" | "family";
|
||||||
|
const { collectionId, theme, courses, servings, dietaryPrefs, difficulty } = parsed.data;
|
||||||
|
|
||||||
|
const collection = await db.query.collections.findFirst({
|
||||||
|
where: and(eq(collections.id, collectionId), eq(collections.userId, userId)),
|
||||||
|
});
|
||||||
|
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${userId}`, 3, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
const [configResult, privateBio] = await Promise.all([
|
||||||
|
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
|
||||||
|
getUserPrivateBio(userId),
|
||||||
|
]);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const config = configResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(userId, tier, () =>
|
||||||
|
generateMeal(
|
||||||
|
{ theme, courses, servings, dietaryPrefs, difficulty },
|
||||||
|
{ ...config, userContext: privateBio ?? undefined },
|
||||||
|
locale
|
||||||
|
), { skipQuota: config.isByok }
|
||||||
|
);
|
||||||
|
if (!result.ok) return result.response;
|
||||||
|
const meal = result.data;
|
||||||
|
|
||||||
|
const created: Array<{ id: string; title: string; course: string }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Each recipe in the meal creates a real recipe row — check the recipe
|
||||||
|
// limit covers all of them before inserting anything.
|
||||||
|
await checkTierLimitInTransaction(tx, userId, tier, "recipe", meal.recipes.length);
|
||||||
|
for (const recipe of meal.recipes) {
|
||||||
|
const recipeId = crypto.randomUUID();
|
||||||
|
await tx.insert(recipes).values({
|
||||||
|
id: recipeId,
|
||||||
|
authorId: userId,
|
||||||
|
title: recipe.title,
|
||||||
|
description: recipe.description,
|
||||||
|
baseServings: recipe.baseServings,
|
||||||
|
recipeType: recipe.recipeType,
|
||||||
|
visibility: "private",
|
||||||
|
language: locale,
|
||||||
|
aiGenerated: true,
|
||||||
|
difficulty: recipe.difficulty ?? null,
|
||||||
|
prepMins: recipe.prepMins ?? null,
|
||||||
|
cookMins: recipe.cookMins ?? null,
|
||||||
|
dietaryTags: recipe.dietaryTags ?? {},
|
||||||
|
tags: [getMessages(locale).collections.course[recipe.course]],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recipe.ingredients.length > 0) {
|
||||||
|
await tx.insert(recipeIngredients).values(
|
||||||
|
recipe.ingredients.map((ing, i) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recipeId,
|
||||||
|
rawName: ing.rawName,
|
||||||
|
quantity: ing.quantity != null ? String(ing.quantity) : null,
|
||||||
|
unit: ing.unit ?? null,
|
||||||
|
note: ing.note ?? null,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipe.steps.length > 0) {
|
||||||
|
await tx.insert(recipeSteps).values(
|
||||||
|
recipe.steps.map((step, i) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
recipeId,
|
||||||
|
instruction: step.instruction,
|
||||||
|
timerSeconds: step.timerSeconds ?? null,
|
||||||
|
order: i,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(collectionRecipes).values({ collectionId, recipeId }).onConflictDoNothing();
|
||||||
|
|
||||||
|
created.push({ id: recipeId, title: recipe.title, course: recipe.course });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ collectionId, recipes: created });
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
prompt: z.string().min(3).max(500),
|
prompt: z.string().min(3).max(500),
|
||||||
@@ -15,7 +16,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -29,14 +30,19 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const privateBio = await getUserPrivateBio(session!.user.id);
|
const privateBio = await getUserPrivateBio(session!.user.id);
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const configResult = await resolveAiConfigOrError(() =>
|
||||||
|
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
|
||||||
|
);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateRecipe(parsed.data.prompt, {
|
generateRecipe(parsed.data.prompt, {
|
||||||
provider: parsed.data.provider,
|
...aiConfig,
|
||||||
model: parsed.data.model,
|
|
||||||
language: parsed.data.language,
|
language: parsed.data.language,
|
||||||
difficulty: parsed.data.difficulty,
|
difficulty: parsed.data.difficulty,
|
||||||
userContext: privateBio ?? undefined,
|
userContext: privateBio ?? undefined,
|
||||||
})
|
}), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { importFromPhoto } from "@/lib/ai/features/import-photo";
|
import { importFromPhoto } from "@/lib/ai/features/import-photo";
|
||||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
imageBase64: z.string().max(14_000_000),
|
imageBase64: z.string().max(14_000_000),
|
||||||
@@ -13,7 +14,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -28,38 +29,56 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
|
const visionConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!visionConfigResult.ok) return visionConfigResult.response;
|
||||||
const aiConfig = configResult.data;
|
const visionConfig = visionConfigResult.data;
|
||||||
|
|
||||||
// Fall back to vision-capable defaults if no explicit model configured
|
// Fall back to vision-capable defaults if no explicit model configured
|
||||||
if (!aiConfig.model) {
|
if (!visionConfig.model) {
|
||||||
if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
|
if (visionConfig.provider === "openai") visionConfig.model = "gpt-4o";
|
||||||
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
|
else if (visionConfig.provider === "anthropic") visionConfig.model = "claude-sonnet-4-6";
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
|
const textConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "text"));
|
||||||
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale)
|
if (!textConfigResult.ok) return textConfigResult.response;
|
||||||
|
const textConfig = textConfigResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
|
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
|
||||||
|
{ skipQuota: visionConfig.isByok && textConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const recipe = result.data;
|
const recipe = result.data;
|
||||||
|
|
||||||
|
if (!recipe.found) {
|
||||||
|
return NextResponse.json({ error: "No recipe recognized in photo" }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
const newRecipeId = crypto.randomUUID();
|
const newRecipeId = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
await tx.insert(recipes).values({
|
await tx.insert(recipes).values({
|
||||||
id: newRecipeId,
|
id: newRecipeId,
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
description: recipe.description,
|
description: recipe.description,
|
||||||
baseServings: recipe.baseServings ?? 4,
|
baseServings: recipe.baseServings ?? 4,
|
||||||
|
recipeType: recipe.recipeType,
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
difficulty: recipe.difficulty,
|
difficulty: recipe.difficulty,
|
||||||
prepMins: recipe.prepMins,
|
prepMins: recipe.prepMins,
|
||||||
cookMins: recipe.cookMins,
|
cookMins: recipe.cookMins,
|
||||||
dietaryTags: recipe.dietaryTags ?? {},
|
dietaryTags: recipe.dietaryTags ?? {},
|
||||||
aiGenerated: true,
|
aiGenerated: true,
|
||||||
|
// The extraction prompt always writes the recipe in the caller's own
|
||||||
|
// locale (see importFromPhoto's langInstruction) regardless of what
|
||||||
|
// language the photo/label was in — so this *is* the recipe's
|
||||||
|
// language, not a guess, and lets the Translate button correctly
|
||||||
|
// stay hidden until the user's app language changes.
|
||||||
|
language: locale,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -89,7 +108,13 @@ export async function POST(req: NextRequest) {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: newRecipeId });
|
return NextResponse.json({ id: newRecipeId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { importFromUrl } from "@/lib/ai/features/import-url";
|
import { importFromUrl } from "@/lib/ai/features/import-url";
|
||||||
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||||
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
@@ -13,7 +14,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -30,11 +31,14 @@ export async function POST(req: NextRequest) {
|
|||||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const configResult = await resolveAiConfigOrError(() =>
|
||||||
importFromUrl(parsed.data.url, {
|
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
|
||||||
provider: parsed.data.provider,
|
);
|
||||||
model: parsed.data.model,
|
if (!configResult.ok) return configResult.response;
|
||||||
})
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
|
importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, eq, and } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
|
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
const [configResult, privateBio] = await Promise.all([
|
const [configResult, privateBio] = await Promise.all([
|
||||||
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
|
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
|
||||||
getUserPrivateBio(userId),
|
getUserPrivateBio(userId),
|
||||||
]);
|
]);
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateMealPlan(
|
generateMealPlan(
|
||||||
{
|
{
|
||||||
dietaryPrefs: parsed.data.dietaryPrefs,
|
dietaryPrefs: parsed.data.dietaryPrefs,
|
||||||
@@ -88,28 +88,11 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
{ ...config, userContext: privateBio ?? undefined },
|
{ ...config, userContext: privateBio ?? undefined },
|
||||||
locale
|
locale
|
||||||
)
|
), { skipQuota: config.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const plan = result.data;
|
const plan = result.data;
|
||||||
|
|
||||||
// Each plan entry creates a draft recipe — charge the recipe limit for all
|
|
||||||
// of them before inserting anything, refunding on breach so a rejected plan
|
|
||||||
// doesn't consume quota.
|
|
||||||
let chargedRecipes = 0;
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < plan.entries.length; i++) {
|
|
||||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "recipe");
|
|
||||||
chargedRecipes++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof TierLimitError) {
|
|
||||||
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
|
|
||||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure meal plan row exists for the week
|
// Ensure meal plan row exists for the week
|
||||||
let mealPlan = await db.query.mealPlans.findFirst({
|
let mealPlan = await db.query.mealPlans.findFirst({
|
||||||
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
|
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
|
||||||
@@ -118,12 +101,16 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!mealPlan) {
|
if (!mealPlan) {
|
||||||
const planId = crypto.randomUUID();
|
const planId = crypto.randomUUID();
|
||||||
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
|
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
|
||||||
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
|
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, isPublic: false, createdAt: new Date() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Each plan entry creates a draft recipe — check the recipe limit covers
|
||||||
|
// all of them before inserting anything.
|
||||||
|
await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe", plan.entries.length);
|
||||||
for (const entry of plan.entries) {
|
for (const entry of plan.entries) {
|
||||||
// Create draft recipe
|
// Create draft recipe
|
||||||
const recipeId = crypto.randomUUID();
|
const recipeId = crypto.randomUUID();
|
||||||
@@ -134,6 +121,7 @@ export async function POST(req: NextRequest) {
|
|||||||
description: entry.recipe.description,
|
description: entry.recipe.description,
|
||||||
baseServings: entry.servings,
|
baseServings: entry.servings,
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
|
language: locale,
|
||||||
aiGenerated: true,
|
aiGenerated: true,
|
||||||
difficulty: entry.recipe.difficulty ?? null,
|
difficulty: entry.recipe.difficulty ?? null,
|
||||||
prepMins: entry.recipe.prepMins ?? null,
|
prepMins: entry.recipe.prepMins ?? null,
|
||||||
@@ -189,7 +177,13 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
|
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "@epicure/db";
|
import { and, eq } from "@epicure/db";
|
||||||
import { db, recipes } from "@epicure/db";
|
import { db, recipes } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
|
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
|
||||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
count: z.number().int().min(1).max(6).default(4),
|
count: z.number().int().min(1).max(6).default(4),
|
||||||
@@ -17,9 +19,24 @@ const Schema = z.object({
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requireFeatureEnabled(session!.user.id, "meal_pairing");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FeatureDisabledError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Allow pairings for own recipes or public recipes
|
// Allow pairings for own recipes or public recipes
|
||||||
@@ -44,7 +61,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestPairings(
|
suggestPairings(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -56,7 +73,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
parsed.data.count,
|
parsed.data.count,
|
||||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||||
(session!.user as { locale?: string }).locale ?? "en"
|
(session!.user as { locale?: string }).locale ?? "en"
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { generateText } from "ai";
|
import { generateText } from "ai";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||||
import { resolveModel } from "@/lib/ai/factory";
|
import { resolveModel } from "@/lib/ai/factory";
|
||||||
import { db, recipes, eq, and } from "@epicure/db";
|
import { db, recipes, chatMessages, eq, and } from "@epicure/db";
|
||||||
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
||||||
import { hasQuantity } from "@/lib/fractions";
|
import { hasQuantity } from "@/lib/fractions";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const Schema = z.object({
|
|||||||
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -64,25 +64,31 @@ ${stepList || "None listed"}
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const [configResult, privateBio] = await Promise.all([
|
const [configResult, privateBio] = await Promise.all([
|
||||||
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")),
|
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "chat")),
|
||||||
getUserPrivateBio(session!.user.id),
|
getUserPrivateBio(session!.user.id),
|
||||||
]);
|
]);
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const model = resolveModel(configResult.data);
|
const aiConfig = configResult.data;
|
||||||
|
const model = resolveModel(aiConfig);
|
||||||
const bioContext = buildUserBioContext(privateBio);
|
const bioContext = buildUserBioContext(privateBio);
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
const lang = LANG[locale] ?? "English";
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateText({
|
generateText({
|
||||||
model,
|
model,
|
||||||
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
system: `You are Epicure, a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
||||||
|
|
||||||
${recipeContext}${bioContext}`,
|
${recipeContext}${bioContext}`,
|
||||||
prompt: parsed.data.question,
|
prompt: parsed.data.question,
|
||||||
})
|
}), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
void db.insert(chatMessages).values([
|
||||||
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "user", content: parsed.data.question },
|
||||||
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "assistant", content: result.data.text },
|
||||||
|
]).catch((err) => console.error("[recipe-chat] failed to persist chat history", err));
|
||||||
|
|
||||||
return NextResponse.json({ answer: result.data.text });
|
return NextResponse.json({ answer: result.data.text });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||||
@@ -25,7 +25,7 @@ const IdeasSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -42,7 +42,8 @@ export async function POST(req: NextRequest) {
|
|||||||
getUserPrivateBio(session!.user.id),
|
getUserPrivateBio(session!.user.id),
|
||||||
]);
|
]);
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const model = resolveModel(configResult.data);
|
const aiConfig = configResult.data;
|
||||||
|
const model = resolveModel(aiConfig);
|
||||||
const bioContext = buildUserBioContext(privateBio);
|
const bioContext = buildUserBioContext(privateBio);
|
||||||
|
|
||||||
const userContext = bioContext
|
const userContext = bioContext
|
||||||
@@ -56,13 +57,13 @@ export async function POST(req: NextRequest) {
|
|||||||
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
||||||
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateObject({
|
generateObject({
|
||||||
model,
|
model,
|
||||||
schema: IdeasSchema,
|
schema: IdeasSchema,
|
||||||
system: `Respond in ${lang}.`,
|
system: `Respond in ${lang}.`,
|
||||||
prompt,
|
prompt,
|
||||||
})
|
}), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
|
import { regenerateRecipe } from "@/lib/ai/features/regenerate-recipe";
|
||||||
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
baseServings: z.number().int().min(1).max(100),
|
||||||
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
|
ingredients: z.array(z.object({
|
||||||
|
rawName: z.string().min(1).max(200),
|
||||||
|
quantity: z.union([z.string(), z.number()]).optional(),
|
||||||
|
unit: z.string().max(50).optional(),
|
||||||
|
})).max(100),
|
||||||
|
steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100),
|
||||||
|
instruction: z.string().min(1).max(500),
|
||||||
|
language: z.string().max(10).default("en"),
|
||||||
|
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// No recipeId, no DB access — this is a stateless AI transform over whatever
|
||||||
|
// draft the editor currently holds (including unsaved edits), not the saved
|
||||||
|
// row. The caller merges the result into their own in-progress form state.
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = await req.json() as unknown;
|
||||||
|
const parsed = Schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const configResult = await resolveAiConfigOrError(() =>
|
||||||
|
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
|
||||||
|
);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
|
const { instruction, language, ...current } = parsed.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
|
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
|
||||||
|
);
|
||||||
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
return NextResponse.json(result.data);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, eq, and, or } from "@epicure/db";
|
import { db, recipes, eq, and, or } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
@@ -13,7 +13,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const limited = await applyRateLimit(`rl:ai:scale:${session!.user.id}`, 20, 60);
|
const limited = await applyRateLimit(`rl:ai:scale:${session!.user.id}`, 20, 60);
|
||||||
@@ -40,11 +40,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
|
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
scaleRecipe(
|
scaleRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -55,7 +55,7 @@ export async function POST(req: NextRequest) {
|
|||||||
recipe.baseServings,
|
recipe.baseServings,
|
||||||
aiConfig,
|
aiConfig,
|
||||||
(session!.user as { locale?: string }).locale ?? "en"
|
(session!.user as { locale?: string }).locale ?? "en"
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
|
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
|
||||||
|
import type { AiConfig } from "@/lib/ai/factory";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
ingredient: z.string().min(1).max(200),
|
ingredient: z.string().min(1).max(200),
|
||||||
@@ -14,7 +15,7 @@ const Schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
@@ -28,19 +29,20 @@ export async function POST(req: NextRequest) {
|
|||||||
? `recipe "${parsed.data.recipeTitle}"`
|
? `recipe "${parsed.data.recipeTitle}"`
|
||||||
: "a general recipe";
|
: "a general recipe";
|
||||||
|
|
||||||
let aiConfig;
|
let aiConfig: AiConfig;
|
||||||
if (parsed.data.provider) {
|
if (parsed.data.provider) {
|
||||||
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
|
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
|
||||||
} else {
|
} else {
|
||||||
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
|
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id, "text"));
|
||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
aiConfig = configResult.data;
|
aiConfig = configResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale)
|
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale),
|
||||||
|
{ skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "@epicure/db";
|
import { and, eq } from "@epicure/db";
|
||||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { withAiQuota } from "@/lib/ai/ai-error";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
|
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
|
||||||
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
|
import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
targetLanguage: z.string().min(2).max(50),
|
targetLanguage: z.string().min(2).max(50),
|
||||||
@@ -15,9 +18,12 @@ const Schema = z.object({
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const recipe = await db.query.recipes.findFirst({
|
const recipe = await db.query.recipes.findFirst({
|
||||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||||
@@ -35,7 +41,13 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const configResult = await resolveAiConfigOrError(() =>
|
||||||
|
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
|
||||||
|
);
|
||||||
|
if (!configResult.ok) return configResult.response;
|
||||||
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
translateRecipe(
|
translateRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -44,8 +56,8 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
steps: recipe.steps,
|
steps: recipe.steps,
|
||||||
},
|
},
|
||||||
parsed.data.targetLanguage,
|
parsed.data.targetLanguage,
|
||||||
{ provider: parsed.data.provider, model: parsed.data.model }
|
aiConfig
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
const translation = result.data;
|
const translation = result.data;
|
||||||
@@ -54,7 +66,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
await tx.insert(recipes).values({
|
await tx.insert(recipes).values({
|
||||||
id: newId,
|
id: newId,
|
||||||
authorId: session!.user.id,
|
authorId: session!.user.id,
|
||||||
@@ -96,7 +110,13 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TierLimitError) {
|
||||||
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: newId });
|
return NextResponse.json({ id: newId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, eq } from "@epicure/db";
|
import { and, eq } from "@epicure/db";
|
||||||
import { db, recipes } from "@epicure/db";
|
import { db, recipes } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
||||||
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
|
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
|
||||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||||
|
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
count: z.number().int().min(1).max(5).default(3),
|
count: z.number().int().min(1).max(5).default(3),
|
||||||
@@ -18,9 +20,24 @@ const Schema = z.object({
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSessionOrApiKey(req);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requireFeatureEnabled(session!.user.id, "recipe_variations");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FeatureDisabledError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const recipe = await db.query.recipes.findFirst({
|
const recipe = await db.query.recipes.findFirst({
|
||||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||||
@@ -47,7 +64,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestVariations(
|
suggestVariations(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -59,7 +76,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||||
parsed.data.directions,
|
parsed.data.directions,
|
||||||
(session!.user as { locale?: string }).locale ?? "en"
|
(session!.user as { locale?: string }).locale ?? "en"
|
||||||
)
|
), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, apiKeys, eq, and } from "@epicure/db";
|
import { db, apiKeys, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireDeveloper } from "@/lib/api-auth";
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireDeveloper();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user