Commit Graph

58 Commits

Author SHA1 Message Date
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 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 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 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
Arnaud aa6bd03c5e feat: push notification when a shared shopping list is updated
Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.

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

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

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

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

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

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

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

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

Verified locally: toggling public via the real dialog UI persists
correctly (confirmed via DB + a fresh curl PATCH to rule out a client
race in my own test script), and the public link loads with zero auth
for a logged-out browser context.
2026-07-12 16:11:57 +02:00
Arnaud d7e0d7eada feat: profile pictures — Gravatar fallback + custom upload
New users get a Gravatar-backed avatar automatically (computed from email
at signup); users can upload a custom photo instead via Settings, or
revert to the Gravatar/initials fallback. avatarUrl stays the single
resolved value (custom photo, OAuth photo, or precomputed Gravatar URL)
so every existing avatar-rendering spot across the app needs zero changes.

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

Verified locally: signup auto-sets a Gravatar URL, upload persists
across reload, remove correctly reverts to Gravatar/initials.
2026-07-12 16:10:54 +02:00
Arnaud 13128df19f feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving
- AI recipe generation always used the app's own locale rather than a
  separate language picker — removed the picker, generation and the
  saved recipe's language now both follow useLocale() directly.
- Bulk-select action bar had an ungated max-w-md that capped it at 448px
  even past the sm: breakpoint where sm:w-auto was supposed to let it
  grow to content — added sm:max-w-none so desktop never scrolls.
