Mirrors shopping lists' isPublic pattern but read-only only -- no
publicEditable equivalent, since anonymous editing of someone's
weekly meal schedule isn't a real use case the way collaborative
shopping-list editing is. New PATCH /api/v1/meal-plans/{weekStart}
toggles it (lazily creates the week's plan row if missing, same
pattern as the existing POST). Public page at /m/[id] renders the
week grouped by day, linking through to public/unlisted recipes and
just showing the title otherwise.
ShareMealPlanButton gained the same public-link toggle UI as its
shopping-list sibling, plus a QR code (generated client-side via the
already-installed `qrcode` package) for the link once enabled --
handing someone a printed or screenshotted plan doesn't require
typing a URL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a "Save offline" button on the recipe page that force-refetches
the current page so the service worker's cache picks up a fresh copy
right now, plus a small IndexedDB-backed list (lib/offline-db.ts) of
what's been saved. The /offline fallback page now reads that list and
renders it instead of being a dead end with just a "go back" link.
Also fixes the service worker's network-first fetch handler, which
never wrote successful responses into its cache -- meaning the
existing offline page's claim that "recently visited recipes are
available" was never actually true. It populates the cache on every
successful GET now.
Background sync: marking a batch-cook dish as cooked while offline
(the only existing mark-cooked call site in the app) now queues the
request in IndexedDB instead of just failing, and registers a
Background Sync (public/sw.js's "sync" listener replays the queue)
for Chromium; lib/offline-queue.ts's online-event fallback covers
Safari/Firefox, which never fire that event at all. Both replay paths
read/write the same IndexedDB store so either one drains it.
Also removes apps/web/public/manifest.json, a stale static manifest
that layout.tsx used to link to before the previous commit pointed it
at the real generated route (app/manifest.ts) -- it had gone
unnoticed and unused since.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Outbound (already existed one-way: ticket create -> Gitea issue) now
also mirrors status changes: closing/reopening a ticket in the admin
UI closes/reopens the linked Gitea issue, and replies posted from
Epicure (by the ticket owner or an admin) post as a comment on the
issue. Captures and stores the Gitea issue number at creation time
to address these follow-up calls without re-parsing the issue URL.
Inbound: new webhook receiver at /api/webhooks/gitea, verified via
HMAC-SHA256 signature (X-Gitea-Signature) with a configurable
GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same
way the existing Stripe receiver dedupes events. Handles "issues"
(closed/reopened -> ticket status) and "issue_comment" (created ->
appends to the ticket's comment thread), skipping comments Epicura
already posted itself (matched by Gitea comment id) to avoid loops.
New support_ticket_comments table backs a lightweight conversation
thread on both the user-facing support page and the admin support
manager, each comment tagged by author (user/admin/gitea).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Feature toggles (Settings -> Features) were filtered into
visibleNavItems but the desktop horizontal nav still mapped over the
raw NAV_ITEMS list, so hiding a feature only worked on mobile. Both
nav renders now read the same filtered list.
Also adds a Chatbots toggle covering the recipe cooking-chat panel
and the recipe-list cooking assistant, wired end-to-end (schema,
migration, feature-prefs lib, API route, settings form, i18n,
openapi).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Notification email preferences: every push category (follow, comment,
reply, reaction, rating, mention, leftoverExpiring, shoppingList) now has
an independent email toggle, plus a Weekly Digest toggle. Previously email
sent unconditionally whenever the recipient had one; now gated the same
way push already was. The weekly-digest cron route excludes opted-out
users.
- Admin-only site-wide webhooks (Admin → Webhooks): new signups, support
tickets, and reports filed can now fire an HMAC-signed HTTP webhook
(Slack/Discord/ops alerting), independent of the existing per-user
webhooks (which stay scoped to a user's own recipe/meal-plan/shopping-list
events). Signing/delivery logic factored into lib/webhook-delivery.ts and
shared by both dispatchers instead of duplicated.
- Settings → Features: users can hide Nutrition, Pantry, Meal Plan,
Shopping Lists, Collections, or Messages from their own nav. Purely
cosmetic — hidden pages stay reachable by direct link, nothing is
access-restricted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Step timer input was seconds-only, no unit — a 90-minute braise meant
typing 5400. Added a seconds/minutes/hours <select> next to the input;
StepRow gets a timerUnit field, converted to seconds at submit. Editing
an existing recipe (and the AI-regenerate flow) picks the largest unit
that divides evenly into the stored seconds so it displays naturally
instead of always falling back to raw seconds.
Ingredient list (serving-scaler.tsx): the quantity column used
min-w-[3rem] on a flex child, which is only a *minimum* — any row whose
formatted quantity text (e.g. an appended "(~2 tbsp)" conversion) exceeded
that width pushed just that row's ingredient name further right,
breaking alignment across the list. Switched the list to a CSS grid with
`display: contents` on each <li>, so the quantity column's width is
shared across every row instead of sized per-row.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New /c/[id] route mirrors /r/[id]'s pattern for recipes: no session
required, gated on collections.visibility != 'private', with an explicit
followers-only check (viewer must be signed in and follow the owner)
since this route has no other access control. Added to proxy.ts's
PUBLIC_PATHS.
Only shows recipes inside the collection that the anonymous/signed-in
viewer could actually see on their own (public/unlisted always, followers
recipes only if following that recipe's author) — the owner's own private
recipes stay hidden from everyone else even inside their own public
collection.
Collection PDF export's QR code and the collection page's new "view
publicly" icon (public visibility only, matching the recipe page's same
rule) both now point here instead of the auth-gated /collections/{id}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.
New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.
Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.
Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
Seven related improvements to collections:
- Drag-and-drop reorder (dnd-kit, same pattern as the shopping list) — new
collection_recipes.position column (migration 0049, backfilled from
existing added_at order so nothing jumps around on upgrade).
- Search collections by name/description (server-side, list page) and
search recipes within a collection (client-side filter, already loaded).
- Edit collection: name/description/tags/private notes via a new dialog;
new collections.notes + collections.tags columns.
- Delete collection with a choice to also delete its recipes — only ones
the deleting user actually owns, never recipes shared in by others.
- Collection detail (both owner and public view) now renders the same
RecipeGridCard used on /recipes, instead of the older, plainer RecipeCard.
- Collection list cards redesigned — photo-collage preview (first 4 recipe
covers/placeholders), tag badges, cleaner layout.
- Fixed the recipe count shown on a collection card: the query capped the
`recipes` relation at 1 for thumbnail purposes and then read `.length`
off that same capped array, so it never showed more than 1. Now a
proper grouped count query, separate from the thumbnail fetch.
New/changed endpoints documented in OpenAPI: PATCH /collections/{id}/reorder,
DELETE /collections/{id}?deleteRecipes, PUT /collections/{id}'s new
notes/tags fields.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Two changes to the no-photo cover placeholder shipped last version:
1. Muted the gradient palette (100/40-opacity tints instead of solid -200/
-950 stops) — the original was too saturated next to real cover photos
in the same grid, per feedback.
2. New coverIcon/coverColor columns on recipes (nullable text, migration
0048, additive-only) let the author pin a specific color+icon from the
recipe editor instead of the automatic per-id pick. getRecipePlaceholder
now checks these first, falling back to the deterministic hash pick when
unset — existing recipes are unaffected until edited.
Wired coverIcon/coverColor through every explicit-column recipe select
that feeds a grid card (explore trending/recent, search, feed, for-you) —
the relational-query call sites (recipes page, collections, profile pages)
already return all columns and needed no changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.
New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Users can now report bugs, suggestions, or questions from a new /support
page. Each submission sends a confirmation email and, when GITEA_URL/
GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled
issue on that repo automatically (best-effort — failure doesn't block the
ticket). Admins get a Support section to triage status and retry failed
Gitea issue creation.
New support_tickets table, gitea.ts client, site-settings entries for the
three new secrets, and OpenAPI docs for the two user-facing endpoints.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Language switcher was two inline emoji-flag buttons -- emoji flag
rendering is inconsistent across OS/browsers (some render as two-letter
country codes instead of a flag). Replaced with hand-rolled inline SVG
flags (no new dependency for two locales) inside a proper dropdown
menu, trigger showing the current language's flag.
Also added a 9th Home highlight (personalized recommendations, same
copy already written for the Features page) so the lg:grid-cols-3
feature grid's last row has 3 items instead of 2.
v0.48.2
Language switcher (flag toggle) and dark-mode toggle in the marketing
header, both usable by logged-out visitors -- the marketing pages
previously hardcoded English (getMessages(undefined)) regardless of
any preference, and had no theme control since the authenticated
Nav's toggle isn't rendered there. New cookie-based locale resolution
(lib/marketing-locale.ts) separate from the authenticated app's
useLocale/setLocale, which persists via an authenticated PATCH that
would just 401 and revert for an anonymous visitor. Root layout now
falls back to that cookie for <html lang> when there's no session.
Features/Home now cover cook mode, recipe variations, personalized
recommendations, and ingredient substitution -- all real, shipped
features that were missing from the page copy.
Signup CTAs check isSignupsDisabled() and swap to "Signups closed"
instead of "Get started free" -- still linking to /signup, since that
page already handles the invite-token exception correctly.
Fixed along the way: importing the cookie-name constant from
marketing-locale.ts in the client-side LanguageSwitcher pulled that
file's server-only imports (lib/auth/server -> the DB client ->
`postgres`) into the client bundle, breaking the build on Node
built-ins. Split the constant into its own client-safe file
(marketing-locale-cookie.ts). Caught by `pnpm build`, not typecheck.
Verified with typecheck, lint, and a full production build (clean
compile) -- no DB in this sandbox to click through a real dev server.
v0.48.1
Adds a (marketing) route group -- Home, Features, About, Privacy,
Terms, Contact -- reusing the app's design system/i18n/deploy
pipeline rather than a separate site. Root page.tsx now branches on
session instead of always redirecting to /recipes: logged-in visitors
still land in the app unchanged, logged-out visitors see the vitrine
home page.
The actual integration point: proxy.ts's PUBLIC_PATHS previously sent
every anonymous "/" request straight to /login before page.tsx's
redirect ever ran. Added the new marketing routes to PUBLIC_PATHS,
plus a separate exact-match list for "/" itself -- it can't go in the
startsWith-matched array, since every path starts with "/" and that
would make the whole app public.
Also adds app/sitemap.ts and app/robots.ts (neither existed before),
disallowing the authenticated app and the unlisted /r/ and /s/ share
routes from crawling while allowing /u/ public profiles.
Pricing page deliberately not included -- waiting on Stripe Checkout
(STRIPE_PLAN.md) so its CTA goes somewhere real. Privacy/Terms are
structural drafts flagged inline as not lawyer-reviewed, per
STRIPE_PLAN.md's own tax-advice caveat -- same spirit here.
Verified with a full production build (pnpm build) -- compiles clean,
all 7 new routes render, since there's no DB in this sandbox to run
the dev server against for a real click-through.
v0.48.0
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
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
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
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
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
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
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
Both chat panels were locked to a fixed 420px slide-over with no way
to get more room for longer conversations. Adds an expand/collapse
button in the header that grows the sheet to fill the viewport.
v0.38.0
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
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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).
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.
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).