Commit Graph

213 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:45:52 +02:00