221 Commits

Author SHA1 Message Date
Arnaud 513e4a904a fix: remove leftover create-next-app default favicon.ico (v0.65.3)
app/favicon.ico was still the stock Next.js/Vercel triangle logo from
the original scaffold, never swapped for Epicure's own icon. Next's
file-based routing serves whatever's at app/favicon.ico as
/favicon.ico independently of the icon.svg configured via metadata,
so browsers preferring the .ico kept showing the placeholder no
matter the cache state -- explains "clean computer, still see the
default icon". No .ico regenerated (no image rasterizer available in
this environment) -- the existing icon.svg is now the only favicon
source, which every modern browser supports directly.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

v0.48.0
2026-07-18 09:33:47 +02:00
Arnaud 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