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
+6
View File
@@ -2,6 +2,12 @@
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.58.0 — 2026-07-20 21:00
### Fixed
- Recipe count and storage usage were incorrectly reset every month, same as AI calls — a user near their recipe/storage cap got fresh room on the 1st even though nothing was deleted. Only AI calls are meant to be monthly; recipes and storage are now lifetime totals derived live from actual data (recipes, photos, avatar), so deleting something is itself what frees up quota.
- Removing your avatar from Settings silently did nothing — the client sent a field the API didn't recognize, so the update was a no-op.
## 0.57.2 — 2026-07-20 20:15
### Fixed
@@ -70,6 +70,7 @@ export default async function EditRecipePage({ params }: Params) {
key: photo.storageKey,
isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey),
sizeMb: photo.sizeMb,
})),
coverIcon: recipe.coverIcon,
coverColor: recipe.coverColor,
+6 -4
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userAiKeys, userModelPrefs, users, tierDefinitions, userUsage, eq, and } from "@epicure/db";
import { UNLIMITED } from "@/lib/tiers";
import { UNLIMITED, getRecipeCount, getStorageUsedMb } from "@/lib/tiers";
import { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
@@ -30,9 +30,11 @@ export default async function AiSettingsPage() {
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true } }),
]);
const [tierDef, usage] = await Promise.all([
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
db.query.tierDefinitions.findFirst({ where: eq(tierDefinitions.tier, dbUser?.tier ?? "free") }),
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
getRecipeCount(session.user.id),
getStorageUsedMb(session.user.id),
]);
const asLimit = (n: number | undefined) => (n === undefined || n === UNLIMITED ? null : n);
@@ -46,13 +48,13 @@ export default async function AiSettingsPage() {
},
{
label: m.settings.usage.recipes,
used: usage?.recipeCount ?? 0,
used: recipeCount,
limit: asLimit(tierDef?.maxRecipes),
unlimitedLabel: m.settings.usage.unlimited,
},
{
label: m.settings.usage.storage,
used: usage?.storageUsedMb ?? 0,
used: storageUsedMb,
limit: asLimit(tierDef?.storageMb),
unit: " MB",
unlimitedLabel: m.settings.usage.unlimited,
+7 -7
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { db } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos, ratings, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
import { count, eq, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
@@ -38,11 +38,11 @@ export default async function AdminPage() {
.where(eq(userUsage.month, currentMonth))
.then((r) => r[0]),
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db
.select({ total: sql<string>`coalesce(sum(${userUsage.storageUsedMb}), 0)` })
.from(userUsage)
.where(eq(userUsage.month, currentMonth))
.then((r) => r[0]),
Promise.all([
db.select({ total: sql<string>`coalesce(sum(${recipePhotos.sizeMb}), 0)` }).from(recipePhotos).then((r) => r[0]),
db.select({ total: sql<string>`coalesce(sum(${ratings.photoSizeMb}), 0)` }).from(ratings).then((r) => r[0]),
db.select({ total: sql<string>`coalesce(sum(${users.avatarSizeMb}), 0)` }).from(users).then((r) => r[0]),
]).then(([photos, reviews, avatars]) => Number(photos?.total ?? 0) + Number(reviews?.total ?? 0) + Number(avatars?.total ?? 0)),
db.select({ count: count() }).from(users).where(gte(users.createdAt, sevenDaysAgo)).then((r) => r[0]),
db.select({ count: count() }).from(recipes).where(gte(recipes.createdAt, sevenDaysAgo)).then((r) => r[0]),
db.select({ count: count() }).from(cookingHistory).where(gte(cookingHistory.cookedAt, sevenDaysAgo)).then((r) => r[0]),
@@ -59,7 +59,7 @@ export default async function AdminPage() {
{ label: "Cooked (7d)", value: cooked7d?.count ?? 0, icon: ChefHat },
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
{ label: "Storage This Month", value: Number(storageUsage?.total ?? 0), unit: "MB", icon: HardDrive },
{ label: "Total Storage", value: storageUsage, unit: "MB", icon: HardDrive },
{
label: "Pending Reports",
value: pendingReports?.count ?? 0,
+9 -8
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { db, users, recipes, userUsage, eq, desc, count, and, sql } from "@epicure/db";
import { db, users, recipes, userUsage, eq, desc, count, and } from "@epicure/db";
import { getStorageUsedMb } from "@/lib/tiers";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,10 +43,10 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
.limit(1);
const [recipeCountRow] = await db
.select({ count: count() })
.from(recipes)
.where(eq(recipes.authorId, id));
const [[recipeCountRow], storageUsedMb] = await Promise.all([
db.select({ count: count() }).from(recipes).where(eq(recipes.authorId, id)),
getStorageUsedMb(id),
]);
const recentRecipes = await db
.select({
@@ -105,11 +106,11 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
<Card>
<CardHeader>
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
<CardTitle className="text-base">Usage &amp; Limits</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">AI Calls Used</span>
<span className="text-muted-foreground">AI Calls Used ({currentMonth})</span>
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
@@ -118,7 +119,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Storage Used</span>
<span className="font-medium">{usage?.storageUsedMb ?? 0} MB</span>
<span className="font-medium">{storageUsedMb} MB</span>
</div>
<div className="pt-2">
<ResetUsageButton userId={user.id} />
@@ -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;
}
}
@@ -10,12 +10,12 @@ export function ResetUsageButton({ userId }: { userId: string }) {
const [resetting, setResetting] = useState(false);
async function handleReset() {
if (!confirm("Reset this user's usage counters for the current month?")) return;
if (!confirm("Reset this user's AI call count for the current month?")) return;
setResetting(true);
try {
const res = await fetch(`/api/v1/admin/users/${userId}/usage`, { method: "PATCH" });
if (!res.ok) throw new Error("Reset failed");
toast.success("Usage reset");
toast.success("AI call count reset");
router.refresh();
} catch {
toast.error("Failed to reset usage");
@@ -32,7 +32,7 @@ export function ResetUsageButton({ userId }: { userId: string }) {
disabled={resetting}
className="text-destructive hover:text-destructive"
>
{resetting ? "Resetting…" : "Reset Usage"}
{resetting ? "Resetting…" : "Reset AI Calls"}
</Button>
);
}
@@ -12,6 +12,7 @@ export type PhotoEntry = {
key: string;
isCover: boolean;
preview: string;
sizeMb: number;
};
export function PhotoUploader({
@@ -42,11 +43,11 @@ export function PhotoUploader({
body: JSON.stringify({ recipeId, contentType: file.type, fileSize: file.size }),
});
if (!res.ok) continue;
const { url, fields, key } = await res.json() as { url: string; fields: Record<string, string>; key: string };
const { url, fields, key, sizeMb } = await res.json() as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) continue;
const isFirst = next.length === 0;
next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file) }];
next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file), sizeMb }];
onChange(next);
}
} finally {
+1 -1
View File
@@ -397,7 +397,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
dietaryTags,
ingredients: filteredIngredients,
steps: filteredSteps,
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover, sizeMb: p.sizeMb })),
coverIcon,
coverColor,
isBatchCook,
@@ -38,7 +38,7 @@ export function AvatarUploader({
toast.error(err.error ?? t("avatarUploadFailed"));
return;
}
const { url, fields, key } = await presignRes.json() as { url: string; fields: Record<string, string>; key: string };
const { url, fields, key, sizeMb } = await presignRes.json() as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) {
toast.error(t("avatarUploadFailed"));
@@ -51,7 +51,7 @@ export function AvatarUploader({
const saveRes = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarKey: key }),
body: JSON.stringify({ avatarKey: key, avatarSizeMb: sizeMb }),
});
if (!saveRes.ok) {
toast.error(t("avatarUploadFailed"));
@@ -71,7 +71,7 @@ export function AvatarUploader({
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: null }),
body: JSON.stringify({ avatarKey: null }),
});
if (!res.ok) {
toast.error(t("avatarUploadFailed"));
@@ -68,6 +68,7 @@ export function CookedItReview({
const [hovered, setHovered] = useState(0);
const [text, setText] = useState(initialText);
const [photoKey, setPhotoKey] = useState<string | null>(initialPhotoKey);
const [photoSizeMb, setPhotoSizeMb] = useState(0);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -94,13 +95,14 @@ export function CookedItReview({
toast.error(t("reviewPhotoFailed"));
return;
}
const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string };
const { url, fields, key, sizeMb } = (await res.json()) as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) {
toast.error(t("reviewPhotoFailed"));
return;
}
setPhotoKey(key);
setPhotoSizeMb(sizeMb);
setPreview(URL.createObjectURL(file));
} finally {
setUploading(false);
@@ -114,7 +116,7 @@ export function CookedItReview({
const res = await fetch(`/api/v1/recipes/${recipeId}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score, reviewText: text.trim() || undefined, photoKey: photoKey ?? undefined }),
body: JSON.stringify({ score, reviewText: text.trim() || undefined, photoKey: photoKey ?? undefined, photoSizeMb }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
@@ -159,7 +161,7 @@ export function CookedItReview({
/>
<button
type="button"
onClick={() => { setPhotoKey(null); setPreview(null); }}
onClick={() => { setPhotoKey(null); setPreview(null); setPhotoSizeMb(0); }}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border p-0.5"
>
<X className="h-3 w-3" />
+106 -102
View File
@@ -3,43 +3,31 @@ import { TierLimitError } from "../tiers";
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
execute: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
db: mockDb,
tierDefinitions: { tier: "tier" },
users: { id: "id", tier: "tier" },
userUsage: {
userId: "user_id",
month: "month",
aiCallsUsed: "ai_calls_used",
recipeCount: "recipe_count",
storageUsedMb: "storage_used_mb",
},
users: { id: "id", tier: "tier", avatarSizeMb: "avatar_size_mb" },
userUsage: { userId: "user_id", month: "month", aiCallsUsed: "ai_calls_used" },
recipes: { id: "id", authorId: "author_id" },
recipePhotos: { recipeId: "recipe_id", sizeMb: "size_mb" },
ratings: { userId: "user_id", photoSizeMb: "photo_size_mb" },
eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
}));
// Import after mock
const { checkAndIncrementTierLimit, incrementUsage, refundAiCall } = await import("../tiers");
const { checkAndIncrementTierLimit, getRecipeCount, getStorageUsedMb, refundAiCall } = await import("../tiers");
function makeChain(finalValue: unknown) {
const chain = {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(finalValue),
};
return chain;
}
function makeInsertChain() {
const chain = {
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
return chain;
}
beforeEach(() => {
@@ -65,45 +53,118 @@ describe("checkAndIncrementTierLimit", () => {
maxPublicRecipes: 3,
};
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
describe("aiCall (monthly, atomic upsert)", () => {
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
});
it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
// caller still thinks it's "free" (stale session), but DB says "pro"
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
expect(mockDb.execute).not.toHaveBeenCalled();
});
});
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
describe("recipe (lifetime total, live count)", () => {
it("does not throw when the live count is under the limit", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.select.mockReturnValueOnce(makeChain([{ n: 9 }]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).resolves.toBeUndefined();
expect(mockDb.execute).not.toHaveBeenCalled();
});
it("throws TierLimitError when the live count has already reached the limit", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.select.mockReturnValueOnce(makeChain([{ n: 10 }]));
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("does not query the count when the tier is unlimited", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, maxRecipes: -1 }]));
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).resolves.toBeUndefined();
expect(mockDb.select).toHaveBeenCalledTimes(2);
});
});
it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
describe("storage (lifetime total, live sum)", () => {
it("does not throw when the live sum is under the limit", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }])); // recipePhotos
mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }])); // ratings
mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }])); // users
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
await expect(checkAndIncrementTierLimit("user1", "free", "storage", 20)).resolves.toBeUndefined();
});
it("throws TierLimitError when the live sum plus amount exceeds the limit", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.select.mockReturnValueOnce(makeChain([{ total: 80 }]));
mockDb.select.mockReturnValueOnce(makeChain([{ total: 15 }]));
mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }]));
await expect(checkAndIncrementTierLimit("user1", "free", "storage", 10)).rejects.toThrow(TierLimitError);
});
});
});
describe("getRecipeCount", () => {
it("returns the live count", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ n: 7 }]));
await expect(getRecipeCount("user1")).resolves.toBe(7);
});
it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
it("returns 0 when no rows come back", async () => {
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(getRecipeCount("user1")).resolves.toBe(0);
});
});
// caller still thinks it's "free" (stale session), but DB says "pro"
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
describe("getStorageUsedMb", () => {
it("sums recipe photos, review photos, and avatar", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }]));
mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }]));
mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }]));
await expect(getStorageUsedMb("user1")).resolves.toBe(55);
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
it("treats missing rows as 0", async () => {
mockDb.select.mockReturnValueOnce(makeChain([]));
mockDb.select.mockReturnValueOnce(makeChain([]));
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
expect(mockDb.execute).not.toHaveBeenCalled();
await expect(getStorageUsedMb("user1")).resolves.toBe(0);
});
});
@@ -114,60 +175,3 @@ describe("refundAiCall", () => {
expect(mockDb.execute).toHaveBeenCalledTimes(1);
});
});
describe("incrementUsage", () => {
it("calls insert with correct aiCall initial values", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "aiCall");
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user1",
aiCallsUsed: 1,
recipeCount: 0,
storageUsedMb: 0,
})
);
});
it("calls insert with correct recipe initial values", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "recipe");
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({
recipeCount: 1,
aiCallsUsed: 0,
storageUsedMb: 0,
})
);
});
it("respects custom amount", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "storage", 50);
expect(chain.values).toHaveBeenCalledWith(
expect.objectContaining({ storageUsedMb: 50 })
);
});
it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
const chain = makeInsertChain();
mockDb.insert.mockReturnValue(chain);
await incrementUsage("user1", "aiCall");
expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
})
);
});
});
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.57.2";
export const APP_VERSION = "0.58.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.58.0",
date: "2026-07-20 21:00",
fixed: [
"Recipe count and storage usage were incorrectly reset every month, same as AI calls — a user near their recipe/storage cap got fresh room on the 1st even though nothing was deleted. Only AI calls are meant to be monthly; recipes and storage are now lifetime totals derived live from actual data (recipes, photos, avatar), so deleting something is itself what frees up quota.",
"Removing your avatar from Settings silently did nothing — the client sent a field the API didn't recognize, so the update was a no-op.",
],
},
{
version: "0.57.2",
date: "2026-07-20 20:15",
+7 -5
View File
@@ -39,7 +39,7 @@ export function generateOpenApiSpec(): object {
}));
const RecipePhotoRef = registry.register("RecipePhoto", z.object({
id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(),
id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(), sizeMb: z.number().int(),
}));
const RecipeBatchDishRef = registry.register("RecipeBatchDish", z.object({
id: z.string(), name: z.string(), description: z.string().nullable(), order: z.number().int(),
@@ -115,6 +115,7 @@ export function generateOpenApiSpec(): 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(),
@@ -159,6 +160,7 @@ export function generateOpenApiSpec(): 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(),
@@ -262,7 +264,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/favorite", summary: "Un-favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate/review a recipe (1-5, optional text + photo)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), photoKey: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "Rating updated", content: { "application/json": { schema: z.object({ updated: z.literal(true) }) } } }, 201: { description: "Rating created", content: { "application/json": { schema: z.object({ created: z.literal(true) }) } } }, 400: { description: "Validation error or cannot rate your own recipe", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate/review a recipe (1-5, optional text + photo)", security, request: { params: idParam, body: { content: { "application/json": { 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) }) } }, required: true } }, responses: { 200: { description: "Rating updated", content: { "application/json": { schema: z.object({ updated: z.literal(true) }) } } }, 201: { description: "Rating created", content: { "application/json": { schema: z.object({ created: z.literal(true) }) } } }, 400: { description: "Validation error or cannot rate your own recipe", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.object({ data: z.array(CommentRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", description: "Rate-limited: 20 req/min.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -446,7 +448,7 @@ export function generateOpenApiSpec(): object {
name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(),
bio: z.string().max(500).nullable().optional(), privateBio: z.string().max(2000).nullable().optional(),
isPrivate: z.boolean().optional(), username: z.string().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), useGravatar: z.boolean().optional(),
avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), avatarSizeMb: z.number().int().min(0).max(50).default(0).describe("Size (MB, rounded up) of the file behind avatarKey, from avatar-presign's response. Ignored unless avatarKey is set."), useGravatar: z.boolean().optional(),
}));
const ModelPrefsRef = registry.register("ModelPrefs", z.object({
id: z.string(), userId: z.string(),
@@ -812,7 +814,7 @@ export function generateOpenApiSpec(): object {
const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
id: z.string().optional(), userId: z.string(), month: z.string(),
aiCallsUsed: z.number().int(), recipeCount: z.number().int(), storageUsedMb: z.number().int(),
aiCallsUsed: z.number().int(),
}));
const tierParam = z.object({ tier: z.enum(["free", "pro", "family"]) });
@@ -845,7 +847,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role and/or tier", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's usage counters for the current month", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's AI call count for the current month", description: "Admin only. Recipe count and storage are lifetime totals derived from real data, not resettable counters.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
const generator = new OpenApiGeneratorV31(registry.definitions);
cachedSpec = generator.generateDocument({
+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,
});
}
+2 -2
View File
@@ -419,8 +419,8 @@
"apiKeys": "API Keys",
"webhooks": "Webhooks",
"usage": {
"title": "Usage this month",
"description": "Resets at the start of each month ({month}).",
"title": "Usage & limits",
"description": "AI calls reset monthly ({month}). Recipes and storage are lifetime totals.",
"aiCalls": "AI calls",
"recipes": "Recipes created",
"storage": "Storage",
+2 -2
View File
@@ -419,8 +419,8 @@
"apiKeys": "Clés API",
"webhooks": "Webhooks",
"usage": {
"title": "Utilisation ce mois-ci",
"description": "Réinitialisé au début de chaque mois ({month}).",
"title": "Utilisation et limites",
"description": "Les appels IA se réinitialisent chaque mois ({month}). Les recettes et le stockage sont des totaux à vie.",
"aiCalls": "Appels IA",
"recipes": "Recettes créées",
"storage": "Stockage",
@@ -0,0 +1,3 @@
ALTER TABLE "users" ADD COLUMN "avatar_size_mb" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "recipe_photos" ADD COLUMN "size_mb" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "ratings" ADD COLUMN "photo_size_mb" integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE "user_usage" DROP COLUMN "recipe_count";--> statement-breakpoint
ALTER TABLE "user_usage" DROP COLUMN "storage_used_mb";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -365,6 +365,20 @@
"when": 1784481857800,
"tag": "0051_chief_karen_page",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1784571682438,
"tag": "0052_wonderful_speed_demon",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1784571695517,
"tag": "0053_square_morgan_stark",
"breakpoints": true
}
]
}
+1
View File
@@ -117,6 +117,7 @@ export const recipePhotos = pgTable("recipe_photos", {
storageKey: text("storage_key").notNull(),
order: integer("order").notNull().default(0),
isCover: boolean("is_cover").notNull().default(false),
sizeMb: integer("size_mb").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
+1
View File
@@ -29,6 +29,7 @@ export const ratings = pgTable("ratings", {
score: integer("score").notNull(),
reviewText: text("review_text"),
photoKey: text("photo_key"),
photoSizeMb: integer("photo_size_mb").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
-2
View File
@@ -24,8 +24,6 @@ export const userUsage = pgTable("user_usage", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
month: text("month").notNull(),
aiCallsUsed: integer("ai_calls_used").notNull().default(0),
recipeCount: integer("recipe_count").notNull().default(0),
storageUsedMb: integer("storage_used_mb").notNull().default(0),
}, (t) => [
index("user_usage_user_month_idx").on(t.userId, t.month),
unique("user_usage_user_month_uniq").on(t.userId, t.month),
+1
View File
@@ -21,6 +21,7 @@ export const users = pgTable("users", {
name: text("name").notNull(),
avatarUrl: text("avatar_url"),
hasCustomAvatar: boolean("has_custom_avatar").notNull().default(false),
avatarSizeMb: integer("avatar_size_mb").notNull().default(0),
// Off by default — Gravatar is looked up by an MD5 hash of the user's
// email, sent to a third party (gravatar.com), which some users won't want
// regardless of MD5 being effectively reversible for a known email.