- Batch-cooking generation had no free-text "describe" field like the
  standard recipe generator does — added one (BatchCookFields, both the
  standalone dialog and the AI dialog's batch tab), threaded through the
  API route and generateBatchCook's prompt as additional guidance.
- Recipes imported from a URL never actually got their sourceUrl
  persisted — CreateRecipeSchema didn't declare the field, so Zod
  silently stripped it before it ever reached the insert. The detail
  page's "Source: <hostname>" link already existed but was dead code
  for every real import until now.

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

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

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
2026-07-12 14:45:24 +02:00
Arnaud 546ba98d2f fix: photos lost on save, multi-upload only kept last file, thumbnails 400
recipe-form/API routes were never sending/persisting photos on create+update.
PhotoUploader accumulated uploads via a stale closure over the photos prop, so
only the last file of a multi-select survived. Once both were fixed, thumbnails
still 400'd because Next's image optimizer blocks upstream hosts resolving to
private/loopback IPs (localhost:9000 MinIO) — skip optimization for
storage-hosted photos since they're already web-sized user uploads.
2026-07-12 13:16:37 +02:00
Arnaud 002f14ced0 feat: batch-cook shopping list (already worked) + leftover expiry reminders
Shopping list add already worked generically for batch-cook recipes —
no code needed there.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:26:03 +02:00
Arnaud b0849c3989 feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
Six M-sized items from HANDOFF.md's new-features backlog:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:50:35 +02:00
Arnaud b4b964aafb feat: add trending/leaderboard for collections
New collection_favorites table lets users star public collections.
/collections/explore mirrors the recipe explore page: trending (star
count in last 7 days) and recently-added public collections. Linked
from the "Discover" button on the collections page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:33:40 +02:00
Arnaud f2a0c20f07 feat: add "cooked it" photo reviews
Users can rate a recipe with review text and an optional photo. Adds
ratings.photo_key column, a reviews list endpoint, and a review-purpose
presign path (reviewer isn't the recipe owner, so the upload
authorization differs from cover-photo uploads).

Also fixes CSP connect-src/img-src to allow the storage origin —
direct-to-S3/MinIO presigned uploads and stored images were silently
blocked by Content-Security-Policy in the browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:20:05 +02:00
Arnaud a8406e9963 feat: add bulk "add to collection" action on recipes page
Select recipes → add to an existing collection or create a new one inline. Fixes duplicate "visibility" i18n key bug found during verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:06:34 +02:00
Arnaud c3776238c7 feat: direct messages
1:1 conversations (userAId < userBId dedup pair), messages,
per-participant read tracking (conversation_reads). Block relationship
is enforced on every send. UI: /messages list + /messages/[id] thread
(5s poll), MessageButton on profiles, unread-badged nav icon.

This completes the social-feature backlog: notifications, rate
limiting, blocking, reporting, search/discovery, mentions, DMs, plus
fixes for the recipe-visibility 404, follow race, and 2-level comment
thread cap found during the earlier audit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:24:56 +02:00
Arnaud a51ba85253 feat: @mentions in comments
Parse @username in comment content, notify mentioned users (dedup
against recipe-author/parent-author who are already notified via
comment/reply), and render @username as a link to their profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:16:06 +02:00
Arnaud 73a71d28cd feat: user search/discovery
Previously the only way to find someone to follow was knowing their
exact username. Add GET /api/v1/users/search (name/username ilike,
excludes blocked-either-way) and a /people page with debounced search
+ inline follow buttons. Linked from the Explore page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:12:03 +02:00
Arnaud 57c29f62b4 feat: user blocking and content reporting/flagging
- user_blocks table (composite PK), Block/Unblock button on profiles.
  Blocking severs any existing follow relationship both ways and
  prevents the blocked party from following, commenting on the
  blocker's recipes, or the blocker from seeing their comments.
- reports table (recipe/comment/user targets, pending/reviewed/
  dismissed status). ReportButton on comments, admin review queue at
  /admin/reports with dismiss/mark-reviewed actions, audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:09:14 +02:00
Arnaud 1abab17ca8 feat: notifications system, rate limiting, fix recipe visibility 404, follow race
Part of the social-feature backlog (follow, comments, reactions, ratings,
feed, threading) audited earlier — see conversation.

- notifications table: follow/comment/reply/reaction/rating events,
  replaces the fully-dead feed_items table (feed_item_type enum existed
  but had zero references anywhere in the codebase).
- Bell UI in the nav with unread badge, mark-all-read, 30s poll.
- Rate limiting on comment posting (20/min), follow/unfollow (30/min),
  and comment reactions (60/min) — previously unthrottled.
- /recipes/[id] queried by (id, authorId=session.user) only, so any
  recipe not owned by the viewer 404'd regardless of visibility.
  Widen the query to include public/unlisted recipes and gate the
  owner-only actions (edit, delete, version history, translate, AI
  content generation) behind an isOwner check.
- user_follows had no primary key/unique constraint, so the follow
  route's onConflictDoNothing() was a silent no-op — concurrent follow
  clicks could insert duplicate rows and inflate follower counts. Add
  a composite primary key on (follower_id, following_id).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:56:34 +02:00
Arnaud e0e1ac49d9 feat: signup toggle, invite links, admin-created users
- New invites table: token-gated signup, optional email lock,
  role/tier override, single-use, expiry.
- SIGNUPS_DISABLED site setting toggle at /admin/settings.
- databaseHooks.user.create gate in auth/server.ts blocks new account
  creation (email + Google OAuth) when disabled unless a valid invite
  cookie is present; applies invite role/tier and marks it consumed.
- /admin/invites: create/list/revoke shareable invite links.
- /admin/users: "Create user" dialog — admin sets email/role/tier,
  account is pre-verified, user gets a set-password email (admin
  never sees a password).
- Signup page reads ?invite=, validates via public
  /api/v1/invites/[token], locks the form when signups are closed
  and no valid invite is present.
- proxy.ts: allowlist /api/v1/invites/ for anonymous invite checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:36:40 +02:00
Arnaud 9412529120 feat: add unlimited option to tier limits
Use -1 as a sentinel meaning "no cap" in tier_definitions numeric
columns. checkAndIncrementTierLimit skips the usage-cap WHERE clause
when the limit is -1. Admin tier UI gets an Unlimited switch per
field that disables the input and sends -1.
2026-07-03 20:19:43 +02:00
Arnaud ac9f5c87e9 fix: language default, google OAuth cookie issue, add admin tier/usage controls
- ai-generate-dialog: useLocale() returns {locale,setLocale} object, not
  string — was stringifying whole object as default language value
- auth/server: add trustedOrigins so session cookie survives reverse-proxy
  deployments where BETTER_AUTH_URL differs from container's own view
- admin: add tier limit editor (/admin/tiers) and per-user usage reset
  button, both audit-logged

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 19:51:06 +02:00
Arnaud 524310433c fix: return clean errors and refund quota on AI provider failures
AI routes had no consistent error handling — a provider failure (e.g.
OpenRouter returning a degraded-model error) crashed the route as an
uncaught 500 and, worse, still charged the user's monthly aiCall
quota for a request that never succeeded.

Adds withAiQuota()/aiErrorResponse() (lib/ai/ai-error.ts) and applies
them across all 12 AI generation routes: charges the quota, runs the
AI call, refunds the credit and returns a clean user-facing message
if it throws. Frontend needs no changes — existing dialogs already
toast whatever `error` string comes back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:32:31 +02:00
Arnaud e5d1080fb9 feat: implement remaining TODO.md feature ideas + fix mobile headers
Implements the six previously-unscoped feature ideas plus a mobile
layout fix reported via screenshot:

- Mobile: Recipes/Collections/Pantry/Meal Plan/Shopping Lists headers
  now stack and wrap instead of clipping buttons on narrow viewports.
- Recipe diff/compare view: word/list diff against any past version,
  next to Restore in version history.
- Shared meal plans & shopping lists: new shoppingListMembers/
  mealPlanMembers tables (viewer/editor roles, mirrors
  collectionMembers), share dialogs, membership-checked routes.
- PDF cookbook export: /print/collection/[id] renders a whole
  collection with page breaks, using the existing print-CSS pattern
  instead of adding a PDF rendering dependency.
- Grocery delivery handoff: shopping lists can copy-as-text (works
  today) or send to Instacart once INSTACART_API_KEY is configured
  (stub adapter — real API needs a partner agreement).
- Personalized "For You" feed tab: ranks public recipes by tag/
  dietary overlap with the user's favorited/highly-rated history.
- PWA: added manifest.json + icons on top of the existing service
  worker so the app is installable; cook-mode pages were already
  cached for offline use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:13:00 +02:00
Arnaud d2faf98ac1 fix: resolve TODO.md security/perf/test-coverage backlog
Fixes the 13-item codebase health scan backlog: wraps meal-plan
generation in a transaction, adds missing userId/GIN indexes, fixes
an IPv6-parsing gap in the webhook SSRF guard (and an identical
duplicated bug in the AI URL-import path, now consolidated onto one
implementation), paginates the collections list, dedupes the AI
recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key
rotation, gets `pnpm typecheck` actually working, and adds test
coverage for the previously-untested admin/webhooks routes.

Two flagged issues (collection removeRecipeId IDOR, tier-limit race)
turned out to already be fixed/non-issues on inspection — noted in
TODO.md rather than silently dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:12:42 +02:00
Arnaud 2154512e54 fix(i18n): translate remaining recipe card/detail strings, localize AI responses
Recipe cards, the recipe detail page, and meal planner still rendered raw
"{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels
outside the earlier i18n pass. Also wires the user's locale into AI features
that previously always answered in English regardless of app language:
recipe chat, ingredient substitution, recipe ideas, and generate-from-idea.
The recipe-language picker in the AI generate dialog now defaults to the
user's locale instead of always defaulting to English.
2026-07-02 08:25:51 +02:00
Arnaud afff6cf9eb fix: substitute lookup, zero-quantity display, chatbot close button + markdown
- Substitute finder never applied the user's BYOK/admin AI key (only fell
  back to raw process.env), so it silently failed whenever the key was
  stored via settings rather than a literal env var. Now resolves via
  getDefaultProviderWithKey like every other AI route. Popover also
  surfaces real error messages instead of swallowing failures.
