f6975e98a9
Recipe count and storage usage shared the monthly user_usage bucket with AI calls, so both incorrectly reset every month even though nothing was deleted. Only AI calls should be monthly. Recipe count and storage are now derived live from real data (recipes, recipe/review photos, avatar) instead of a counter — deleting a photo or recipe is itself the "decrement", no extra wiring needed. Storage size is tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb) and threaded through presign -> upload -> save. Also fixes avatar removal silently no-oping (client sent a field the PATCH schema didn't recognize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
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<number> {
|
|
const [row] = await db
|
|
.select({ n: sql<number>`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<number> {
|
|
const [[photoRow], [reviewRow], [userRow]] = await Promise.all([
|
|
db
|
|
.select({ total: sql<number>`coalesce(sum(${recipePhotos.sizeMb}), 0)::int` })
|
|
.from(recipePhotos)
|
|
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
|
|
.where(eq(recipes.authorId, userId)),
|
|
db
|
|
.select({ total: sql<number>`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<void> {
|
|
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<void> {
|
|
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}
|
|
`);
|
|
}
|