Moderator role existed in the schema and was already respected by
comment deletion, but every admin page/route treated moderator
identically to a regular user (403/redirect). Wires it up narrowly:
admin/layout.tsx now lets admin+moderator through and filters the
nav by role, while every admin-only page (users, tiers, settings,
webhooks, insights, etc.) explicitly redirects moderators away via a
new requireFullAdminPage() helper -- the nav filter is UX, this is
the actual gate. Moderators land on Reports and Recipes: reports
GET/PATCH now accept requireAdmin({allowModerator: true}), and a new
PATCH /api/v1/admin/recipes/[id] lets admin+moderator unpublish a
public recipe (flip to private) as a takedown action, audit-logged.
Also found and fixed a real bug while auditing the PWA push pipeline
for a "push click-through" gap: public/sw.js had no `push` event
listener at all, so incoming push messages never displayed anything
-- push was silently non-functional end-to-end despite the
subscribe/send plumbing all working. Added the push listener
(showNotification) and a notificationclick listener that focuses an
existing tab or opens one at the payload's url.
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>
- 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>
Chatbot (general assistant + per-recipe Q&A) now resolves its default model
from its own site setting (DEFAULT_CHAT_PROVIDER/MODEL) instead of sharing
the generic "text" use case with recipe generation, so admins can point it
at a different model.
Added AI_TOOL_CALLING_ENABLED: turns off the chatbot's createRecipe/
addToShoppingList tools (and the forced-retry pass) for models that don't
reliably support tool calling — it falls back to plain text answers.
Simplified the admin AI Configuration page: it showed provider keys and
routing settings twice (a read-only card, then an identical edit form).
Merged into one editable section; the resolved fallback provider is now a
one-line note instead of its own card.
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>
Server logs (thanks to the user pulling them) showed the real error:
"Functions cannot be passed directly to Client Components" — the server
component page was passing formatShortDate/formatMonth as a `formatDate`
prop into TimeSeriesChart ("use client"). Functions aren't serializable
across the RSC boundary; the two previous fixes (query hardening,
Promise.allSettled) were real improvements but not the actual cause of
the reported crash.
TimeSeriesChart now takes a plain `dateFormat: "day" | "month"` string
and formats internally — BarChart was never affected (its formatValue
prop is only ever used via its own default, never passed from the page).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New Admin > Insights: signups/day and recipes-created/day (manual vs AI)
over the last 30 days, users by tier, recipes by visibility, monthly AI
call totals (6 months), and support tickets by status.
Two hand-rolled SVG chart components (bar-chart.tsx, time-series-chart.tsx)
instead of pulling in a charting library — avoids a new dependency and
any React 19 peer-dep risk. Both ship a hover tooltip (crosshair for the
time series, per-bar for the bar chart) and a "show as table" toggle per
the dataviz method's accessibility requirement.
Repurposed the app's existing (previously unused, grayscale-only)
shadcn --chart-1..5 CSS variables with a validated categorical palette —
run through the dataviz skill's CVD/contrast validator for both light and
dark surfaces (both pass; three light-mode slots fall under 3:1 contrast
by design, mitigated by the charts' direct value/axis labels).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).
Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).
The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.
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>
All literal "team" tier-value references renamed to "family" across
API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum
value itself is renamed in place via ALTER TYPE ... RENAME VALUE
(migration 0044) rather than drizzle-kit's auto-generated
drop-and-recreate-the-enum migration, which would have failed against
any existing row still holding 'team' — RENAME VALUE preserves
existing data with no cast/backfill needed.
Also adds STRIPE_PLAN.md — a full Stripe billing integration plan
(Checkout+Portal, tier→Price mapping, admin billing dashboard, and a
multi-user Family-group design since Family is meant to cover several
accounts under one subscription, not one payer). Planning only, no
Stripe code yet.
v0.47.0
Widens the tier enum from free/pro to free/pro/team and every
"free" | "pro" cast that assumed exactly two tiers (~30 call sites:
every AI route's withAiQuota/checkAndIncrementTierLimit call, admin
user/invite management, upload quota checks, OpenAPI schemas). Team
sits above Pro with genuinely unlimited recipes/public-recipes (the
-1 sentinel, which Pro doesn't actually use — Pro uses large finite
numbers instead) and a higher AI-call/storage cap. Seeded via
db:seed, editable afterward from Admin > Tiers.
role (user/moderator/admin — permissions) and tier (free/pro/team —
billing limits) stay separate concepts, as they already were; this
does not touch role-based permissions.
Requires migration 0043 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate` then `pnpm db:seed`.
v0.44.0
- Signups toggle was inverted (on = disabled) — flipped so on means
open, matching how every other on/off toggle in the app reads.
- Moved AI provider keys and default-model settings from Site
Settings to AI Config, so all AI setup lives in one place instead
of split across two pages with a cross-link.
- Admin overview: added new users/recipes (7d), recipes cooked (7d),
pending reports (linked, highlighted if > 0), storage used this
month, active webhooks, and API keys issued — previously just 4
lifetime totals.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.
Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- 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>
- 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>
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.
- 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>
Sticky sidebar with back-to-app link. Overview: user counts, recipe photos, AI calls/month.
User management with role/tier editing. Audit log (all admin actions).
Storage page with photo breakdown by tier. AI config showing DB vs .env key source.
Site settings page to override .env values at runtime (encrypted in DB).