import { db } from "@epicure/db"; import { tierDefinitions, users, recipes, recipePhotos, ratings } from "@epicure/db"; import { eq, sql } from "@epicure/db"; function currentMonth() { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } export type LimitKey = "recipe" | "aiCall" | "storage"; /** Sentinel stored in tier_definitions numeric columns to mean "no cap". */ export const UNLIMITED = -1; export class TierLimitError extends Error { constructor(public readonly limit: LimitKey, public readonly tier: string) { super(`Tier limit reached: ${limit} (tier: ${tier})`); this.name = "TierLimitError"; } } /** Live count of a user's recipes — lifetime total, not a monthly counter. */ export async function getRecipeCount(userId: string): Promise { const [row] = await db .select({ n: sql`count(*)::int` }) .from(recipes) .where(eq(recipes.authorId, userId)); return row?.n ?? 0; } /** Live sum of a user's storage usage (recipe photos + review photos + avatar) — lifetime total, not a monthly counter. */ export async function getStorageUsedMb(userId: string): Promise { const [[photoRow], [reviewRow], [userRow]] = await Promise.all([ db .select({ total: sql`coalesce(sum(${recipePhotos.sizeMb}), 0)::int` }) .from(recipePhotos) .innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id)) .where(eq(recipes.authorId, userId)), db .select({ total: sql`coalesce(sum(${ratings.photoSizeMb}), 0)::int` }) .from(ratings) .where(eq(ratings.userId, userId)), db.select({ avatarSizeMb: users.avatarSizeMb }).from(users).where(eq(users.id, userId)), ]); return (photoRow?.total ?? 0) + (reviewRow?.total ?? 0) + (userRow?.avatarSizeMb ?? 0); } /** * Checks the tier limit for the given key, throwing TierLimitError if it's * already been reached (or would be exceeded by `amount`). * * The caller's `userTier` is never trusted directly — it comes from the * session's 5-minute cookieCache (see lib/auth/server.ts), so a just-downgraded * user would otherwise keep the old tier's caps for up to 5 minutes. The * current tier is always re-read from the DB here. * * "aiCall" is the only key that's monthly — it atomically checks-and-increments * a per-month counter in a single SQL statement to avoid a TOCTOU race. * "recipe" and "storage" are lifetime totals derived live from real data * (recipes/photos/avatar rows), so they're pure checks with no counter to * increment — deleting a recipe/photo/avatar is itself the "decrement". */ export async function checkAndIncrementTierLimit( userId: string, fallbackTier: "free" | "pro" | "family", key: "recipe" | "aiCall" | "storage", amount = 1 ): Promise { const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId)); const userTier = (dbUser?.tier as "free" | "pro" | "family" | undefined) ?? fallbackTier; const [tierDef] = await db .select() .from(tierDefinitions) .where(eq(tierDefinitions.tier, userTier)); if (!tierDef) throw new TierLimitError(key, userTier); if (key === "aiCall") { const month = currentMonth(); const id = `${userId}-${month}`; const limit = tierDef.aiCallsPerMonth; const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.ai_calls_used < ${limit}`; const result = await db.execute(sql` INSERT INTO user_usage (id, user_id, month, ai_calls_used) VALUES (${id}, ${userId}, ${month}, 1) ON CONFLICT (user_id, month) DO UPDATE SET ai_calls_used = user_usage.ai_calls_used + 1 WHERE ${cap} RETURNING ai_calls_used `); if (result.length === 0) { throw new TierLimitError("aiCall", userTier); } } else if (key === "recipe") { const limit = tierDef.maxRecipes; if (limit !== UNLIMITED) { const current = await getRecipeCount(userId); if (current + amount > limit) throw new TierLimitError("recipe", userTier); } } else { const limit = tierDef.storageMb; if (limit !== UNLIMITED) { const current = await getStorageUsedMb(userId); if (current + amount > limit) throw new TierLimitError("storage", userTier); } } } /** * Refunds one aiCall credit for the current month. Call this when an AI * request failed after the quota was already charged (e.g. provider error) * so users aren't billed against their limit for a call that never succeeded. */ export async function refundAiCall(userId: string): Promise { const month = currentMonth(); await db.execute(sql` UPDATE user_usage SET ai_calls_used = GREATEST(ai_calls_used - 1, 0) WHERE user_id = ${userId} AND month = ${month} `); }