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
@@ -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.