fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)

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>
This commit is contained in:
Arnaud
2026-07-20 20:32:31 +02:00
parent f6214e60df
commit f6975e98a9
34 changed files with 11356 additions and 240 deletions
+44 -67
View File
@@ -1,5 +1,5 @@
import { db } from "@epicure/db";
import { tierDefinitions, userUsage, users } from "@epicure/db";
import { tierDefinitions, users, recipes, recipePhotos, ratings } from "@epicure/db";
import { eq, sql } from "@epicure/db";
function currentMonth() {
@@ -19,19 +19,46 @@ export class TierLimitError extends Error {
}
}
/** 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);
}
/**
* Atomically checks the tier limit and increments the usage counter in a
* single SQL statement, eliminating the TOCTOU race that existed when
* checkTierLimit and incrementUsage were called separately.
* 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.
*
* Throws TierLimitError if the limit has already been reached.
* Use this instead of the separate checkTierLimit + incrementUsage pair
* for "recipe" and "aiCall" keys.
* "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,
@@ -49,15 +76,14 @@ export async function checkAndIncrementTierLimit(
if (!tierDef) throw new TierLimitError(key, userTier);
const month = currentMonth();
const id = `${userId}-${month}`;
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, recipe_count, storage_used_mb)
VALUES (${id}, ${userId}, ${month}, 1, 0, 0)
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}
@@ -68,31 +94,15 @@ export async function checkAndIncrementTierLimit(
}
} else if (key === "recipe") {
const limit = tierDef.maxRecipes;
const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.recipe_count < ${limit}`;
const result = await db.execute(sql`
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
VALUES (${id}, ${userId}, ${month}, 0, 1, 0)
ON CONFLICT (user_id, month) DO UPDATE
SET recipe_count = user_usage.recipe_count + 1
WHERE ${cap}
RETURNING recipe_count
`);
if (result.length === 0) {
throw new TierLimitError("recipe", userTier);
if (limit !== UNLIMITED) {
const current = await getRecipeCount(userId);
if (current + amount > limit) throw new TierLimitError("recipe", userTier);
}
} else {
const limit = tierDef.storageMb;
const cap = limit === UNLIMITED ? sql`true` : sql`user_usage.storage_used_mb + ${amount} <= ${limit}`;
const result = await db.execute(sql`
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
VALUES (${id}, ${userId}, ${month}, 0, 0, ${amount})
ON CONFLICT (user_id, month) DO UPDATE
SET storage_used_mb = user_usage.storage_used_mb + ${amount}
WHERE ${cap}
RETURNING storage_used_mb
`);
if (result.length === 0) {
throw new TierLimitError("storage", userTier);
if (limit !== UNLIMITED) {
const current = await getStorageUsedMb(userId);
if (current + amount > limit) throw new TierLimitError("storage", userTier);
}
}
}
@@ -110,36 +120,3 @@ export async function refundAiCall(userId: string): Promise<void> {
WHERE user_id = ${userId} AND month = ${month}
`);
}
export async function incrementUsage(
userId: string,
key: LimitKey,
amount = 1
): Promise<void> {
const month = currentMonth();
const id = `${userId}-${month}`;
const initialValues = {
id,
userId,
month,
aiCallsUsed: key === "aiCall" ? amount : 0,
recipeCount: key === "recipe" ? amount : 0,
storageUsedMb: key === "storage" ? amount : 0,
};
const incrementSet =
key === "aiCall"
? { aiCallsUsed: sql`${userUsage.aiCallsUsed} + ${amount}` }
: key === "recipe"
? { recipeCount: sql`${userUsage.recipeCount} + ${amount}` }
: { storageUsedMb: sql`${userUsage.storageUsedMb} + ${amount}` };
await db
.insert(userUsage)
.values(initialValues)
.onConflictDoUpdate({
target: [userUsage.userId, userUsage.month],
set: incrementSet,
});
}