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:
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user