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
@@ -19,9 +19,12 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { id } = await params;
const month = currentMonth();
// Only aiCallsUsed is a resettable monthly counter — recipe count and
// storage usage are lifetime totals derived live from real data (recipes,
// photos, avatar), so there's nothing to reset there.
const [updated] = await db
.update(userUsage)
.set({ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 })
.set({ aiCallsUsed: 0 })
.where(and(eq(userUsage.userId, id), eq(userUsage.month, month)))
.returning();
@@ -35,5 +38,5 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
createdAt: new Date(),
});
return NextResponse.json({ usage: updated ?? { userId: id, month, aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 } });
return NextResponse.json({ usage: updated ?? { userId: id, month, aiCallsUsed: 0 } });
}
+4 -11
View File
@@ -5,7 +5,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { getMessages } from "@/lib/i18n/server";
@@ -59,19 +59,12 @@ export async function POST(req: NextRequest) {
if (!result.ok) return result.response;
const meal = result.data;
// Each recipe in the meal creates a real recipe row — charge the recipe
// limit for all of them before inserting anything, refunding on breach
// so a rejected meal doesn't consume quota (same pattern as meal-plan
// generation).
let chargedRecipes = 0;
// Each recipe in the meal creates a real recipe row — check the recipe
// limit covers all of them before inserting anything.
try {
for (let i = 0; i < meal.recipes.length; i++) {
await checkAndIncrementTierLimit(userId, tier, "recipe");
chargedRecipes++;
}
await checkAndIncrementTierLimit(userId, tier, "recipe", meal.recipes.length);
} catch (err) {
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
@@ -5,7 +5,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -93,18 +93,12 @@ export async function POST(req: NextRequest) {
if (!result.ok) return result.response;
const plan = result.data;
// Each plan entry creates a draft recipe — charge the recipe limit for all
// of them before inserting anything, refunding on breach so a rejected plan
// doesn't consume quota.
let chargedRecipes = 0;
// Each plan entry creates a draft recipe — check the recipe limit covers
// all of them before inserting anything.
try {
for (let i = 0; i < plan.entries.length; i++) {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe");
chargedRecipes++;
}
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe", plan.entries.length);
} catch (err) {
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
@@ -9,6 +9,7 @@ const Schema = z.object({
score: z.number().int().min(1).max(5),
reviewText: z.string().max(2000).optional(),
photoKey: z.string().max(500).optional(),
photoSizeMb: z.number().int().min(0).max(50).default(0),
});
type Params = { params: Promise<{ id: string }> };
@@ -44,6 +45,7 @@ export async function POST(req: NextRequest, { params }: Params) {
score: parsed.data.score,
reviewText: parsed.data.reviewText,
photoKey: parsed.data.photoKey,
photoSizeMb: parsed.data.photoKey ? parsed.data.photoSizeMb : 0,
updatedAt: new Date(),
})
.where(eq(ratings.id, existing.id));
@@ -57,6 +59,7 @@ export async function POST(req: NextRequest, { params }: Params) {
score: parsed.data.score,
reviewText: parsed.data.reviewText,
photoKey: parsed.data.photoKey,
photoSizeMb: parsed.data.photoKey ? parsed.data.photoSizeMb : 0,
});
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id, score: parsed.data.score });
return NextResponse.json({ created: true }, { status: 201 });
@@ -46,6 +46,7 @@ const UpdateRecipeSchema = z.object({
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
sizeMb: z.number().int().min(0).max(50).default(0),
})).max(20).optional(),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
@@ -242,6 +243,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
storageKey: photo.key,
order: i,
isCover: photo.isCover,
sizeMb: photo.sizeMb,
}))
);
}
+2
View File
@@ -50,6 +50,7 @@ const CreateRecipeSchema = z.object({
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
sizeMb: z.number().int().min(0).max(50).default(0),
})).max(20).default([]),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
@@ -188,6 +189,7 @@ export async function POST(req: NextRequest) {
storageKey: photo.key,
order: i,
isCover: photo.isCover,
sizeMb: photo.sizeMb,
}))
);
}
@@ -27,8 +27,8 @@ export async function POST(req: NextRequest) {
const { contentType, fileSize } = parsed.data;
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
try {
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", sizeMb);
} catch (err) {
if (err instanceof TierLimitError) {
@@ -41,5 +41,5 @@ export async function POST(req: NextRequest) {
const key = `user-avatars/${session!.user.id}/${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
return NextResponse.json({ url, fields, key });
return NextResponse.json({ url, fields, key, sizeMb });
}
+2 -2
View File
@@ -40,8 +40,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
try {
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", sizeMb);
} catch (err) {
if (err instanceof TierLimitError) {
@@ -55,5 +55,5 @@ export async function POST(req: NextRequest) {
const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
return NextResponse.json({ url, fields, key });
return NextResponse.json({ url, fields, key, sizeMb });
}
+6 -1
View File
@@ -20,6 +20,9 @@ const PatchSchema = z.object({
// another user's object. `null` reverts to the initials fallback (or
// Gravatar, if useGravatar is on — see below).
avatarKey: z.string().max(500).optional().nullable(),
// Size (MB, rounded up) of the file behind avatarKey — as returned by
// /api/v1/upload/avatar-presign. Ignored unless avatarKey is set.
avatarSizeMb: z.number().int().min(0).max(50).default(0),
// Off by default (Settings → Profile) — Gravatar is looked up by an MD5
// hash of the user's email, sent to a third party.
useGravatar: z.boolean().optional(),
@@ -36,7 +39,7 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Username already taken" }, { status: 409 });
}
const { avatarKey, useGravatar, ...rest } = body.data;
const { avatarKey, avatarSizeMb, useGravatar, ...rest } = body.data;
const updates: Partial<typeof users.$inferInsert> = { ...rest };
// useGravatar is only ever toggled from the settings form, never alongside
@@ -54,12 +57,14 @@ export async function PATCH(req: Request) {
const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id));
updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null;
updates.hasCustomAvatar = false;
updates.avatarSizeMb = 0;
} else {
if (!isOwnedAvatarKey(avatarKey, session.user.id)) {
return NextResponse.json({ error: "Invalid avatar key" }, { status: 400 });
}
updates.avatarUrl = getPublicUrl(avatarKey);
updates.hasCustomAvatar = true;
updates.avatarSizeMb = avatarSizeMb;
}
}