feat: add Team billing tier
Widens the tier enum from free/pro to free/pro/team and every "free" | "pro" cast that assumed exactly two tiers (~30 call sites: every AI route's withAiQuota/checkAndIncrementTierLimit call, admin user/invite management, upload quota checks, OpenAPI schemas). Team sits above Pro with genuinely unlimited recipes/public-recipes (the -1 sentinel, which Pro doesn't actually use — Pro uses large finite numbers instead) and a higher AI-call/storage cap. Seeded via db:seed, editable afterward from Admin > Tiers. role (user/moderator/admin — permissions) and tier (free/pro/team — billing limits) stay separate concepts, as they already were; this does not touch role-based permissions. Requires migration 0043 to run against a live DB — not applied in this sandbox (no Docker here); run `pnpm db:migrate` then `pnpm db:seed`. v0.44.0
This commit is contained in:
@@ -41,6 +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);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -51,7 +52,7 @@ export default async function AdminStoragePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle>
|
||||
@@ -82,6 +83,16 @@ export default async function AdminStoragePage() {
|
||||
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{teamPhotos.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -25,6 +25,7 @@ const ROLE_COLORS = {
|
||||
const TIER_COLORS = {
|
||||
free: "secondary",
|
||||
pro: "default",
|
||||
team: "default",
|
||||
} as const;
|
||||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||
|
||||
@@ -24,6 +24,7 @@ const ROLE_COLORS = {
|
||||
const TIER_COLORS = {
|
||||
free: "secondary",
|
||||
pro: "default",
|
||||
team: "default",
|
||||
} as const;
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
||||
|
||||
@@ -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"] as const;
|
||||
const VALID_TIERS = ["free", "pro", "team"] 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") {
|
||||
if (tier !== "free" && tier !== "pro" && tier !== "team") {
|
||||
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"] as const;
|
||||
const validTiers = ["free", "pro", "team"] 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"; updatedAt: Date }> = {
|
||||
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "team"; updatedAt: Date }> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (role) updateData.role = role as "user" | "moderator" | "admin";
|
||||
if (tier) updateData.tier = tier as "free" | "pro";
|
||||
if (tier) updateData.tier = tier as "free" | "pro" | "team";
|
||||
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
|
||||
@@ -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"] as const;
|
||||
const VALID_TIERS = ["free", "pro", "team"] as const;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireAdmin();
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", "recipe");
|
||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
generateBatchCook(
|
||||
{
|
||||
dinners: parsed.data.dinners,
|
||||
|
||||
@@ -40,7 +40,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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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}.${bioContext}`,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
generateRecipe(parsed.data.title, {
|
||||
...aiConfig,
|
||||
userContext: privateBio ?? undefined,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
generateRecipe(parsed.data.prompt, {
|
||||
...aiConfig,
|
||||
language: parsed.data.language,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
|
||||
{ skipQuota: visionConfig.isByok && textConfig.isByok }
|
||||
);
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", () =>
|
||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", "recipe");
|
||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
suggestPairings(
|
||||
{
|
||||
title: recipe.title,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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}.
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
generateObject({
|
||||
model,
|
||||
schema: IdeasSchema,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
|
||||
);
|
||||
if (!result.ok) return result.response;
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
scaleRecipe(
|
||||
{
|
||||
title: recipe.title,
|
||||
|
||||
@@ -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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", () =>
|
||||
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
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", "recipe");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
||||
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||
estimateNutrition({
|
||||
title: recipe.title,
|
||||
baseServings: recipe.baseServings,
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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", "storage", sizeMb);
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "storage", sizeMb);
|
||||
} catch (err) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
||||
|
||||
@@ -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", "storage", sizeMb);
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "storage", sizeMb);
|
||||
} catch (err) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
|
||||
|
||||
Reference in New Issue
Block a user