rename: "Team" billing tier to "Family"
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
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.47.0 — 2026-07-18 00:30
|
||||||
|
|
||||||
|
Renamed the "Team" billing tier to "Family" (free/pro/family) — same limits, same admin editing, just the name. Existing Team users keep their tier/limits unaffected; the DB enum value itself was renamed in place (no data migration needed).
|
||||||
|
|
||||||
## 0.46.2 — 2026-07-17 19:20
|
## 0.46.2 — 2026-07-17 19:20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
- **DECISION NEEDED — Pricing amounts.** What does Pro cost/month? Family? Annual discount? Stripe Prices are created once you know this.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
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. Two ways
|
||||||
|
to charge for it (pick one — recommend the first):
|
||||||
|
|
||||||
|
- **Flat price, capped membership** (recommended): one Stripe Price for
|
||||||
|
"Family," regardless of exact member count up to the cap in the decision
|
||||||
|
above. No Stripe API calls needed when members join/leave — membership is
|
||||||
|
purely an app-side concern. Simpler, matches how Family plans read to
|
||||||
|
users (a household, not per-head billing).
|
||||||
|
- **Per-seat (quantity-based)**: the subscription's `quantity` = member
|
||||||
|
count; Stripe bills `quantity × price`. Requires calling
|
||||||
|
`stripe.subscriptions.update(id, { items: [{ id: itemId, quantity }] })`
|
||||||
|
every time someone joins/leaves, and Stripe will prorate the change
|
||||||
|
automatically. More correct if you want to charge per additional person,
|
||||||
|
more moving parts to keep in sync.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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), 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.<event>"`), 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. **Link out to Stripe Dashboard** for anything deeper (full transaction history, disputes, tax) — 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.
|
||||||
|
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).
|
||||||
@@ -41,7 +41,7 @@ export default async function AdminStoragePage() {
|
|||||||
|
|
||||||
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
|
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
|
||||||
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
|
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
|
||||||
const teamPhotos = Number(photosByTier.find((r) => r.tier === "team")?.photoCount ?? 0);
|
const familyPhotos = Number(photosByTier.find((r) => r.tier === "family")?.photoCount ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -86,11 +86,11 @@ export default async function AdminStoragePage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Team Tier Photos</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Family Tier Photos</CardTitle>
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{teamPhotos.toLocaleString()}</div>
|
<div className="text-2xl font-bold">{familyPhotos.toLocaleString()}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ROLE_COLORS = {
|
|||||||
const TIER_COLORS = {
|
const TIER_COLORS = {
|
||||||
free: "secondary",
|
free: "secondary",
|
||||||
pro: "default",
|
pro: "default",
|
||||||
team: "default",
|
family: "default",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default async function AdminUserDetailPage({ params }: PageProps) {
|
export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ROLE_COLORS = {
|
|||||||
const TIER_COLORS = {
|
const TIER_COLORS = {
|
||||||
free: "secondary",
|
free: "secondary",
|
||||||
pro: "default",
|
pro: "default",
|
||||||
team: "default",
|
family: "default",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createInvite, listInvites } from "@/lib/invites";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
const VALID_ROLES = ["user", "moderator", "admin"] as const;
|
const VALID_ROLES = ["user", "moderator", "admin"] as const;
|
||||||
const VALID_TIERS = ["free", "pro", "team"] as const;
|
const VALID_TIERS = ["free", "pro", "family"] as const;
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { response } = await requireAdmin();
|
const { response } = await requireAdmin();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const { tier } = await params;
|
const { tier } = await params;
|
||||||
if (tier !== "free" && tier !== "pro" && tier !== "team") {
|
if (tier !== "free" && tier !== "pro" && tier !== "family") {
|
||||||
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
const { role, tier } = body;
|
const { role, tier } = body;
|
||||||
|
|
||||||
const validRoles = ["user", "moderator", "admin"] as const;
|
const validRoles = ["user", "moderator", "admin"] as const;
|
||||||
const validTiers = ["free", "pro", "team"] as const;
|
const validTiers = ["free", "pro", "family"] as const;
|
||||||
|
|
||||||
if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) {
|
if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) {
|
||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
|
||||||
@@ -25,11 +25,11 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|||||||
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "team"; updatedAt: Date }> = {
|
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; updatedAt: Date }> = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
if (role) updateData.role = role as "user" | "moderator" | "admin";
|
if (role) updateData.role = role as "user" | "moderator" | "admin";
|
||||||
if (tier) updateData.tier = tier as "free" | "pro" | "team";
|
if (tier) updateData.tier = tier as "free" | "pro" | "family";
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from "crypto";
|
|||||||
import { APIError } from "better-auth";
|
import { APIError } from "better-auth";
|
||||||
|
|
||||||
const VALID_ROLES = ["user", "moderator", "admin"] as const;
|
const VALID_ROLES = ["user", "moderator", "admin"] as const;
|
||||||
const VALID_TIERS = ["free", "pro", "team"] as const;
|
const VALID_TIERS = ["free", "pro", "family"] as const;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireAdmin();
|
const { session, response } = await requireAdmin();
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
adaptRecipe(
|
adaptRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
@@ -80,7 +80,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "recipe");
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TierLimitError) {
|
if (err instanceof TierLimitError) {
|
||||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const config = configResult.data;
|
const config = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateBatchCook(
|
generateBatchCook(
|
||||||
{
|
{
|
||||||
dinners: parsed.data.dinners,
|
dinners: parsed.data.dinners,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
const lang = LANG[locale] ?? "English";
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateText({
|
generateText({
|
||||||
model,
|
model,
|
||||||
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.${bioContext}`,
|
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.${bioContext}`,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestDrinks(
|
suggestDrinks(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateRecipe(parsed.data.title, {
|
generateRecipe(parsed.data.title, {
|
||||||
...aiConfig,
|
...aiConfig,
|
||||||
userContext: privateBio ?? undefined,
|
userContext: privateBio ?? undefined,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateRecipe(parsed.data.prompt, {
|
generateRecipe(parsed.data.prompt, {
|
||||||
...aiConfig,
|
...aiConfig,
|
||||||
language: parsed.data.language,
|
language: parsed.data.language,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!textConfigResult.ok) return textConfigResult.response;
|
if (!textConfigResult.ok) return textConfigResult.response;
|
||||||
const textConfig = textConfigResult.data;
|
const textConfig = textConfigResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
|
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
|
||||||
{ skipQuota: visionConfig.isByok && textConfig.isByok }
|
{ skipQuota: visionConfig.isByok && textConfig.isByok }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok }
|
importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateMealPlan(
|
generateMealPlan(
|
||||||
{
|
{
|
||||||
dietaryPrefs: parsed.data.dietaryPrefs,
|
dietaryPrefs: parsed.data.dietaryPrefs,
|
||||||
@@ -99,7 +99,7 @@ export async function POST(req: NextRequest) {
|
|||||||
let chargedRecipes = 0;
|
let chargedRecipes = 0;
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < plan.entries.length; i++) {
|
for (let i = 0; i < plan.entries.length; i++) {
|
||||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "recipe");
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
chargedRecipes++;
|
chargedRecipes++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestPairings(
|
suggestPairings(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ ${stepList || "None listed"}
|
|||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
const lang = LANG[locale] ?? "English";
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateText({
|
generateText({
|
||||||
model,
|
model,
|
||||||
system: `You are Epicure, a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
system: `You are Epicure, a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
|
|||||||
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
||||||
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
generateObject({
|
generateObject({
|
||||||
model,
|
model,
|
||||||
schema: IdeasSchema,
|
schema: IdeasSchema,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const { instruction, language, ...current } = parsed.data;
|
const { instruction, language, ...current } = parsed.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
|
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
scaleRecipe(
|
scaleRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale),
|
substituteIngredient(parsed.data.ingredient, context, aiConfig, locale),
|
||||||
{ skipQuota: aiConfig.isByok }
|
{ skipQuota: aiConfig.isByok }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
translateRecipe(
|
translateRecipe(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!configResult.ok) return configResult.response;
|
if (!configResult.ok) return configResult.response;
|
||||||
const aiConfig = configResult.data;
|
const aiConfig = configResult.data;
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
suggestVariations(
|
suggestVariations(
|
||||||
{
|
{
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
|
|||||||
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
|
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
scanPantryPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig)
|
scanPantryPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig)
|
||||||
);
|
);
|
||||||
if (!result.ok) return result.response;
|
if (!result.ok) return result.response;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "recipe");
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TierLimitError) {
|
if (err instanceof TierLimitError) {
|
||||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
||||||
estimateNutrition({
|
estimateNutrition({
|
||||||
title: recipe.title,
|
title: recipe.title,
|
||||||
baseServings: recipe.baseServings,
|
baseServings: recipe.baseServings,
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "recipe");
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TierLimitError) {
|
if (err instanceof TierLimitError) {
|
||||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
||||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "storage", sizeMb);
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", sizeMb);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TierLimitError) {
|
if (err instanceof TierLimitError) {
|
||||||
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
||||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "storage", sizeMb);
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", sizeMb);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TierLimitError) {
|
if (err instanceof TierLimitError) {
|
||||||
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function CreateUserDialog() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
|
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
|
||||||
const [tier, setTier] = useState<"free" | "pro" | "team">("free");
|
const [tier, setTier] = useState<"free" | "pro" | "family">("free");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
@@ -85,7 +85,7 @@ export function CreateUserDialog() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">Free</SelectItem>
|
||||||
<SelectItem value="pro">Pro</SelectItem>
|
<SelectItem value="pro">Pro</SelectItem>
|
||||||
<SelectItem value="team">Team</SelectItem>
|
<SelectItem value="family">Family</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Invite = {
|
|||||||
token: string;
|
token: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
role: "user" | "moderator" | "admin";
|
role: "user" | "moderator" | "admin";
|
||||||
tier: "free" | "pro" | "team";
|
tier: "free" | "pro" | "family";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
};
|
};
|
||||||
@@ -33,7 +33,7 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
|
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
|
||||||
const [tier, setTier] = useState<"free" | "pro" | "team">("free");
|
const [tier, setTier] = useState<"free" | "pro" | "family">("free");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">Free</SelectItem>
|
||||||
<SelectItem value="pro">Pro</SelectItem>
|
<SelectItem value="pro">Pro</SelectItem>
|
||||||
<SelectItem value="team">Team</SelectItem>
|
<SelectItem value="family">Family</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
interface UserEditorProps {
|
interface UserEditorProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
currentRole: "user" | "moderator" | "admin";
|
currentRole: "user" | "moderator" | "admin";
|
||||||
currentTier: "free" | "pro" | "team";
|
currentTier: "free" | "pro" | "family";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps) {
|
export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps) {
|
||||||
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
|
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
|
||||||
const [tier, setTier] = useState<"free" | "pro" | "team">(currentTier);
|
const [tier, setTier] = useState<"free" | "pro" | "family">(currentTier);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -68,7 +68,7 @@ export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">Free</SelectItem>
|
||||||
<SelectItem value="pro">Pro</SelectItem>
|
<SelectItem value="pro">Pro</SelectItem>
|
||||||
<SelectItem value="team">Team</SelectItem>
|
<SelectItem value="family">Family</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type QuotaResult<T> = { ok: true; data: T } | { ok: false; response: NextRespons
|
|||||||
*/
|
*/
|
||||||
export async function withAiQuota<T>(
|
export async function withAiQuota<T>(
|
||||||
userId: string,
|
userId: string,
|
||||||
tier: "free" | "pro" | "team",
|
tier: "free" | "pro" | "family",
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
opts?: { skipQuota?: boolean }
|
opts?: { skipQuota?: boolean }
|
||||||
): Promise<QuotaResult<T>> {
|
): Promise<QuotaResult<T>> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.46.2";
|
export const APP_VERSION = "0.47.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,11 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.47.0",
|
||||||
|
date: "2026-07-18 00:30",
|
||||||
|
notes: "Renamed the \"Team\" billing tier to \"Family\" (free/pro/family) — same limits, same admin editing, just the name. Existing Team users keep their tier/limits unaffected; the DB enum value itself was renamed in place (no data migration needed).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.46.2",
|
version: "0.46.2",
|
||||||
date: "2026-07-17 19:20",
|
date: "2026-07-17 19:20",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function createInvite(opts: {
|
|||||||
createdById: string;
|
createdById: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role?: "user" | "moderator" | "admin";
|
role?: "user" | "moderator" | "admin";
|
||||||
tier?: "free" | "pro" | "team";
|
tier?: "free" | "pro" | "family";
|
||||||
expiresInDays?: number | null;
|
expiresInDays?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const [invite] = await db
|
const [invite] = await db
|
||||||
|
|||||||
@@ -696,14 +696,14 @@ export function generateOpenApiSpec(): object {
|
|||||||
|
|
||||||
const InviteRef = registry.register("Invite", z.object({
|
const InviteRef = registry.register("Invite", z.object({
|
||||||
id: z.string(), token: z.string(), email: z.string().nullable(),
|
id: z.string(), token: z.string(), email: z.string().nullable(),
|
||||||
role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro", "team"]),
|
role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro", "family"]),
|
||||||
createdById: z.string(), createdAt: z.string().datetime(),
|
createdById: z.string(), createdAt: z.string().datetime(),
|
||||||
expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
|
expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
|
||||||
usedById: z.string().nullable(),
|
usedById: z.string().nullable(),
|
||||||
}));
|
}));
|
||||||
const CreateInviteRef = registry.register("CreateInvite", z.object({
|
const CreateInviteRef = registry.register("CreateInvite", z.object({
|
||||||
email: z.string().optional(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
email: z.string().optional(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
||||||
tier: z.enum(["free", "pro", "team"]).default("free"), expiresInDays: z.number().default(7),
|
tier: z.enum(["free", "pro", "family"]).default("free"), expiresInDays: z.number().default(7),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AdminReportRef = registry.register("AdminReport", z.object({
|
const AdminReportRef = registry.register("AdminReport", z.object({
|
||||||
@@ -745,20 +745,20 @@ export function generateOpenApiSpec(): object {
|
|||||||
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
|
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
|
||||||
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
|
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
|
||||||
const TierDefinitionRef = registry.register("TierDefinition", z.object({
|
const TierDefinitionRef = registry.register("TierDefinition", z.object({
|
||||||
tier: z.enum(["free", "pro", "team"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
|
tier: z.enum(["free", "pro", "family"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
|
||||||
storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
|
storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
|
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
|
||||||
email: z.string(), name: z.string(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
email: z.string(), name: z.string(), role: z.enum(["user", "moderator", "admin"]).default("user"),
|
||||||
tier: z.enum(["free", "pro", "team"]).default("free"),
|
tier: z.enum(["free", "pro", "family"]).default("free"),
|
||||||
}));
|
}));
|
||||||
const AdminCreatedUserRef = registry.register("AdminCreatedUser", z.object({
|
const AdminCreatedUserRef = registry.register("AdminCreatedUser", z.object({
|
||||||
user: z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), tier: z.string() }),
|
user: z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), tier: z.string() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
|
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
|
||||||
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "team"]).optional(),
|
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "family"]).optional(),
|
||||||
}));
|
}));
|
||||||
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
|
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
|
||||||
user: z.object({ id: z.string(), role: z.string(), tier: z.string() }),
|
user: z.object({ id: z.string(), role: z.string(), tier: z.string() }),
|
||||||
@@ -769,7 +769,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
aiCallsUsed: z.number().int(), recipeCount: z.number().int(), storageUsedMb: z.number().int(),
|
aiCallsUsed: z.number().int(), recipeCount: z.number().int(), storageUsedMb: z.number().int(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tierParam = z.object({ tier: z.enum(["free", "pro", "team"]) });
|
const tierParam = z.object({ tier: z.enum(["free", "pro", "family"]) });
|
||||||
|
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/admin/invites", summary: "List invites", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Invites", content: { "application/json": { schema: z.object({ invites: z.array(InviteRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/admin/invites", summary: "List invites", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Invites", content: { "application/json": { schema: z.object({ invites: z.array(InviteRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ export class TierLimitError extends Error {
|
|||||||
*/
|
*/
|
||||||
export async function checkAndIncrementTierLimit(
|
export async function checkAndIncrementTierLimit(
|
||||||
userId: string,
|
userId: string,
|
||||||
fallbackTier: "free" | "pro" | "team",
|
fallbackTier: "free" | "pro" | "family",
|
||||||
key: "recipe" | "aiCall" | "storage",
|
key: "recipe" | "aiCall" | "storage",
|
||||||
amount = 1
|
amount = 1
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
|
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
|
||||||
const userTier = (dbUser?.tier as "free" | "pro" | "team" | undefined) ?? fallbackTier;
|
const userTier = (dbUser?.tier as "free" | "pro" | "family" | undefined) ?? fallbackTier;
|
||||||
|
|
||||||
const [tierDef] = await db
|
const [tierDef] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.46.2",
|
"version": "0.47.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.46.2",
|
"version": "0.47.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."tier" RENAME VALUE 'team' TO 'family';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -309,6 +309,13 @@
|
|||||||
"when": 1784302338735,
|
"when": 1784302338735,
|
||||||
"tag": "0043_futuristic_shadowcat",
|
"tag": "0043_futuristic_shadowcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784326930573,
|
||||||
|
"tag": "0044_polite_living_mummy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
|
export const userRoleEnum = pgEnum("user_role", ["user", "moderator", "admin"]);
|
||||||
export const tierEnum = pgEnum("tier", ["free", "pro", "team"]);
|
export const tierEnum = pgEnum("tier", ["free", "pro", "family"]);
|
||||||
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
|
export const unitPrefEnum = pgEnum("unit_pref", ["metric", "imperial"]);
|
||||||
export const apiKeyScopeEnum = pgEnum("api_key_scope", ["full", "read"]);
|
export const apiKeyScopeEnum = pgEnum("api_key_scope", ["full", "read"]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ async function seed() {
|
|||||||
maxPublicRecipes: 99999,
|
maxPublicRecipes: 99999,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tier: "team",
|
tier: "family",
|
||||||
// -1 is the actual "no cap" sentinel (see lib/tiers.ts UNLIMITED) —
|
// -1 is the actual "no cap" sentinel (see lib/tiers.ts UNLIMITED) —
|
||||||
// pro uses large-but-finite numbers instead; team is genuinely unlimited.
|
// pro uses large-but-finite numbers instead; family is genuinely unlimited.
|
||||||
maxRecipes: -1,
|
maxRecipes: -1,
|
||||||
aiCallsPerMonth: 2000,
|
aiCallsPerMonth: 2000,
|
||||||
storageMb: 50000,
|
storageMb: 50000,
|
||||||
|
|||||||
Reference in New Issue
Block a user