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>
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>
- 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>
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>