From cced962bffda568bf617eda0acb0206e14a1b69d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 20 Jul 2026 22:30:55 +0200 Subject: [PATCH] chore: reorganize planning docs into plans/, drop stale security-audit docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves PLAN.md, STRIPE_PLAN.md, VITRINE_PLAN.md, WHATS_NEW_PLAN.md into a gitignored plans/ directory instead of tracking them individually. Drops SECURITY_AUDIT.md and its follow-up — session-scoped audit notes, already captured in CHANGELOG.md and the actual code fixes. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 +- SECURITY_AUDIT.md | 60 ------ SECURITY_AUDIT_2026-07-20_followup.md | 37 ---- STRIPE_PLAN.md | 284 -------------------------- VITRINE_PLAN.md | 93 --------- WHATS_NEW_PLAN.md | 101 --------- 6 files changed, 1 insertion(+), 576 deletions(-) delete mode 100644 SECURITY_AUDIT.md delete mode 100644 SECURITY_AUDIT_2026-07-20_followup.md delete mode 100644 STRIPE_PLAN.md delete mode 100644 VITRINE_PLAN.md delete mode 100644 WHATS_NEW_PLAN.md diff --git a/.gitignore b/.gitignore index 85f2dcb..658f172 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,4 @@ coverage/ # Misc .claude/ project_epicure.md -PLAN.md +plans/ diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md deleted file mode 100644 index a1f04e8..0000000 --- a/SECURITY_AUDIT.md +++ /dev/null @@ -1,60 +0,0 @@ -# Epicure Security Audit - -Two-round audit: round 1 = 5 parallel domain sweeps (auth, API routes, DB/tier-limits, storage, AI/chatbot); round 2 = independent adversarial verification of every finding that survived round 1. Date: 2026-07-20. - -**Status: all findings below fixed and re-verified by a fresh adversarial pass (2026-07-20, later same day) — see "Fix verification" under each.** - -## Confirmed findings - -### 1. TOCTOU race in recipe/storage tier-limit enforcement — Medium — FIXED -**File:** `apps/web/lib/tiers.ts` (recipe/storage branches of `checkAndIncrementTierLimit`) -**Call sites:** `apps/web/app/api/v1/recipes/route.ts`, `apps/web/app/api/v1/upload/presign/route.ts`, and (found during fix-verification, see below) several AI recipe-creation routes that had no check at all. - -Check did a live `SELECT COUNT`/`SUM`, compared to limit, returned — no transaction or lock tied it to the later insert. Two concurrent requests near the cap could both read the pre-insert count, both pass, both write — exceeding lifetime `maxRecipes`/`storageMb` via a trivial `Promise.all` script. - -**Fix:** added `checkTierLimitInTransaction(tx, userId, fallbackTier, key, amount)` to `lib/tiers.ts` — runs `pg_advisory_xact_lock(hashtext(userId))` as the first statement inside the caller's `db.transaction`, then re-checks the live count/sum against that same transaction, before the insert/update runs. A concurrent request for the same user blocks on the lock until the first commits/rolls back, then sees its committed row. Applied as the first statement inside the write transaction in: -- `recipes/route.ts` (POST — recipe count + photo storage) -- `recipes/[id]/route.ts` (PUT — storage delta on photo add/remove) -- `recipes/[id]/fork/route.ts`, `recipes/[id]/rate/route.ts`, `users/me/route.ts` (avatar) -- `ai/adapt/[id]/route.ts`, `ai/generate-meal/route.ts`, `ai/meal-plan/generate/route.ts`, `ai/translate/[id]/route.ts`, `ai/import-photo/route.ts`, `ai/generate-from-idea/route.ts`, `ai/batch-cook/generate/route.ts` - -**Fix verification:** a fresh adversarial pass confirmed the lock+check+write are genuinely atomic per-user in every listed call site, and also caught that four AI recipe-creation routes (`import-photo`, `generate-from-idea`, `batch-cook/generate`, `translate/[id]`) had **no tier check at all** (not even the old unlocked pre-flight) — an unconditional bypass of `maxRecipes`, broader than the original race. All four now call `checkTierLimitInTransaction` inside their insert transaction. Presign routes (`upload/presign`, `upload/avatar-presign`) still only do the old unlocked pre-flight check — this is fine, since storage is derived live from real rows (not a counter), so presigning alone never moves the accounted total; the authoritative, locked check now happens at write time (recipe save, rating save, avatar save) instead. - -### 2. No rate limit on 5 AI endpoints, bypassable further by BYOK — Medium — FIXED -**Files:** `apps/web/app/api/v1/ai/adapt/[id]/route.ts`, `ai/drinks/[id]/route.ts`, `ai/pairings/[id]/route.ts`, `ai/translate/[id]/route.ts`, `ai/variations/[id]/route.ts` - -None of these called `applyRateLimit`, unlike siblings `ai/generate`, `ai/scale`, `ai/substitute`, `ai/recipe-chat`, `ai/import-url`, which all throttle per-user via Redis. - -**Fix:** added `applyRateLimit(\`rl:ai:${userId}\`, 10, 60)` to all 5, placed immediately after the session check, matching the sibling routes' convention and rate class (one-shot single-recipe AI transforms). - -**Fix verification:** confirmed correct placement (before any DB/AI work), correct per-user keying, and consistent limit choice against siblings. Re-swept the entire `ai/` route tree — no other endpoint is missing a rate limit. BYOK still bypasses the usage quota (`skipQuota: isByok`) but not the rate limit itself — same behavior as every other AI route in the codebase, not a gap introduced or left open by this fix. - -### 3. `search` page has no server-side session check — Low — FIXED -**File:** `apps/web/app/(app)/search/page.tsx` - -Rendered `SearchPageContent` with zero session check, unlike sibling pages. Low-impact even before the fix: the underlying API route is intentionally public (query hard-filtered to public recipes regardless of session), and `proxy.ts` (the actual edge guard — `middleware.ts` was removed in an earlier commit, contrary to what `CLAUDE.md` claimed at audit time) already redirects unauthenticated requests to `/login` for non-public paths. The missing page-level check was a defense-in-depth/consistency gap, not a live data-leak. - -**Fix:** added the same `auth.api.getSession` / `if (!session) return null;` check used by every sibling page. Also corrected `CLAUDE.md`'s stale auth section to describe `proxy.ts` (not the removed `middleware.ts`) plus the per-route checks as defense-in-depth. - -**Fix verification:** confirmed the added check matches the established convention exactly (checked against 9 sibling pages). No regression. - -## Minor / non-blocking — FIXED - -- `apps/web/app/api/v1/admin/settings/route.ts` reimplemented `requireAdmin()` locally instead of importing the shared `lib/api-auth.ts` version. **Fixed**: now imports and uses the shared `requireAdmin`. Response code for the no-session case changed from 403 to 401 (shared helper's behavior) — verified this is a correctness improvement, not a breaking change: no frontend caller branches on that specific status code, and `/admin/*` is already gated upstream by `proxy.ts`. Updated `lib/openapi.ts`'s documented responses to include 401. Swept all other admin routes — none hand-roll their own admin check; this was the only duplicate. -- `apps/web/lib/ai/user-bio.ts`'s `buildUserBioContext` interpolates the user's own `privateBio` directly into the system prompt — self-injection only, not a cross-user vector. Flagged for completeness only, no fix needed. - -## Clean (checked, no issues found) - -- **Auth/authz:** role/tier always re-read from DB (`requireAdmin`, `checkAndIncrementTierLimit`, moderator-delete branch), never trusted from session cookie cache. No IDOR, no open redirect, no hardcoded secrets, no timing-attack-relevant comparisons. -- **API routes:** ownership scoping consistent across recipes, meal-plans, shopping-lists, collections, comments, webhooks. No mass assignment (explicit field allowlists). SSRF closed via IP-pinning + redirect re-validation in `safeFetch`/`validate-webhook-url.ts` (covers RFC1918/loopback/link-local/multicast, IPv4 + IPv6). File uploads validate content-type/size server-side, server-generated keys (no path traversal). Auth rate limiter backed by Redis (not per-replica in-memory). Error responses sanitized, no stack-trace leakage. -- **DB/SQL:** all raw `sql\`\`` usage is parameter-bound, not string-concatenated. `search` route explicitly escapes `%`/`_`/`\` before `ilike`. ~30 spot-checked update/delete sites all scope by owner or a pre-verified `findFirst`. Stripe webhook updates `users.tier` keyed by webhook-verified identifiers, not client input. -- **Storage/S3:** keys always server-built (UUID-based, never from client filenames). Presigned POST (not bare PUT) with signed `content-length-range`/`Content-Type` conditions, 300s expiry, single-key scope. Ownership re-checked via `isOwned*Key` helpers before any writeback of a client-submitted key. Deletes only ever use DB-read-back keys after owner-scoped fetch. Secrets never reach the client bundle. -- **AI/chatbot:** tool-calls (`create-recipe-tool`, `add-to-shopping-list-tool`) don't write to the DB directly — persistence goes through normal authz-checked API routes after user confirmation. No "fetch/modify by ID" tool exists to exploit. Admin AI settings allowlisted + audit-logged. Secrets masked in `site-settings.ts`. Non-BYOK AI calls are quota-checked and rate-limited (all AI routes, after finding #2's fix). Model output rendered as plain React text (no `dangerouslySetInnerHTML` in chat paths) — no XSS via model output. - -## Round-2 verification notes - -All three confirmed findings were independently re-verified by a second agent reading the code fresh, with one correction: the original search-page write-up attributed the low severity to the API route being "auth-protected" — round 2 found that's wrong, the API is intentionally public by design, not gated. Net severity assessment unchanged. - -## Fix-verification round (2026-07-20, later same day) - -After implementing fixes for all three confirmed findings plus the minor `requireAdmin` duplication, ran a second independent adversarial pass specifically against the fix diff (two parallel reviewers, one per fix area). Outcome: findings #2, #3, and the minor `requireAdmin` item confirmed cleanly fixed with no regressions. Finding #1's fix was confirmed correct for every call site it touched, but the same pass caught a **broader pre-existing gap** in the same area — four AI recipe-creation routes with no tier-limit check whatsoever — which has since also been fixed and included in the "Fix" description above. No further issues found; typecheck, lint, full test suite (191/193 passing — the 2 failures are pre-existing and unrelated to this session, see git history), and production build all pass clean. diff --git a/SECURITY_AUDIT_2026-07-20_followup.md b/SECURITY_AUDIT_2026-07-20_followup.md deleted file mode 100644 index 4b38008..0000000 --- a/SECURITY_AUDIT_2026-07-20_followup.md +++ /dev/null @@ -1,37 +0,0 @@ -# Follow-up: dependency CVEs + OpenAPI coverage - -Companion to `SECURITY_AUDIT.md` (same day, same session). Two additional checks requested after the tier-limit/rate-limit fixes shipped. - -## 1. Dependency vulnerabilities (`pnpm audit`) - -**Before:** - -| Severity | Package | Issue | Fixed in | -|---|---|---|---| -| High | `drizzle-orm@0.44.7` | SQL injection via improperly escaped SQL identifiers ([GHSA-gpj5-g38j-94v9](https://github.com/advisories/GHSA-gpj5-g38j-94v9)) | `>=0.45.2` | -| Moderate | `esbuild@0.18.20` (transitive, via `drizzle-kit` → `@esbuild-kit/esm-loader`, dev-only) | esbuild dev server accepts requests from any origin ([GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99)) | `>=0.24.3` | -| Moderate | `postcss@8.4.31` (transitive, via `next@16.2.9`) | XSS via unescaped `` in CSS stringify output ([GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93)) | `>=8.5.10` | - -**Fix:** -- Bumped `drizzle-orm` to `^0.45.2` directly in `apps/web/package.json` and `packages/db/package.json` (it was a top-level dependency in both, not just transitive — straightforward version bump, no breaking API changes hit typecheck/tests/build). -- Added `overrides` in `pnpm-workspace.yaml` (pnpm 11 moved this out of `package.json`'s `pnpm.overrides`, which is silently ignored — that field was removed) forcing `esbuild: ">=0.24.3"` and `postcss: ">=8.5.10"` across the whole dependency tree, collapsing the vulnerable transitive copies into already-present patched versions (both packages already had a patched instance installed for other consumers — this just eliminates the vulnerable *second* copy rather than introducing a new major version). - -**After:** `pnpm audit` → `No known vulnerabilities found`. - -**Verification:** full re-run of `pnpm typecheck`, `pnpm exec vitest run` (191/193 passing — the 2 failures are the same pre-existing, unrelated baseline failures noted throughout this session), and `pnpm build` (production build succeeds, including the Tailwind/PostCSS pipeline under the new override) — all clean after the bump + overrides. - -## 2. OpenAPI documentation coverage - -Cross-referenced every `route.ts` under `apps/web/app/api/v1/**` (which HTTP methods each file exports) against every `registry.registerPath({ method, path, ... })` call in `apps/web/lib/openapi.ts`. - -**Before:** two endpoints existed with zero OpenAPI documentation: -- `GET /api/v1/admin/support` — list all support tickets (admin only) -- `PATCH /api/v1/admin/support/{id}` — update a ticket's status, or retry its Gitea issue creation (admin only) - -Everything else (109 other route/method pairs) was already documented, and there were no stale entries (documented paths with no matching route file). - -**Fix:** added `AdminSupportTicketRef` / `UpdateAdminSupportTicketRef` schemas and both `registerPath` entries to `lib/openapi.ts`, next to the existing `admin/reports` entry, matching the response/error shapes actually returned by the two route handlers. - -**After:** re-ran the cross-reference — full parity. The only "missing" entry is `GET /api/v1/openapi.json` itself, which doesn't need to self-document. - -**Verification:** `pnpm typecheck` and `pnpm exec vitest run` clean after the addition (the new refs slot into the existing Zod-based OpenAPI registry with no schema conflicts). diff --git a/STRIPE_PLAN.md b/STRIPE_PLAN.md deleted file mode 100644 index 828f9e6..0000000 --- a/STRIPE_PLAN.md +++ /dev/null @@ -1,284 +0,0 @@ -# Stripe Integration Plan - -## Status: planning only — nothing in this doc is implemented yet - -This plan builds on infrastructure that already exists in the repo (from an -earlier security-audit pass), not a blank slate. Before touching anything, -know what's already there: - -**Already built:** -- `users.tier` enum column (`free | pro | family`), `packages/db/src/schema/users.ts:33` -- `users.stripeCustomerId` (nullable, unique), `users.ts:34` — migration `0013_damp_richard_fisk.sql` -- `processed_stripe_events` table (webhook dedup log), `packages/db/src/schema/billing.ts:7-11` — migration `0024_moaning_roughhouse.sql` -- `tierDefinitions` table (per-tier numeric limits), `packages/db/src/schema/tiers.ts:14-20`, admin-editable via `/admin/tiers` + `TierLimitsForm` -- A **hand-rolled** inbound webhook at `apps/web/app/api/webhooks/stripe/route.ts` — manual HMAC-SHA256 signature verification (`t=`/`v1=` parsing, `crypto.timingSafeEqual`, 300s tolerance window), handling exactly two event types (`checkout.session.completed`, `customer.subscription.deleted`), everything else silently ignored -- `STRIPE_WEBHOOK_SECRET` env var, read directly via `process.env`, not through `site-settings.ts` -- `apps/web/app/api/v1/admin/users/[id]/route.ts` — a **second, independent** path that can set `users.tier` (manual admin override), which will need to coexist with Stripe-driven tier changes without fighting them - -**Not built at all:** -- The `stripe` npm package — nothing in the repo imports it; the existing webhook route re-implements signature verification instead of using `stripe.webhooks.constructEvent` -- Checkout Session creation, Customer Portal, Price/Product IDs anywhere, `lib/stripe.ts`, a Billing admin page, handling for `subscription.updated` / `invoice.payment_failed` / trial events - -The plan below fills those gaps and asks you to make a handful of product -decisions before code starts (marked **DECISION NEEDED**). - ---- - -## 1. Scope & product decisions (answer before implementation starts) - -These aren't engineering choices — they change what gets built. Flagging -rather than guessing: - -- **Pricing amounts — recommended, see §1c.** Pro €4.99/mo (€39/yr), Family €8.99/mo (€69/yr). Still a decision to confirm, not locked — §1c has the full reasoning. -- **Family tier is multi-user, confirmed.** One subscription, shared by several app accounts (Netflix/Spotify-Family model) — not one-tier-per-payer like Pro. This needs a new schema concept (§1a) and changes how a member's effective tier gets resolved. See below. -- **DECISION NEEDED — Family member cap.** How many accounts per family subscription? (Netflix Family = 5, Spotify Family = 6.) A flat number, enforced app-side — recommend **5** as a default, easy to change later since it's just a constant, not a schema value. -- **DECISION NEEDED — Downgrade/cancellation behavior.** Cancel immediately (lose Pro/Family features now) or at period end (keep access until the paid period runs out, matching what Stripe bills)? Recommend **at period end** — standard SaaS expectation, and Stripe's Customer Portal defaults to this. For Family specifically, also decide: if the owner cancels, do members lose access immediately at that point, or also ride out the period end? Recommend the latter for consistency. -- **DECISION NEEDED — Trial period?** None, or e.g. 14 days no card required? Affects Checkout Session config (`subscription_data.trial_period_days`) and whether `tier` should flip to `pro` optimistically before payment or wait for `checkout.session.completed`. -- **Proration** — Stripe handles this automatically on plan-switch (Pro↔Family); no schema work needed, just don't fight it by writing custom proration logic. Doesn't apply to adding/removing family members, since that's flat-price (§1a), not quantity-based. - -Everything else below assumes: monthly + optional annual price per paid tier, flat-price Family (not per-seat billing), cancel-at-period-end, and trial handling deferred until the above is answered (the plumbing supports adding it later without further schema changes). - ---- - -## 1a. Family groups — the multi-user piece (Netflix-style, decided) - -This is genuinely new scope, not just a billing detail — Stripe only ever -bills one Customer per subscription; grouping several *app accounts* under -that one subscription is something Epicure has to model itself. - -**Decided: flat price, capped membership, no per-seat billing** — one -Stripe Price for "Family" regardless of exact member count up to the cap -(§1, default 5), same shape as Netflix/Spotify Family. No Stripe API calls -on join/leave — membership is purely an app-side concern; Stripe only ever -sees the owner's one subscription. (Ruled out: per-seat/quantity-based -billing, where the subscription's `quantity` tracks member count and every -join/leave calls `stripe.subscriptions.update` — more correct if you wanted -to charge per head, but that's not what "Family" means here.) - -**New tables** (`packages/db/src/schema/billing.ts`, alongside `processedStripeEvents`), same shape as the sharing tables you already have (`collectionMembers`, `mealPlanMembers`): - -```ts -export const familyGroups = pgTable("family_groups", { - id: text("id").primaryKey(), - ownerId: text("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }), - stripeSubscriptionId: text("stripe_subscription_id"), // the owner's subscription — billing lives here, not per-member - createdAt: timestamp("created_at").notNull().defaultNow(), -}); - -export const familyMembers = pgTable("family_members", { - id: text("id").primaryKey(), - groupId: text("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }), - userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(), // a user is in at most one family group - joinedAt: timestamp("joined_at").notNull().defaultNow(), -}); -``` - -**Tier resolution changes.** Today `apps/web/lib/tiers.ts:42-43` reads `users.tier` straight from the DB as the source of truth. With family groups, a member's *effective* tier is no longer just their own column — it's "family" if they're in a group whose owner has an active subscription, regardless of what their own `users.tier` sits at. `checkAndIncrementTierLimit` (and anywhere else `users.tier` is read for gating, e.g. `getMessages`'s usage-quota-section in §7) needs a resolution step: look up group membership first, fall back to the user's own `tier` column if not in a group. Keep the owner's own `users.tier` as the real, webhook-driven value (that's what Stripe events update, per §5) — members don't get their own `tier` column changed at all, they're resolved dynamically through the group. - -**Invite flow** — an owner needs a way to invite people into their family group (email invite or a shareable link, similar to the existing `invites` table/flow used for beta signups — `apps/web/lib/invites.ts` is a close precedent for the invite-token mechanics, though that system is signup-time only and would need adapting for "join an existing user into a group"). New endpoints: `POST /api/v1/family/invite`, `POST /api/v1/family/join/{token}`, `DELETE /api/v1/family/members/{userId}` (owner removes someone, or a member leaves). - -**What happens if the owner's subscription lapses** (`customer.subscription.deleted` webhook) — every member in that group loses "family" status simultaneously, purely as a side effect of the tier-resolution fallback above (no per-member cleanup needed, since membership rows aren't what grants tier — the group's subscription status is). The group row itself can stay (in case they resubscribe) or get cleaned up — low-stakes choice, doesn't affect billing correctness either way. - ---- - -## 1b. Promotions/discounts - -Stripe already has this fully built (Coupons + Promotion Codes) — no -custom discount logic needed. Two layers, both worth doing: - -- **Checkout-side (do this, it's one flag):** pass `allow_promotion_codes: true` - when creating the Checkout Session (§5). Stripe then shows a "Add promotion - code" field on the hosted Checkout page itself — percent-off, fixed-amount-off, - free trial extension, redemption limits, expiry dates, first-time-customer-only, - all handled by Stripe, none of it built by us. -- **Creating/managing codes:** done in the **Stripe Dashboard**, not Epicure's - admin — Stripe's Coupon/Promotion-code UI already covers this well, and - duplicating it would be pure rebuild-the-wheel. No API integration required - for this part at all. -- **Optional — admin visibility page:** if you want to see active promotions - without leaving Epicure, a read-only `/admin/billing` widget can list them via - `stripe.promotionCodes.list({ active: true })` (a few lines, uses the same - `lib/stripe.ts` client from §2). This is display-only, reading Stripe's data — - it does not need its own schema, table, or write path. Nice-to-have, not - required for promotions to work. - -So: promotions work the moment `allow_promotion_codes: true` ships with -Checkout — everything else here is optional polish. - ---- - -## 1c. Recommended pricing (resolves the §1 "pricing amounts" decision) - -A starting recommendation, not a locked-in number — see the "adjust after -launch" note at the end. - -| Tier | Monthly | Annual | Annual discount | -|---|---|---|---| -| Free | €0 | — | — | -| Pro | **€4.99** | **€39** | ~35% off (39 vs 59.88) | -| Family | **€8.99** | **€69** | ~36% off (69 vs 107.88) | - -**Why these numbers, not round-trip guesses:** - -- **Stripe's fee floor makes very cheap pricing inefficient.** Domestic EEA rate is 1.5% + €0.25 (§10). At €0.99/month that flat €0.25 alone is >25% of revenue before the percentage even applies — pricing has to clear roughly €3–4 before the fixed component stops dominating. €4.99 clears it comfortably. -- **Priced against the AI cost the tier actually permits**, not against competitors. `tierDefinitions` already caps Pro at 500 AI calls/month, Family at 2000 (`packages/db/src/schema/tiers.ts`). Those calls cost real money once they leave OpenRouter's free tier — €4.99 needs to cover the *realistic* per-user AI spend plus Stripe's cut plus margin, not just "feel competitive." The actual per-call cost depends on which model users end up on (admin-configured default, or BYOK bypasses this entirely per `aiConfig.isByok` skip-quota logic already in `withAiQuota`) — **this is the part most likely to be wrong before you have real usage data**, not the psychology of the price itself. -- **Annual discount (~35%) is the standard SaaS anchor** — enough to visibly reward committing, not so steep it cannibalizes monthly revenue or makes monthly pricing look like a rip-off by comparison. Also improves cash flow (a year of revenue upfront) and reduces monthly churn-management overhead (11 fewer renewal events per converted customer per year). -- **Family priced below 2× Pro on purpose.** €8.99 vs €9.98 (2 × €4.99) — the whole pitch of a household plan is "cheaper than everyone buying Pro separately." If Family cost the same or more than 2 individual Pro subscriptions, nobody rational picks it over just having each person subscribe individually (or one person subscribing and not sharing at all, which the member cap in §1a doesn't prevent — sharing outside the app isn't something billing can stop, only the in-app group feature needs pricing that makes *joining the group* the obviously better option over each member paying separately). -- **Family's 4× AI-call allowance (2000 vs Pro's 500) for less than 2× the price** is intentional generosity, not an oversight — it's the concrete value-per-euro reason to pick Family over "everyone just buys their own Pro," beyond the flat convenience of one payment. - -**Adjust after launch, and adjust the price — not the AI-call limits.** Limits are what users plan around; changing them after people are used to a number reads as a downgrade even if the price stays flat. Price is the variable meant to absorb reality once you have actual OpenRouter spend-per-active-user data — if real costs run higher than assumed here, raise Pro/Family prices (existing subscribers keep their price until they actively resubscribe/change plans, per Stripe's normal behavior — see §8's note on the admin manual-override path for edge cases), don't quietly shrink what €4.99 already promised someone. - ---- - -## 2. Package & client setup - -- Add `stripe` (server SDK) to `apps/web/package.json`. Do **not** add `@stripe/stripe-js`/`@stripe/react-stripe-js` — Checkout/Portal are hosted redirects, no Stripe Elements needed for v1 (see §7 for why hosted over custom). -- New `apps/web/lib/stripe.ts` — single `Stripe` client instance (mirrors the existing provider-factory pattern in `lib/ai/factory.ts`: one place that constructs the third-party client from a resolved secret key, everything else imports from here). -- **Replace** the hand-rolled verifier in `apps/web/app/api/webhooks/stripe/route.ts` with `stripe.webhooks.constructEvent(rawBody, signature, secret)`. Same security properties (timing-safe compare, timestamp tolerance), less code to maintain, and gives typed `Stripe.Event`/`Stripe.Checkout.Session`/etc. instead of the current loosely-typed inline interface at route.ts:71. Keep the `processed_stripe_events` dedup insert exactly as-is — it's correct and SDK-agnostic. - ---- - -## 3. Secrets & config - -Follow the existing `site-settings.ts` pattern (`apps/web/lib/site-settings.ts`) rather than plain env vars — it already does AES-256-GCM encryption for exactly this kind of admin-managed secret (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.), and it's the mechanism the admin UI in §6 will read/write. - -- Add to `SiteSettingKey` union: `"STRIPE_SECRET_KEY"`, `"STRIPE_PUBLISHABLE_KEY"`, `"STRIPE_WEBHOOK_SECRET"`. -- Add `"STRIPE_SECRET_KEY"` and `"STRIPE_WEBHOOK_SECRET"` to `SECRET_KEYS` (encrypted at rest). Publishable key is not secret — store it plain, same as the VAPID public key is treated today. -- `lib/stripe.ts`'s client construction calls `getSiteSetting("STRIPE_SECRET_KEY")` (DB → env fallback, exactly like every other provider key already does) instead of reading `process.env` directly. -- Update `.env.example`'s existing `# Stripe (optional — webhook stub only)` section: keep `STRIPE_WEBHOOK_SECRET` as the bootstrap/self-hosted fallback, add commented `STRIPE_SECRET_KEY=` / `STRIPE_PUBLISHABLE_KEY=` for the same reason. Self-hosters without the admin UI set up yet still need an env-var path — don't remove it, just make DB-stored take precedence (matches current `getSiteSetting` fallback order). -- **Never** let the secret key or webhook secret be readable from any API response, including admin ones — mirror how `getAllSiteSettings` presumably masks secret values today (verify this before adding Stripe keys to that list; if it doesn't mask, that's a pre-existing gap worth fixing first, not something to inherit). - ---- - -## 4. Schema changes - -Plus `familyGroups`/`familyMembers` from §1a — this section covers the -per-tier/per-user additions: - -**`tierDefinitions`** (`packages/db/src/schema/tiers.ts`) — add: -```ts -stripeProductId: text("stripe_product_id"), -stripePriceIdMonthly: text("stripe_price_id_monthly"), -stripePriceIdYearly: text("stripe_price_id_yearly"), // nullable if no annual option -``` -Nullable — the `free` row has none. This is how a webhook event (which carries a Price ID) maps back to a tier: `tierDefinitions` becomes the single source of truth for "which Stripe Price = which tier," looked up once and cached in memory per request (small table, 3 rows). - -**`users`** — add: -```ts -stripeSubscriptionId: text("stripe_subscription_id"), -subscriptionStatus: pgEnum("subscription_status", ["active", "trialing", "past_due", "canceled", "incomplete"])(...).nullable(), -currentPeriodEnd: timestamp("current_period_end"), -``` -Deliberately **not** a separate `subscriptions` table — the existing model is one-tier-per-user with no subscription history requirement anywhere else in the app. A join table would be over-engineering for a product that doesn't have multi-subscription users. If that ever changes (e.g. add-ons, multiple products per user), revisit then. - -`subscriptionStatus` matters beyond just `tier`: a `past_due` user should probably keep Pro access for a grace period (Stripe retries the card automatically) rather than being instantly downgraded on `invoice.payment_failed` — that's a product call, not this plan's to make, but the column needs to exist either way to display "payment failed, update your card" in the UI (§7). - -Generate + apply via the existing `pnpm db:generate` / `pnpm db:migrate` flow, same as every other schema change this session. - ---- - -## 5. API routes - -New, under `apps/web/app/api/v1/billing/`: - -- **`POST /api/v1/billing/checkout`** — body `{ tier: "pro" | "family", interval: "month" | "year" }`. Looks up the matching `stripePriceId*` from `tierDefinitions`, creates a Checkout Session (`mode: "subscription"`, `customer: existing stripeCustomerId ?? create one`, `success_url`/`cancel_url` back into the app, `allow_promotion_codes: true` — see §1b), returns `{ url }` for the client to redirect to. Gated by `requireSessionOrApiKey`, rate-limited like other mutating routes (`applyRateLimit`). -- **`POST /api/v1/billing/portal`** — no body. Requires `users.stripeCustomerId` to already exist (i.e., user has been through Checkout at least once); creates a Billing Portal Session, returns `{ url }`. This is where users self-serve cancel/upgrade/update card — don't rebuild that UI, Stripe's hosted portal already does it. -- **`GET /api/v1/billing/status`** — returns the current user's `{ tier, subscriptionStatus, currentPeriodEnd, hasStripeCustomer }` for the client UI in §7 to render without needing a webhook round-trip on every page load. - -**Rewrite** `apps/web/app/api/webhooks/stripe/route.ts` to handle the full event set instead of two: -| Event | Action | -|---|---| -| `checkout.session.completed` | Set `tier` (looked up from the session's Price ID → `tierDefinitions`, not hardcoded to `"pro"` like today), `stripeCustomerId`, `stripeSubscriptionId` | -| `customer.subscription.updated` | Sync `tier` (plan switch), `subscriptionStatus`, `currentPeriodEnd` | -| `customer.subscription.deleted` | `tier: "free"`, `subscriptionStatus: "canceled"` | -| `invoice.payment_failed` | `subscriptionStatus: "past_due"` — do **not** downgrade tier here, Stripe will retry and either recover (→ `subscription.updated`) or eventually cancel (→ `subscription.deleted`) | -| `invoice.paid` | Clear `past_due` back to `active` if it was set (belt-and-suspenders alongside `subscription.updated`) | - -Every handler writes an `auditLogs` row (`action: "billing."`), same pattern already used for admin tier edits — gives you a history for support/debugging without querying Stripe's dashboard. - ---- - -## 6. Admin panel - -Add one nav entry to `apps/web/app/admin/layout.tsx`'s `adminNav` array → `apps/web/app/admin/billing/page.tsx`. - -**Page contents:** -1. **Connection status** — is `STRIPE_SECRET_KEY` configured (DB or env)? Test-mode or live-mode key (Stripe keys are prefixed `sk_test_`/`sk_live_` — detectable without an API call)? Link to configure, reusing the same settings-form pattern already used for AI provider keys in `/admin/ai-config`. -2. **Price mapping** — extend the existing `TierLimitsForm` (`apps/web/components/admin/tier-limits-form.tsx`) with the three new `stripe*` fields per tier, saved through the same `PATCH /api/v1/admin/tiers/{tier}` route (widen its Zod schema and `NUMERIC_FIELDS`→include these as string fields). Keeps one form per tier instead of a second parallel UI. -3. **Insights** — computed from local DB (fast, no Stripe API call, no rate-limit exposure): - - Active subscriber count per tier (`count(*) from users where tier != 'free' group by tier`) - - MRR estimate: `active pro count × pro monthly price + active family count × family monthly price` — price pulled from `tierDefinitions` or a small hardcoded display value if Stripe Prices aren't fetched live (fetching live pricing from Stripe on every admin page load is unnecessary; prices don't change often enough to justify the API call/latency) - - Failed payments needing attention: `count(*) from users where subscriptionStatus = 'past_due'`, listed with links to each user's admin detail page - - Recent billing events: last N rows from `auditLogs` filtered to `action LIKE 'billing.%'` -4. **Active promotions (optional, read-only)** — `stripe.promotionCodes.list({ active: true })`, displayed as a small table (code, discount, redemption count/limit, expiry). Creating/editing codes still happens in Stripe's own dashboard (§1b) — this widget is purely so you can glance at what's live without leaving Epicure. -5. **Link out to Stripe Dashboard** for anything deeper (full transaction history, disputes, tax, creating/editing promotion codes) — don't rebuild Stripe's own reporting, that's what their dashboard is for. - -This matches the existing admin philosophy in this codebase: local DB for fast/cheap counts (see `/admin/storage`, `/admin/page.tsx`'s overview stats), external dashboard link for anything that needs Stripe's own depth. - ---- - -## 7. User-facing UI/UX - -**New page: `/settings/billing`** (alongside the existing `/settings/ai` etc. — same route-group, same layout conventions). - -Sections, top to bottom: -1. **Current plan card** — tier name, price, renewal date (`currentPeriodEnd`), status badge (reuse the `Badge`/color-variant pattern already used for tier/visibility badges elsewhere). If `past_due`: a prominent warning with a "Manage billing" button (→ Portal) to fix the card — don't bury this. -2. **Usage this month** — this already exists! `apps/web/components/settings/usage-quota-section.tsx` (built earlier this session) shows AI-calls/recipes/storage against tier limits with progress bars. Reuse it verbatim on this page instead of only on `/settings/ai` — it's exactly the "why upgrade" nudge a billing page wants, and it's already wired to `tierDefinitions`/`userUsage`. -3. **Plan comparison / upgrade cards** — one card per tier (Free/Pro/Family), current plan visually distinguished (border/highlight), feature list pulled from `tierDefinitions` values formatted as copy ("50 recipes/month" vs "Unlimited"), monthly/annual toggle if annual pricing exists (§1), a primary button per non-current tier → `POST /api/v1/billing/checkout` → redirect to `session.url`. -4. **Manage billing** button (if `stripeCustomerId` exists) → `POST /api/v1/billing/portal` → redirect. -5. **Family group panel** (only relevant once on the Family tier): if you're the owner — member list (avatar/name), an "Invite" button (email or copyable link, per §1a), a remove button per member. If you're a member (not owner) — who owns the group, a "Leave family" action, and no billing controls (owner manages that in Portal, member has no `stripeCustomerId` of their own from this subscription). Reuse the member-list UI patterns already built for `collectionMembers`/`mealPlanMembers` sharing (`apps/web/components/collections/*`, `apps/web/components/meal-plan/*`) rather than inventing new list/avatar-row styling. - -**"Appealing and fluid" — concretely:** -- No new animation library needed — this codebase already gets fluid-feeling transitions from Tailwind's `transition-*`/`animate-in`/`animate-pulse` utilities and Base UI's `data-starting-style`/`data-ending-style` hooks (see `components/ui/sheet.tsx`), consistently across every dialog/sheet/dropdown already built. Match that, don't introduce framer-motion just for this page. -- Skeleton/pulse loading state for the plan cards while `GET /billing/status` resolves (same `animate-pulse` placeholder pattern used in `explore-page-content.tsx`'s search-loading skeleton). -- Checkout/Portal redirects are full-page navigations by design (Stripe-hosted) — the "fluid" part is the *return* trip: `success_url` should land back on `/settings/billing?checkout=success`, which shows an immediate optimistic "Welcome to Pro 🎉" state client-side (don't block on the webhook having landed yet — it usually beats the redirect back, but don't assume it) with a `GET /billing/status` poll (2–3 retries, short backoff) to confirm and clear the optimistic banner once `tier` actually updated server-side. -- Progress bars (usage section) and plan cards should use the same `Card`/`Progress` primitives already in `components/ui/`, not new one-off styling — visual consistency with the rest of Settings matters more than novelty here. - ---- - -## 8. Security & correctness checklist - -Carried over from patterns already established in this codebase, applied to the new surface: - -- Webhook route: keep raw-body signature verification (SDK now, not hand-rolled) — never trust an unsigned request claiming to be Stripe. -- Idempotency: keep `processed_stripe_events` dedup insert — Stripe retries webhooks, handlers must be safe to run twice. -- Never let the client dictate `tier` directly for a Stripe-driven change — only the webhook handler (server-side, signature-verified) writes `tier` from Checkout/subscription events. The existing admin manual-override path (`/api/v1/admin/users/[id]`) stays as an explicit, audited escape hatch for support cases — document that using it while a Stripe subscription is active will get overwritten on the next webhook event, so support should cancel-in-Stripe-first if they want to downgrade someone. -- Rate-limit `/billing/checkout` and `/billing/portal` (session-creation abuse / accidental double-submit) — same `applyRateLimit` helper already used everywhere else. -- Checkout Session should set `client_reference_id`/`metadata.userId` so the webhook can resolve the user even before `stripeCustomerId` is set (first-time checkout). -- Test with the Stripe CLI (`stripe listen --forward-to localhost:3000/api/webhooks/stripe`) in dev before touching production keys — standard Stripe workflow, not specific to this app. - ---- - -## 9. Rollout order - -Suggested build order (each step shippable/testable on its own, matching this session's incremental-ship convention): - -1. Schema migration (§4) — no behavior change yet, safe to ship alone. -2. `lib/stripe.ts` + site-settings keys (§2–3) — still no user-facing change. -3. Rewrite webhook route with real SDK + full event handling (§5) — testable via Stripe CLI against a manually-created test Product/Price, before any UI exists. -4. Admin price-mapping UI (§6.2) — lets you wire real test-mode Price IDs without touching code again. -5. Checkout/Portal API routes + `/settings/billing` page (§5, §7) — Pro works fully at this point; Family checkout works but sharing doesn't exist yet. Promotions (§1b) ship for free here too, it's the one `allow_promotion_codes: true` flag on the same Checkout Session call — no separate step. -6. Family groups: `familyGroups`/`familyMembers` schema, invite/join/remove endpoints, tier-resolution change in `lib/tiers.ts`, the family panel in Settings (§1a, §7.5) — ship after Pro is proven working end-to-end, since it's the most novel piece and easiest to get subtly wrong (tier-resolution fallback logic especially). -7. Admin insights dashboard (§6.3) — pure read-side, can land anytime after step 3. -8. Switch test-mode keys → live-mode keys as the very last step, after a full test-mode Checkout→webhook→tier-change dry run (Pro solo *and* Family group). - ---- - -## 10. France legal/tax notes - -**Not legal or tax advice** — this is a starting point for a conversation -with an actual French accountant (expert-comptable) before real money -moves through live-mode keys, not a substitute for one. Confirm the -specifics for your situation before launch; figures below are current as -of mid-2026 and can change. - -- **Business structure**: for a solo launch, **auto-entrepreneur** (micro-entreprise) is the cheapest legal structure — no share capital, simplified accounting, charges (URSSAF) only apply to revenue actually invoiced, nothing owed at €0 revenue. Only worth moving to SASU/EURL if you want stricter personal/business asset separation, outside investment, or you exceed the auto-entrepreneur revenue ceiling. -- **VAT (TVA) thresholds, 2026, SaaS/subscriptions classified as "prestation de services"**: - - Franchise en base: **€37,500** revenue — below this, no VAT charged on French customers. - - Tolerance ceiling: **€41,250** — crossing this mid-year means VAT applies immediately from the 1st of that month; between the two thresholds you get until the following January 1st. - - Unchanged for 2026 (a threshold-unification reform was dropped by Parliament in late 2025). -- **Open question — cross-border EU customers**: the French franchise en base only covers domestic (France) sales. Selling subscriptions to customers in *other* EU countries falls under the EU's digital-services VAT regime (OSS — One-Stop-Shop), which has its own rules/threshold, separate from the French one above. **Needs accountant confirmation before enabling billing for non-French EU customers** — not resolved by this plan, flagging rather than guessing at numbers. -- **Stripe fees, France-domestic**: 1.5% + €0.25 per transaction (EEA cards). EEA corporate/commercial cards: 1.9% + €0.25. Non-EEA cards: +3.25% on top (~4.75% + €0.25 total). Card issued outside your Stripe account's country: +1.5%. Currency mismatch: +1–2%. -- **Stripe Tax** (optional automated VAT calculation/filing): 0.5% per transaction in jurisdictions where you're registered to collect tax, plus a small per-calculation API fee above the included quota. Worth it once billing spans multiple VAT jurisdictions and manual rate-tracking gets error-prone; skip it while VAT-exempt under the franchise en base. - -Practical order: stay auto-entrepreneur + franchise en base (no VAT complexity) for the initial France-only launch, get the OSS/cross-border question answered before opening Checkout to other EU countries, and only evaluate Stripe Tax once that becomes relevant. diff --git a/VITRINE_PLAN.md b/VITRINE_PLAN.md deleted file mode 100644 index f202a60..0000000 --- a/VITRINE_PLAN.md +++ /dev/null @@ -1,93 +0,0 @@ -# Vitrine (Marketing Site) Plan - -## Status: planning only — nothing in this doc is implemented yet - -**Current state, confirmed by reading the actual code, not assumed:** -- `apps/web/app/page.tsx` — `/` unconditionally `redirect("/recipes")`, no marketing content exists. -- `apps/web/proxy.ts` (the app's middleware, `export default proxy` + `config.matcher`) gates every path **not** in `PUBLIC_PATHS` behind a session cookie, redirecting straight to `/login`. `/` is not in that list — so today, an anonymous visitor hitting `/` never even reaches `page.tsx`'s redirect; they're bounced to `/login` first. **This is the one integration point that actually matters** — the vitrine is invisible to anonymous visitors until `PUBLIC_PATHS` changes. -- No `/privacy`, `/terms`, `/about`, `/contact`, `/features`, or `/pricing` route exists anywhere. -- No `sitemap.ts`/`robots.ts` — Next.js supports both as file-based special routes (same convention already used for `app/manifest.ts`, seen in that file) — currently missing entirely, worth adding as basic SEO hygiene regardless of how much content the vitrine ends up with. - ---- - -## 1. Page list - -| Route | Purpose | -|---|---| -| `/` | Home — hero, value prop, 3-4 feature highlights with screenshots, primary CTA → `/signup` | -| `/features` | Deeper tour: AI recipe generation, meal planning, pantry tracking, social (follows/ratings/comments), the cooking assistant + its tool-calling (create recipe / add to shopping list from chat) | -| `/pricing` | Free/Pro/Family comparison table, CTA into Checkout once Stripe ships (`STRIPE_PLAN.md` §5/§7), or straight to `/signup` before it does | -| `/about` | Story/mission — why this exists, who's building it | -| `/privacy` | Privacy policy — needed once collecting emails regardless of Stripe timing (GDPR, since you're in France/EU per `STRIPE_PLAN.md` §10) | -| `/terms` | Terms of service | -| `/contact` | Support email / simple contact form | - -Not in v1, deferred: a blog (`/blog`) — real SEO value but ongoing content-production cost that's a separate commitment from building the site itself; add later if organic-search growth becomes a priority. - ---- - -## 2. Where it lives — same app, new route group - -`apps/web/app/(marketing)/` — a new route group alongside the existing `(auth)`/`(app)` ones, same conventions (shared `layout.tsx` for the marketing nav/footer, page-per-route). This reuses: -- the design system (`components/ui/*`, Tailwind config, dark/light theming already wired app-wide), -- `next-intl` i18n (`lib/i18n/*`, `messages/en.json`/`fr.json`) — marketing copy gets the same `en`/`fr` treatment as the rest of the app, new keys under a `marketing` namespace, -- the existing deploy pipeline (`DEPLOY.md`, Docker/Traefik) — no second build/deploy to maintain. - -**Root page change**: `apps/web/app/page.tsx` currently always redirects to `/recipes`. Change to: authenticated session → redirect to `/recipes` (unchanged behavior for existing users); no session → render the `(marketing)` home page instead of redirecting. Concretely, move the marketing home content into `app/(marketing)/page.tsx` (or a shared component) and have root `page.tsx` branch on `auth.api.getSession(...)` the same way every other server component in this app already checks session (see `app/(app)/recipes/page.tsx` for the exact pattern to mirror). - -**Middleware change (the one that actually gates visibility)** — add the marketing routes to `PUBLIC_PATHS` in `apps/web/proxy.ts`: -```ts -const PUBLIC_PATHS = [ - "/login", "/signup", /* ...existing... */, - "/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact", -]; -``` -Note `/` needs an **exact** match, not `startsWith` — the current check is `pathname.startsWith(p)`, which is fine for `/features` etc. (nothing else starts with those prefixes) but `"/"` as a prefix would make `PUBLIC_PATHS.some(p => pathname.startsWith(p))` true for literally every path, since every path starts with `/`. This needs a small tweak to the matching logic, not just an array addition — e.g. check `pathname === "/"` as its own condition alongside the `startsWith` list, don't add bare `"/"` into the array. - -**Nav**: the existing authenticated `Nav` component (`apps/web/components/layout/nav.tsx`) doesn't apply here — the marketing route group gets its own simple header/footer (logo, nav links to the pages above, Login/Signup buttons) inside `(marketing)/layout.tsx`, not reused from the app shell. - ---- - -## 3. SEO basics (currently entirely missing) - -- `app/sitemap.ts` — list the 7 marketing routes (file-based Next.js route, same pattern as the existing `app/manifest.ts`). -- `app/robots.ts` — allow marketing routes, disallow `/api`, `/admin`, `(app)` authenticated routes, `/r/`/`/s/`/`/u/` if those shouldn't be indexed (check current intent — they're public-but-unlisted-ish today, decide explicitly rather than defaulting). -- Per-page `generateMetadata` (title/description/OpenGraph image) — every existing app page already sets `export const metadata: Metadata = {}` as a placeholder pattern; marketing pages should actually fill these in, they're the ones search engines/social shares see. -- One shared OG image (or per-page) — doesn't need to be fancy, just present; a page with no OG image looks broken when shared on socials, more damaging than a plain one. - ---- - -## 4. Content sourcing - -- **Screenshots** — real app screenshots (recipe detail page, meal planner, the cooking-assistant proposal card) sell the product better than illustrations for a tool like this. Needs light art-direction (consistent browser-frame chrome, consistent sample data — a demo/seed account with good-looking recipes, not someone's messy real account) but no dedicated design tooling. -- **Copy** — feature descriptions can mostly be extracted from what already exists in `messages/en.json` (feature names, help text) as a starting draft, then punched up for marketing tone rather than in-app instructional tone — those two registers are legitimately different and shouldn't just be copy-pasted. -- **Privacy/Terms** — templated legal content exists (e.g. via a generator, or your accountant/lawyer per `STRIPE_PLAN.md` §10 can point you to a template suited to a French micro-entreprise) — not something to draft from scratch or have generated without review, this is exactly the kind of content where a wrong clause has real consequences. Flag as **needs non-engineering review** before publishing, same spirit as the Stripe plan's tax-advice caveat. - ---- - -## 5. Rollout order - -1. `sitemap.ts`/`robots.ts` + the `PUBLIC_PATHS`/root-`page.tsx` change (§2–3) — plumbing first, still no real content, safe to ship and verify the routing/auth-gating logic works before writing copy. -2. `/` (home) + `/features` — the two pages that actually sell the product; ship these before pricing exists if Stripe isn't live yet (CTA → signup, not checkout). -3. `/privacy` + `/terms` + `/about` + `/contact` — required-but-not-persuasive pages, batch together. -4. `/pricing` — once `STRIPE_PLAN.md` pricing (§1c) is confirmed and ideally once Checkout exists, so the CTA goes somewhere real instead of just to signup. - ---- - -## 6. Separate concern: advertising new features to existing users - -Not part of this plan (in-app, not public/marketing), but flagged since it -came up here: `apps/web/lib/changelog.ts`'s `CHANGELOG` array is written -for you, not users — entries mix real feature announcements with internal -notes ("not applied in sandbox — run `pnpm db:migrate`", bug-fix jargon, -migration reminders). That's correctly *not* public. But there's a real -gap: users have no clean way to learn what shipped since they last looked. - -Direction for later, not scoped in detail here: a curated, user-facing -"What's New" — either a hand-picked subset/rewrite of `CHANGELOG` entries -(only the ones worth telling a user about, in plain language, not the raw -dev log) or a separate small table if the two need to diverge more than a -filter allows. Surfaced in-app — a badge/dot on a bell icon, a dismissible -panel on next login, keyed off "last version the user has seen" so it only -shows genuinely new items. This is a distinct, smaller feature from the -vitrine and can be scoped properly in its own pass. diff --git a/WHATS_NEW_PLAN.md b/WHATS_NEW_PLAN.md deleted file mode 100644 index 6be1bbc..0000000 --- a/WHATS_NEW_PLAN.md +++ /dev/null @@ -1,101 +0,0 @@ -# "What's New" Plan - -## Status: planning only — nothing in this doc is implemented yet - -The problem this solves: `apps/web/lib/changelog.ts`'s `CHANGELOG` array is -a dev log — migration reminders, bug-fix jargon, internal notes ("not -applied in sandbox") mixed in with real feature announcements. Correctly -not public (per `VITRINE_PLAN.md` §6, which flagged this gap). Users have -no clean way to see what's new since they last looked. - -**Existing precedent to mirror, not reinvent**: `apps/web/components/social/notification-bell.tsx` -is exactly this UI shape already — bell icon, unread badge, dropdown list, -polling `GET` on an interval, a "mark read" POST. Same skeleton, new data -source. - ---- - -## 1. Content — extend `ChangelogEntry`, don't fork a second data source - -Add one optional field to the existing type in `apps/web/lib/changelog.ts`: - -```ts -export type ChangelogEntry = { - version: string; - date: string; - added?: string[]; - fixed?: string[]; - security?: string[]; - notes?: string; - highlights?: string[]; // user-facing, plain language — only set on entries worth telling users about -}; -``` - -Only versions with `highlights` populated ever show up in the "What's New" -panel — everything else in `CHANGELOG` stays exactly as dev-facing as it is -today. This is a deliberate editorial step, not automatic: when a version -ships something a real user would care about, someone (you) writes one or -two `highlights` lines in plain language, same commit as the `added`/`fixed` -entries. Retroactively, only add `highlights` to past entries actually worth -surfacing — no need to backfill all 47+ versions, most were internal -fixes/refactors nobody needs a notification about. - ---- - -## 2. Schema - -One column, `apps/web/packages/db/src/schema/users.ts`: -```ts -lastSeenChangelogVersion: text("last_seen_changelog_version"), -``` -Nullable. On the migration, backfill existing users to the current -`APP_VERSION` at migration time (not `null`) — otherwise every existing -user sees the entire history of `highlights` entries as "new" the moment -this ships, which is noise, not a useful announcement. New signups after -this ships get set to `APP_VERSION` at account-creation time for the same -reason — someone who just joined doesn't need to be told about features -that existed before they showed up. - ---- - -## 3. API - -- **`GET /api/v1/whats-new`** — compares the user's `lastSeenChangelogVersion` - against `CHANGELOG` (imported directly, no DB query needed for the content - itself — it's a static in-code array), returns every entry with - `highlights` whose version is newer (semver comparison, not string - comparison — `"0.9.0" < "0.10.0"` is false under plain string compare). - A tiny local semver-compare helper is enough; no need for a package for - a 3-segment version string this app already controls end to end. -- **`POST /api/v1/whats-new/seen`** — sets `lastSeenChangelogVersion` to the - current `APP_VERSION` for the calling user. Called when the panel opens - (or on explicit dismiss — same UX choice `NotificationBell`'s `markAllRead` - already made for regular notifications, mirror whichever this app's - notifications settled on). - -Both gated by `requireSessionOrApiKey`, same as every other authenticated -route — no new auth pattern needed. - ---- - -## 4. UI - -New component `apps/web/components/layout/whats-new-bell.tsx`, structurally -identical to `NotificationBell`: bell/gift icon, badge showing count of -unseen highlighted versions, dropdown listing them (version, date, bullet -list of that version's `highlights`), placed in `apps/web/components/layout/nav.tsx` -next to the existing `NotificationBell`/`MessagesNavLink` icons in the -authenticated header. - -Not a public/vitrine concern — this lives entirely inside the authenticated -app shell, unrelated to `VITRINE_PLAN.md`'s route group. - ---- - -## 5. Rollout order - -1. Schema migration (backfill existing users to current `APP_VERSION`) — no visible change yet. -2. `highlights` field on `ChangelogEntry` + write it for the next few versions going forward, so there's something to show once the UI ships. -3. API routes. -4. `WhatsNewBell` component wired into `nav.tsx`. -5. Retroactively add `highlights` to a handful of past standout versions (the tier system, the cooking-assistant tools, followers-only visibility) if you want new/returning users to see a bit of history on first exposure, not just what ships after this point.