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({ />