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:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
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.44.0 — 2026-07-17 16:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New "Team" billing tier, above Pro — higher AI-call, recipe, and storage limits, editable from Admin > Tiers like the existing tiers. (Moderator/admin remain separate account roles, unrelated to billing tier — unchanged by this.)
|
||||||
|
|
||||||
## 0.43.0 — 2026-07-17 15:45
|
## 0.43.0 — 2026-07-17 15:45
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -41,6 +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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -51,7 +52,7 @@ export default async function AdminStoragePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<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">Total Photos</CardTitle>
|
<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>
|
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const ROLE_COLORS = {
|
|||||||
const TIER_COLORS = {
|
const TIER_COLORS = {
|
||||||
free: "secondary",
|
free: "secondary",
|
||||||
pro: "default",
|
pro: "default",
|
||||||
|
team: "default",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default async function AdminUserDetailPage({ params }: PageProps) {
|
export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const ROLE_COLORS = {
|
|||||||
const TIER_COLORS = {
|
const TIER_COLORS = {
|
||||||
free: "secondary",
|
free: "secondary",
|
||||||
pro: "default",
|
pro: "default",
|
||||||
|
team: "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"] as const;
|
const VALID_TIERS = ["free", "pro", "team"] 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") {
|
if (tier !== "free" && tier !== "pro" && tier !== "team") {
|
||||||
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"] as const;
|
const validTiers = ["free", "pro", "team"] 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"; updatedAt: Date }> = {
|
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "team"; 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";
|
if (tier) updateData.tier = tier as "free" | "pro" | "team";
|
||||||
|
|
||||||
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"] as const;
|
const VALID_TIERS = ["free", "pro", "team"] 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", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", "recipe");
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
generateBatchCook(
|
generateBatchCook(
|
||||||
{
|
{
|
||||||
dinners: parsed.data.dinners,
|
dinners: parsed.data.dinners,
|
||||||
|
|||||||
@@ -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 lang = LANG[locale] ?? "English";
|
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({
|
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}.${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}.${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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", "recipe");
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", () =>
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", "recipe");
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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", () =>
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
|
||||||
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", "recipe");
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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", "storage", sizeMb);
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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", "storage", sizeMb);
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "team", "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">("free");
|
const [tier, setTier] = useState<"free" | "pro" | "team">("free");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
@@ -85,6 +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>
|
||||||
</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";
|
tier: "free" | "pro" | "team";
|
||||||
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">("free");
|
const [tier, setTier] = useState<"free" | "pro" | "team">("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,6 +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>
|
||||||
</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";
|
currentTier: "free" | "pro" | "team";
|
||||||
}
|
}
|
||||||
|
|
||||||
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">(currentTier);
|
const [tier, setTier] = useState<"free" | "pro" | "team">(currentTier);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -68,6 +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>
|
||||||
</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",
|
tier: "free" | "pro" | "team",
|
||||||
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.43.0";
|
export const APP_VERSION = "0.44.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.44.0",
|
||||||
|
date: "2026-07-17 16:15",
|
||||||
|
added: [
|
||||||
|
"New \"Team\" billing tier, above Pro — higher AI-call, recipe, and storage limits, editable from Admin > Tiers like the existing tiers. (Moderator/admin remain separate account roles, unrelated to billing tier — unchanged by this.)",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.43.0",
|
version: "0.43.0",
|
||||||
date: "2026-07-17 15:45",
|
date: "2026-07-17 15:45",
|
||||||
|
|||||||
@@ -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";
|
tier?: "free" | "pro" | "team";
|
||||||
expiresInDays?: number | null;
|
expiresInDays?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const [invite] = await db
|
const [invite] = await db
|
||||||
|
|||||||
@@ -685,14 +685,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"]),
|
role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro", "team"]),
|
||||||
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"]).default("free"), expiresInDays: z.number().default(7),
|
tier: z.enum(["free", "pro", "team"]).default("free"), expiresInDays: z.number().default(7),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AdminReportRef = registry.register("AdminReport", z.object({
|
const AdminReportRef = registry.register("AdminReport", z.object({
|
||||||
@@ -734,20 +734,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"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
|
tier: z.enum(["free", "pro", "team"]), 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"]).default("free"),
|
tier: z.enum(["free", "pro", "team"]).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"]).optional(),
|
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "team"]).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() }),
|
||||||
@@ -758,7 +758,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"]) });
|
const tierParam = z.object({ tier: z.enum(["free", "pro", "team"]) });
|
||||||
|
|
||||||
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",
|
fallbackTier: "free" | "pro" | "team",
|
||||||
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" | undefined) ?? fallbackTier;
|
const userTier = (dbUser?.tier as "free" | "pro" | "team" | undefined) ?? fallbackTier;
|
||||||
|
|
||||||
const [tierDef] = await db
|
const [tierDef] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.43.0",
|
"version": "0.44.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.43.0",
|
"version": "0.44.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."tier" ADD VALUE 'team';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,13 @@
|
|||||||
"when": 1784301168549,
|
"when": 1784301168549,
|
||||||
"tag": "0042_windy_masked_marvel",
|
"tag": "0042_windy_masked_marvel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784302338735,
|
||||||
|
"tag": "0043_futuristic_shadowcat",
|
||||||
|
"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"]);
|
export const tierEnum = pgEnum("tier", ["free", "pro", "team"]);
|
||||||
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"]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ async function seed() {
|
|||||||
storageMb: 10000,
|
storageMb: 10000,
|
||||||
maxPublicRecipes: 99999,
|
maxPublicRecipes: 99999,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tier: "team",
|
||||||
|
// -1 is the actual "no cap" sentinel (see lib/tiers.ts UNLIMITED) —
|
||||||
|
// pro uses large-but-finite numbers instead; team is genuinely unlimited.
|
||||||
|
maxRecipes: -1,
|
||||||
|
aiCallsPerMonth: 2000,
|
||||||
|
storageMb: 50000,
|
||||||
|
maxPublicRecipes: -1,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user