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:
@@ -54,7 +54,7 @@ type QuotaResult<T> = { ok: true; data: T } | { ok: false; response: NextRespons
|
||||
*/
|
||||
export async function withAiQuota<T>(
|
||||
userId: string,
|
||||
tier: "free" | "pro" | "team",
|
||||
tier: "free" | "pro" | "family",
|
||||
fn: () => Promise<T>,
|
||||
opts?: { skipQuota?: boolean }
|
||||
): Promise<QuotaResult<T>> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 = {
|
||||
version: string;
|
||||
@@ -11,6 +11,11 @@ export type 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",
|
||||
date: "2026-07-17 19:20",
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function createInvite(opts: {
|
||||
createdById: string;
|
||||
email?: string | null;
|
||||
role?: "user" | "moderator" | "admin";
|
||||
tier?: "free" | "pro" | "team";
|
||||
tier?: "free" | "pro" | "family";
|
||||
expiresInDays?: number | null;
|
||||
}) {
|
||||
const [invite] = await db
|
||||
|
||||
@@ -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 } } } } });
|
||||
|
||||
@@ -35,12 +35,12 @@ export class TierLimitError extends Error {
|
||||
*/
|
||||
export async function checkAndIncrementTierLimit(
|
||||
userId: string,
|
||||
fallbackTier: "free" | "pro" | "team",
|
||||
fallbackTier: "free" | "pro" | "family",
|
||||
key: "recipe" | "aiCall" | "storage",
|
||||
amount = 1
|
||||
): Promise<void> {
|
||||
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
|
||||
.select()
|
||||
|
||||
Reference in New Issue
Block a user