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:
Arnaud
2026-07-18 00:25:51 +02:00
parent 21a3622e6c
commit c8f4b50ef3
47 changed files with 5469 additions and 60 deletions
+6 -6
View File
@@ -696,14 +696,14 @@ export function generateOpenApiSpec(): object {
const InviteRef = registry.register("Invite", z.object({
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(),
expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
usedById: z.string().nullable(),
}));
const CreateInviteRef = registry.register("CreateInvite", z.object({
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({
@@ -745,20 +745,20 @@ export function generateOpenApiSpec(): object {
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
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(),
}));
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
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({
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({
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({
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(),
}));
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: "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 } } } } });