- Ingredients with quantity 0 (salt, pepper, "to taste") rendered the
  literal digit "0" in cooking mode, print view, public recipe page,
  shopping lists, and serving scaler — several sites relied on generic
  truthiness/filter(Boolean), which doesn't catch a stored "0" string.
  Added a shared hasQuantity() helper and applied it everywhere quantity
  is rendered, plus in the AI recipe-chat context sent to the model.
- Recipe chat panel rendered a duplicate close button on top of shadcn
  Sheet's own built-in close X, producing a garbled overlapping glyph.
  Removed the duplicate.
- Recipe chat assistant replies are markdown from the model but were
  rendered as raw text; added react-markdown so formatting actually
  renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 07:43:37 +02:00
Arnaud 8b57a3fd87 Update features and dependencies 2026-07-01 11:10:37 +02:00
Arnaud 9d9dfb46c6 feat: misc — cooking mode, OpenAPI docs, email, storage, upload, homepage
Cooking mode step-by-step view. OpenAPI 3.1 spec auto-generated from Zod schemas.
Email lib (resend). Redis client. S3 storage helper. Photo upload endpoint.
Landing page. Short recipe URL redirect /r/[id]. API docs page.
2026-07-01 08:11:31 +02:00
Arnaud 3f96d1ea41 feat(webhooks): outbound webhooks with HMAC-SHA256 signing and API key auth
Webhook registration/management. HMAC-signed delivery with retry.
Events: recipe.created/updated, comment.created, follower.new.
REST API key creation for programmatic access.
2026-07-01 08:11:19 +02:00