Commit Graph

122 Commits

Author SHA1 Message Date
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 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 f0340859fa feat: split photo-import into vision recognition + text generation
The photo-import flow used one vision-capable model to both read the
photo and structure the full recipe (quantities, steps, timing) in a
single call. Split it into two: a vision model recognizes what's in
the picture, then a text model reconstructs the recipe from that
description — same pattern the pantry photo-scan already uses for
recognition, and lets structuring use whichever model is actually
configured for text generation. Still counts as one AI-quota unit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified both live at 390px: input now shows real placeholder text with
tappable icon buttons alongside it; Save button now clears the chat FAB
with real spacing.
2026-07-13 09:25:20 +02:00
Arnaud 257908da7f fix: recipes page wasted too much vertical space above content on mobile
Search bar forced its own full-width row (w-full), pushing Sort/Filter
to a second row below it — on mobile that's title + action buttons +
search row + sort/filter row + toolbar row before any recipe appears.

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

Verified at a real 390px mobile viewport: first recipe card now clears
with room to spare, versus barely visible at the same viewport height
before. Desktop screenshot confirms zero visual change there.
2026-07-13 08:37:40 +02:00
Arnaud b1995522ee fix: destructive confirm buttons had unreadable black-on-red text
--destructive-foreground was never defined in globals.css (light or dark
theme) — text-destructive-foreground resolved to nothing, so both the
clear-chat-history confirm button and recipe-form's discard-changes
confirm (same bg-destructive text-destructive-foreground pattern) fell
back to the default dark text color on a solid red background.

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

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

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

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

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

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

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

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

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

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

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

Verified locally: asked a real cooking question through the actual UI,
got a correctly-formatted markdown answer with no recipe context leaking
in (confirmed the request payload only carries the question, no recipeId).
2026-07-12 19:35:00 +02:00
Arnaud 9a448ef34d fix: add missing backlink from webhooks documentation page to webhooks settings 2026-07-12 19:34:48 +02:00
Arnaud 4d5269aced feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

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

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
2026-07-12 19:07:32 +02:00
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:05:20 +02:00