From f6975e98a9a0f4ffb62be6214f9a16b6388fac60 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 20 Jul 2026 20:32:31 +0200 Subject: [PATCH] fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 + apps/web/app/(app)/recipes/[id]/edit/page.tsx | 1 + apps/web/app/(app)/settings/ai/page.tsx | 10 +- apps/web/app/admin/page.tsx | 14 +- apps/web/app/admin/users/[id]/page.tsx | 17 +- .../api/v1/admin/users/[id]/usage/route.ts | 7 +- apps/web/app/api/v1/ai/generate-meal/route.ts | 15 +- .../app/api/v1/ai/meal-plan/generate/route.ts | 14 +- .../web/app/api/v1/recipes/[id]/rate/route.ts | 3 + apps/web/app/api/v1/recipes/[id]/route.ts | 2 + apps/web/app/api/v1/recipes/route.ts | 2 + .../app/api/v1/upload/avatar-presign/route.ts | 4 +- apps/web/app/api/v1/upload/presign/route.ts | 4 +- apps/web/app/api/v1/users/me/route.ts | 7 +- .../components/admin/reset-usage-button.tsx | 6 +- apps/web/components/recipe/photo-uploader.tsx | 5 +- apps/web/components/recipe/recipe-form.tsx | 2 +- .../components/settings/avatar-uploader.tsx | 6 +- .../components/social/cooked-it-review.tsx | 8 +- apps/web/lib/__tests__/tiers.test.ts | 208 +- apps/web/lib/changelog.ts | 10 +- apps/web/lib/openapi.ts | 12 +- apps/web/lib/tiers.ts | 111 +- apps/web/messages/en.json | 4 +- apps/web/messages/fr.json | 4 +- .../migrations/0052_wonderful_speed_demon.sql | 3 + .../migrations/0053_square_morgan_stark.sql | 2 + .../db/src/migrations/meta/0052_snapshot.json | 5552 +++++++++++++++++ .../db/src/migrations/meta/0053_snapshot.json | 5538 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/recipes.ts | 1 + packages/db/src/schema/social.ts | 1 + packages/db/src/schema/tiers.ts | 2 - packages/db/src/schema/users.ts | 1 + 34 files changed, 11356 insertions(+), 240 deletions(-) create mode 100644 packages/db/src/migrations/0052_wonderful_speed_demon.sql create mode 100644 packages/db/src/migrations/0053_square_morgan_stark.sql create mode 100644 packages/db/src/migrations/meta/0052_snapshot.json create mode 100644 packages/db/src/migrations/meta/0053_snapshot.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cab81..e2fd81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/(app)/recipes/[id]/edit/page.tsx b/apps/web/app/(app)/recipes/[id]/edit/page.tsx index 4b56c73..2e535ea 100644 --- a/apps/web/app/(app)/recipes/[id]/edit/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/edit/page.tsx @@ -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, diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx index fb80129..bc0ad51 100644 --- a/apps/web/app/(app)/settings/ai/page.tsx +++ b/apps/web/app/(app)/settings/ai/page.tsx @@ -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, diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index f343773..da30585 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -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`coalesce(sum(${userUsage.storageUsedMb}), 0)` }) - .from(userUsage) - .where(eq(userUsage.month, currentMonth)) - .then((r) => r[0]), + Promise.all([ + db.select({ total: sql`coalesce(sum(${recipePhotos.sizeMb}), 0)` }).from(recipePhotos).then((r) => r[0]), + db.select({ total: sql`coalesce(sum(${ratings.photoSizeMb}), 0)` }).from(ratings).then((r) => r[0]), + db.select({ total: sql`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, diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx index b1fb3da..dd053cf 100644 --- a/apps/web/app/admin/users/[id]/page.tsx +++ b/apps/web/app/admin/users/[id]/page.tsx @@ -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) { - Usage This Month ({currentMonth}) + Usage & Limits
- AI Calls Used + AI Calls Used ({currentMonth}) {usage?.aiCallsUsed ?? 0}
@@ -118,7 +119,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
Storage Used - {usage?.storageUsedMb ?? 0} MB + {storageUsedMb} MB
diff --git a/apps/web/app/api/v1/admin/users/[id]/usage/route.ts b/apps/web/app/api/v1/admin/users/[id]/usage/route.ts index 2461ae9..2b55f6e 100644 --- a/apps/web/app/api/v1/admin/users/[id]/usage/route.ts +++ b/apps/web/app/api/v1/admin/users/[id]/usage/route.ts @@ -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 } }); } diff --git a/apps/web/app/api/v1/ai/generate-meal/route.ts b/apps/web/app/api/v1/ai/generate-meal/route.ts index 4090e00..34f8634 100644 --- a/apps/web/app/api/v1/ai/generate-meal/route.ts +++ b/apps/web/app/api/v1/ai/generate-meal/route.ts @@ -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; diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 48613b3..67edf40 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -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; diff --git a/apps/web/app/api/v1/recipes/[id]/rate/route.ts b/apps/web/app/api/v1/recipes/[id]/rate/route.ts index 911e389..950a68c 100644 --- a/apps/web/app/api/v1/recipes/[id]/rate/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/rate/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/recipes/[id]/route.ts b/apps/web/app/api/v1/recipes/[id]/route.ts index 3eb367f..f9f2a39 100644 --- a/apps/web/app/api/v1/recipes/[id]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/route.ts @@ -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, })) ); } diff --git a/apps/web/app/api/v1/recipes/route.ts b/apps/web/app/api/v1/recipes/route.ts index f0ea4ed..ef14845 100644 --- a/apps/web/app/api/v1/recipes/route.ts +++ b/apps/web/app/api/v1/recipes/route.ts @@ -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, })) ); } diff --git a/apps/web/app/api/v1/upload/avatar-presign/route.ts b/apps/web/app/api/v1/upload/avatar-presign/route.ts index c698419..c51f87c 100644 --- a/apps/web/app/api/v1/upload/avatar-presign/route.ts +++ b/apps/web/app/api/v1/upload/avatar-presign/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index 35964ef..62b8aeb 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts index 8e9c2e1..41ff8dd 100644 --- a/apps/web/app/api/v1/users/me/route.ts +++ b/apps/web/app/api/v1/users/me/route.ts @@ -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 = { ...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; } } diff --git a/apps/web/components/admin/reset-usage-button.tsx b/apps/web/components/admin/reset-usage-button.tsx index 6a9643b..f5e1c37 100644 --- a/apps/web/components/admin/reset-usage-button.tsx +++ b/apps/web/components/admin/reset-usage-button.tsx @@ -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"} ); } diff --git a/apps/web/components/recipe/photo-uploader.tsx b/apps/web/components/recipe/photo-uploader.tsx index 311105b..0fd27da 100644 --- a/apps/web/components/recipe/photo-uploader.tsx +++ b/apps/web/components/recipe/photo-uploader.tsx @@ -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; key: string }; + const { url, fields, key, sizeMb } = await res.json() as { url: string; fields: Record; 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 { diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 73491bd..ce84190 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -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, diff --git a/apps/web/components/settings/avatar-uploader.tsx b/apps/web/components/settings/avatar-uploader.tsx index 9587c80..d12035f 100644 --- a/apps/web/components/settings/avatar-uploader.tsx +++ b/apps/web/components/settings/avatar-uploader.tsx @@ -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; key: string }; + const { url, fields, key, sizeMb } = await presignRes.json() as { url: string; fields: Record; 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")); diff --git a/apps/web/components/social/cooked-it-review.tsx b/apps/web/components/social/cooked-it-review.tsx index f32966e..f94aa20 100644 --- a/apps/web/components/social/cooked-it-review.tsx +++ b/apps/web/components/social/cooked-it-review.tsx @@ -68,6 +68,7 @@ export function CookedItReview({ const [hovered, setHovered] = useState(0); const [text, setText] = useState(initialText); const [photoKey, setPhotoKey] = useState(initialPhotoKey); + const [photoSizeMb, setPhotoSizeMb] = useState(0); const [preview, setPreview] = useState(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; key: string }; + const { url, fields, key, sizeMb } = (await res.json()) as { url: string; fields: Record; 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({ />