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>
Every "nothing here" page had its own copy-pasted dashed-border box —
icon + one muted line, inconsistent (some had a CTA, some didn't, no
description text anywhere). Replaced with one shared EmptyState
component: icon in a soft tinted circle, a real heading plus optional
description, and primary/secondary actions (either a Link or an
arbitrary action slot for things like "New Collection" that open a
dialog rather than navigate).
Applied to: recipes (no recipes / no search match), favorites, feed
(no one followed / no new posts / trending / for-you), collections
(index + detail), shopping lists, pantry, notifications, can-cook.
Left the small inline "no trending"/"no recent" lines inside Explore's
already-labeled sections and the notification-bell dropdown alone —
different context, a full empty-state box would be heavier than the
space warrants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stopPropagation() alone doesn't cancel a Link's native anchor navigation —
that's the browser's default action for the click reaching the <a>, not
a bubbling JS handler propagation can block. Needed preventDefault() too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Was floating over the cover photo with a circular dark backdrop — too
heavy. Moved into the bottom meta row next to the visibility lock icon,
plain ghost icon button matching the recipe detail page's style exactly
(no background, same size/variant). List and compact rows were already
placed there with no backdrop, only the grid card needed this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Added a heart toggle to all three recipe-grid views (grid card overlay,
list row, compact row), hidden while in multi-select mode. Detail page
already had it; this was the other place it made sense.
- recipes/page.tsx now fetches which of the current page's recipes are
already favorited and passes it through.
- FavoriteButton gained an optional iconClassName so the unfavorited heart
reads on a photo overlay (was invisible in light mode against the dark
backdrop — filled/favorited state was already fine since it's opaque red).
- Fixed the floating bulk-select action bar overflowing both edges of the
viewport on mobile (screenshotted: centered with no max-width, wider
than the screen, clipping "Supprimer" on one side and the selected-count
label on the other). Now caps to viewport width, scrolls horizontally
as a fallback, and drops button text labels below sm (icon + aria-label
+ tooltip only), matching the icon-row convention used elsewhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Favoriting already worked (heart toggle on the recipe detail page, DB
table, API route) but there was nowhere to see what you'd favorited —
the actual missing piece behind "add a favorites feature".
- New /recipes/favorites page: paginated grid of favorited recipes (cover
photo, difficulty, time, author), sorted by most-recently favorited.
A previously-favorited recipe stays visible even if the author later
goes private, matching the existing private-accounts scope decision
(existing access isn't revoked, only new discovery is affected).
- Unfavoriting from that page removes the card immediately — added an
optional onToggle callback to FavoriteButton for this.
- Added a lightweight "My Recipes / Favorites" tab pair at the top of
/recipes linking between the two, rather than a new top-level nav item
(nav is already at 8 entries).
Deliberately out of scope: favorite toggles on the main recipe grid, feed
cards, or search/explore results — favoriting your own recipes is odd
UX, and wiring session-aware favorited state into the public
(unauthenticated) search route is a separate, bigger change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flex-wrap let the header buttons (export, share, print, markdown export,
rename/delete) drop to a second row once they didn't fit. Switched to the
same non-wrapping, horizontally-scrollable row pattern already used for
the recipe detail page's action buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This was the actual menu being reported (a prior fix mistakenly targeted
the per-item category dropdown instead, reverted). Replaced the single
MoreVertical dropdown trigger (rename + delete hidden behind it) with two
directly visible icon buttons + tooltips, matching the recipe detail
page's icon-row convention. Used on both the shopping-lists index rows
and the list detail page header, unchanged at both call sites since only
the component's internals changed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaced the hidden dropdown (change category submenu + delete, behind a
single unlabeled MoreVertical icon) with two directly visible controls,
matching the recipe detail page's icon-row pattern: a compact category
Select showing the item's current category at a glance (still supports
picking a predefined category, "Other", or typing a new custom one), and
a direct trash icon button with a tooltip. Row stays single-line via
truncation on the name and a fixed-width category trigger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cross-category drops worked (previous fix) but had no live preview — the
item stayed rendered in its original group the whole time you were
dragging over a different one, since dnd-kit only computes a transform
for items actually present in a group's array.
Added the standard multi-container dnd-kit pattern: onDragOver moves the
item into the target group locally as soon as you drag over it (pure
preview, nothing persisted yet); onDragEnd finalizes the exact position
and only fires a network request for whatever actually changed — order
within the final group, and the aisle itself only if it differs from
what it was when the drag started. Also handles dropping outside any
group: the local preview move is reverted instead of silently leaving an
unpersisted, visually-wrong position until refresh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each category group had its own isolated DndContext, so dnd-kit had no way
to resolve a drop that landed in a different group — cross-category drags
were silently no-ops by construction, not an edge-case bug.
Lifted to a single DndContext wrapping all groups, with one handleDragEnd
that looks up both the dragged item's and the drop target's current group
from live state: same group reorders as before, different group changes
the item's aisle (appended after the item it was dropped on) via the
existing changeCategory persistence path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:
- Categories are now translated (grocery-categories.ts stores locale-
independent slugs like "produce", translated for display via
shoppingLists.categories.* instead of storing/rendering the English label
directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
its label — base-ui's Select.Value has no built-in value->label lookup,
it needs an explicit render function, which no Select in this call site
had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
the quantity embedded in the name (e.g. "1 avocat") still showed that way
even though quantity/unit were already populated separately, because the
extraction helper only fired when quantity was empty. Now it always
cleans the name but only backfills quantity/unit when they're missing,
and runs at shopping-list generation time too (not just recipe save) so
it also cleans up already-contaminated recipes, not just new ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
- 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>
- 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>
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>
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>
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>
Dockerfile still COPYed packages/api-types (deleted in the prior audit-fixes
commit), breaking Portainer builds. Also drop apps/web/middleware.ts, added
in the same commit for auth guarding — it duplicated and conflicted with the
pre-existing apps/web/proxy.ts (Next 16's middleware entry point), which
already redirects unauthenticated requests to /login, 401s API routes, and
covers /recipes/new, /explore, /search, /settings/webhooks/docs via its
catch-all matcher. Having both middleware.ts and proxy.ts breaks `next build`
outright. Verified with a local `docker build` end to end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
Pantry page now shows a "Use it up soon" widget with recipes that use
soon-to-expire pantry items, across the user's own recipes plus public/
unlisted ones (the existing /recipes/can-cook page only looked at the
user's own). Extracted the matching/scoring logic shared by both into
lib/pantry-match.ts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
Root layout used title.template ("%s | Epicure") and 38 pages set
their own title, producing "Recipes | Epicure", "Messages — Epicure",
etc. Drop the template, set a flat "Epicure" default, and clear every
page's title override so they all inherit it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Grid was the only layout. Add a view-mode toggle (grid/list/compact)
persisted to localStorage:
- List: thumbnail + description + inline metadata, one per row
- Compact: dense single-line rows (thumb, title, servings/time/
difficulty/date/visibility) for scanning large libraries
Selection mode and bulk actions work identically across all three.
Also fixes a real bug hit while wiring this up: "recipe.visibility"
was defined twice in the messages files — once as a flat string
("Visibility", used as the bulk-actions dropdown label) and once as
the {private,unlisted,public} label object. The object silently won
in JSON, so t("visibility") resolved to an object and next-intl threw
INSUFFICIENT_PATH the moment you opened that dropdown. Renamed the
flat one to visibilityMenuLabel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Version compare used positional (index-by-index) list alignment —
inserting one ingredient in the middle shifted every subsequent line
out of alignment, making the whole rest of the list look changed.
Switch to diffArrays (LCS-based) so insertions/deletions are
detected properly; adjacent removed+added chunks still get paired
for word-level diffing so a single edited item doesn't render as a
full delete+insert.
- Translated "Title"/"Description"/"Ingredients"/"Steps" diff section
headers, "Version History" sheet title, compare/restore buttons,
loading/empty states, and the version-detail ingredient/step count
summary (with proper plural forms).
- formatDate() in version history was hardcoded to "en-US" regardless
of the app's locale — now uses the session locale.
- ExportMarkdownButton had no hover tooltip (bare title attribute) —
wrap it in the same Tooltip pattern every other icon button in the
toolbar uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
proxy.ts's matcher excluded favicon.ico but not icon.svg, the PWA
manifest, or the service worker — logged-out requests for any of them
got 307-redirected to /login instead of the actual asset, so browsers
silently gave up on the tab icon (and PWA install/offline support was
broken the same way). Exclude the same static-asset set favicon.ico
already had.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full sweep of hardcoded English strings across:
- Meal pairing and drink pairing dialogs (titles, descriptions, role/
type labels, regenerate/generate buttons, progress labels) — also
fixed drink type labels that had French text hardcoded regardless
of locale ("Sans alcool"/"Chaud").
- Nutrition panel — had no useTranslations at all.
- Comments: header, empty/loading state, post/reply/cancel/delete
buttons, relative timestamps (just now/Xm ago/Xh ago/Xd ago), and
comment-reactions' toasts + aria-labels.
- Rating stars toasts.
- Direct messages: thread, conversation list, message button, both
/messages pages.
- People search page and component.
- URL import dialog (title, description, buttons, toasts).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- The interactive RatingStars widget was always initialized with
initialScore={0}, ignoring the current user's existing rating.
Fetch it via a third parallel query and pass it through — the
rating was persisted correctly all along, just never displayed
back.
- CommentReactions never fetched its counts on mount, relying solely
on initialCounts/initialUserReactions props that comments-section
always passed as empty — so every comment showed zero reactions
until you clicked one yourself (which returns fresh counts from the
POST response). Fetch on mount via the existing GET endpoint.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Added a shared ExportMarkdownButton (copy to clipboard / download .md)
next to every existing print button: recipe, shopping list,
collection, meal plan, pantry. Each surface gets a small serializer in
lib/markdown/ built from data already in scope at that page — no new
queries except pantry, where items now thread through as a prop to
PantryPageHeader instead of being fetched only for PantryManager.
Also fixes an unrelated bug hit while verifying the collection export:
RecipeCard called the client-only useTranslations() hook without
"use client", so it rendered fine everywhere it happened to run inside
an already-client tree but 500'd — "Couldn't find next-intl config
file" — when Next tried to run it as a Server Component, which only
happens on the collection detail page (its only caller). Collections
with recipes in them were completely broken.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/recipes/[id] never queried or displayed author info at all — viewing
someone else's public/unlisted recipe gave no way to see or visit
whoever wrote it. Add the author relation to the query and render an
avatar + "by {name}" link to /u/[username] under the title (hidden for
the owner's own recipes).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same issue as settings — fixed w-56 vertical sidebar with no
breakpoint squeezed all admin pages into a sliver on mobile. Horizontal
scrollable tab bar under md, normal sidebar above; "Back to app" moves
into the header row on mobile since the desktop footer placement is
hidden there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Settings sidebar was a fixed-width vertical list with no responsive
breakpoint, squeezing the form into a sliver on mobile. Turn it into
a horizontal scrollable tab bar under md, vertical sidebar above.
- setLocale() fired the PATCH and swallowed the result with
.catch(() => {}) — never checked res.ok, no success/failure feedback.
A failed save (expired session, network blip) looked identical to a
successful one. Now returns a boolean, reverts local state on
failure, and the settings form toasts success/failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- sourceUrl was already saved on URL import but never displayed —
render it as a link (hostname only) under the description.
- Extract cook-mode's wake lock into a reusable useWakeLock hook, add
visibility-change re-acquisition (a wake lock releases when the tab
goes background and won't come back on its own), and use it on the
regular recipe view too, not just cook mode.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
- 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>
DB/API already supported arbitrary-depth replies via the self-referencing
parentId, but the UI only rendered top-level + one reply level — a
reply-to-a-reply was fetched but never shown. Rewrite CommentItem as a
recursive component keyed off a parentId->children map; visual indent
caps at 4 levels (Reddit-style flatten) but nesting itself is unbounded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- 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>
- signOut() cleared the cookie but never navigated, leaving the user
stuck on the current page — redirect to /login on success.
- better-auth's core "image" field had no matching Drizzle column
(schema uses avatarUrl), so every OAuth signup threw
BetterAuthError: unable_to_create_user, silently failing before a
session was ever created. Remap via user.fields.image.
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.
Nav links were `hidden md:flex` with no mobile fallback — sub-768px
viewports showed only logo + avatar, no way to reach Explore, Feed,
Collections, etc. Add a Sheet-based hamburger menu with the same links.
- 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>
Settings layout header ("Settings" / "Manage your account,
preferences, and integrations.") was hardcoded English, shown on
every settings sub-page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>