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
+3 -3
View File
@@ -41,7 +41,7 @@ export default async function AdminStoragePage() {
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.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 (
<div className="space-y-6">
@@ -86,11 +86,11 @@ export default async function AdminStoragePage() {
<Card>
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{teamPhotos.toLocaleString()}</div>
<div className="text-2xl font-bold">{familyPhotos.toLocaleString()}</div>
</CardContent>
</Card>
</div>
+1 -1
View File
@@ -25,7 +25,7 @@ const ROLE_COLORS = {
const TIER_COLORS = {
free: "secondary",
pro: "default",
team: "default",
family: "default",
} as const;
export default async function AdminUserDetailPage({ params }: PageProps) {
+1 -1
View File
@@ -24,7 +24,7 @@ const ROLE_COLORS = {
const TIER_COLORS = {
free: "secondary",
pro: "default",
team: "default",
family: "default",
} as const;
export default async function AdminUsersPage({ searchParams }: PageProps) {
+1 -1
View File
@@ -5,7 +5,7 @@ import { createInvite, listInvites } from "@/lib/invites";
import { randomUUID } from "crypto";
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() {
const { response } = await requireAdmin();
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response;
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 });
}
@@ -16,7 +16,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { role, tier } = body;
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])) {
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 });
}
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(),
};
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
.update(users)
+1 -1
View File
@@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from "crypto";
import { APIError } from "better-auth";
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) {
const { session, response } = await requireAdmin();
+2 -2
View File
@@ -52,7 +52,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
@@ -80,7 +80,7 @@ export async function POST(req: NextRequest, { params }: Params) {
}
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) {
if (err instanceof TierLimitError) {
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;
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(
{
dinners: parsed.data.dinners,
+1 -1
View File
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
const locale = (session!.user as { locale?: string }).locale ?? "en";
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({
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}`,
+1 -1
View File
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
@@ -39,7 +39,7 @@ export async function POST(req: NextRequest) {
if (!configResult.ok) return configResult.response;
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, {
...aiConfig,
userContext: privateBio ?? undefined,
+1 -1
View File
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
if (!configResult.ok) return configResult.response;
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, {
...aiConfig,
language: parsed.data.language,
+1 -1
View File
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
if (!textConfigResult.ok) return textConfigResult.response;
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),
{ skipQuota: visionConfig.isByok && textConfig.isByok }
);
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
if (!configResult.ok) return configResult.response;
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 }
);
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(
{
dietaryPrefs: parsed.data.dietaryPrefs,
@@ -99,7 +99,7 @@ export async function POST(req: NextRequest) {
let chargedRecipes = 0;
try {
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++;
}
} catch (err) {
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
+1 -1
View File
@@ -74,7 +74,7 @@ ${stepList || "None listed"}
const locale = (session!.user as { locale?: string }).locale ?? "en";
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({
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}.
+1 -1
View File
@@ -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, 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({
model,
schema: IdeasSchema,
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(req: NextRequest) {
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 }
);
if (!result.ok) return result.response;
+1 -1
View File
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
+1 -1
View File
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
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),
{ skipQuota: aiConfig.isByok }
);
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
@@ -47,7 +47,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!configResult.ok) return configResult.response;
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(
{
title: recipe.title,
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
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)
);
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 });
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) {
if (err instanceof TierLimitError) {
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 });
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({
title: recipe.title,
baseServings: recipe.baseServings,
+1 -1
View File
@@ -117,7 +117,7 @@ export async function POST(req: NextRequest) {
}
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) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
try {
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) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
+1 -1
View File
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
try {
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) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });