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