fix: correct openapi.ts schemas against actual route code (v0.27.1)

Audited every documented request/response schema against the real
Zod validators and NextResponse.json shapes across all route
families, then fixed drift: missing batch-cook/tags/photos fields on
recipes, wrong comment/rating shapes, meal-plan entry shape (day vs
dayOfWeek, nullable recipeId), shopping-list/pantry response fields,
a phantom GET /webhooks/{id} with no backing route, and numerous
missing 400/401/429 response codes.

Also fixes a real bug found during the audit: the webhook-redelivery
route returned {error: object} instead of the app-wide {error:
string} convention on validation failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 13:09:07 +02:00
parent 3e71bd29a2
commit 8ef97da2c2
6 changed files with 198 additions and 63 deletions
+5
View File
@@ -2,6 +2,11 @@
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.27.1 — 2026-07-14 12:05
### Fixed
- API Reference (`/docs`) schemas corrected against the actual code across the whole API — recipes (batch-cook fields, tags, photos were undocumented), comments, ratings, meal plans, shopping lists, pantry, feed, and several missing error responses. A webhook-redelivery route also returned a malformed error body — now matches the rest of the API's `{ error: string }` convention.
## 0.27.0 — 2026-07-14 11:15
### Added
@@ -21,7 +21,7 @@ export async function POST(req: NextRequest, { params }: Params) {
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = Schema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
if (!body.success) return NextResponse.json({ error: "Validation error", issues: body.error.issues }, { status: 400 });
const delivery = await db.query.webhookDeliveries.findFirst({
where: and(
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.27.0";
export const APP_VERSION = "0.27.1";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.27.1",
date: "2026-07-14 12:05",
fixed: [
"API Reference (`/docs`) schemas corrected against the actual code across the whole API — recipes (batch-cook fields, tags, photos were undocumented), comments, ratings, meal plans, shopping lists, pantry, feed, and several missing error responses. A webhook-redelivery route also returned a malformed error body — now matches the rest of the API's `{ error: string }` convention.",
],
},
{
version: "0.27.0",
date: "2026-07-14 11:15",
+182 -59
View File
@@ -29,12 +29,22 @@ export function generateOpenApiSpec(): object {
}));
const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({
id: z.string(), rawName: z.string(), quantity: z.number().nullable(),
id: z.string(), rawName: z.string(), quantity: z.string().nullable(),
unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(),
}));
const RecipeStepRef = registry.register("RecipeStep", z.object({
id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(),
photoUrl: z.string().nullable(), appliesTo: z.array(z.string()),
}));
const RecipePhotoRef = registry.register("RecipePhoto", z.object({
id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(),
}));
const RecipeBatchDishRef = registry.register("RecipeBatchDish", z.object({
id: z.string(), name: z.string(), description: z.string().nullable(), order: z.number().int(),
fridgeDays: z.number().int(), freezerFriendly: z.boolean(), freezerNote: z.string().nullable(),
dayOfInstructions: z.string(),
}));
const RecipeRef = registry.register("Recipe", z.object({
@@ -42,38 +52,138 @@ export function generateOpenApiSpec(): object {
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef),
photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
language: z.string().nullable(), sourceUrl: z.string().nullable(),
isBatchCook: z.boolean(), nutritionData: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET)."));
const CreateRecipeRef = registry.register("CreateRecipe", z.object({
title: z.string().min(1).max(200), description: z.string().optional(),
baseServings: z.number().int().positive(), visibility: z.enum(["private", "unlisted", "public"]),
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().positive().optional(), cookMins: z.number().int().positive().optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
aiGenerated: z.boolean().optional(),
language: z.string().max(10).optional(),
sourceUrl: z.string().url().max(2000).optional(),
dietaryTags: DietaryTagsRef.optional(),
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int() })),
steps: z.array(z.object({ order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable() })),
ingredients: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
})).max(100).default([]),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).default([]),
isBatchCook: z.boolean().default(false),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
fridgeDays: z.number().int().min(1).max(14),
freezerFriendly: z.boolean().default(false),
freezerNote: z.string().max(300).optional(),
dayOfInstructions: z.string().min(1).max(1000),
})).max(10).default([]),
}));
const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
baseServings: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
language: z.string().max(10).nullable().optional(),
sourceUrl: z.string().url().max(2000).nullable().optional(),
dietaryTags: DietaryTagsRef.optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
})).max(100).optional(),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).optional(),
isBatchCook: z.boolean().optional(),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
fridgeDays: z.number().int().min(1).max(14),
freezerFriendly: z.boolean().default(false),
freezerNote: z.string().max(300).optional(),
dayOfInstructions: z.string().min(1).max(1000),
})).max(10).optional(),
}));
const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() }));
const Pagination = z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20) });
const CommentRef = registry.register("Comment", z.object({
id: z.string(), recipeId: z.string(), userId: z.string(),
body: z.string(), parentId: z.string().nullable(), createdAt: z.string().datetime(),
id: z.string(), userId: z.string(),
content: z.string(), parentId: z.string().nullable(),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
userName: z.string().nullable(), userUsername: z.string().nullable(), userAvatarUrl: z.string().nullable(),
}));
const CollectionRecipeEntryRef = registry.register("CollectionRecipeEntry", z.object({
collectionId: z.string(), recipeId: z.string(), addedAt: z.string().datetime(),
recipe: RecipeRef,
}));
const CollectionRef = registry.register("Collection", z.object({
id: z.string(), userId: z.string(), name: z.string(),
description: z.string().nullable(), isPublic: z.boolean(), createdAt: z.string().datetime(),
description: z.string().nullable(), isPublic: z.boolean(),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
recipes: z.array(CollectionRecipeEntryRef),
}));
const MealPlanFullEntryRef = registry.register("MealPlanFullEntry", z.object({
id: z.string(), mealPlanId: z.string(),
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
recipeId: z.string().nullable(), batchDishId: z.string().nullable(),
servings: z.number().int(), note: z.string().nullable(),
recipe: RecipeRef.nullable(),
}));
const MealPlanRef = registry.register("MealPlan", z.object({
weekStart: z.string(),
entries: z.array(z.object({ id: z.string(), dayOfWeek: z.number(), mealType: z.string(), recipeId: z.string(), servings: z.number() })),
id: z.string().optional(), userId: z.string().optional(), weekStart: z.string(),
createdAt: z.string().datetime().optional(),
entries: z.array(MealPlanFullEntryRef),
}));
const PantryItemRef = registry.register("PantryItem", z.object({
@@ -82,8 +192,9 @@ export function generateOpenApiSpec(): object {
}));
const ShoppingListRef = registry.register("ShoppingList", z.object({
id: z.string(), name: z.string(), generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean() })),
id: z.string(), userId: z.string().nullable(), name: z.string(), isPublic: z.boolean(), publicEditable: z.boolean(),
generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(),
items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean(), inPantry: z.boolean(), sortOrder: z.number().int() })),
}));
const ApiKeyRef = registry.register("ApiKey", z.object({
@@ -104,7 +215,7 @@ export function generateOpenApiSpec(): object {
}));
const PaginatedRecipes = z.object({
data: z.array(RecipeRef),
data: z.array(RecipeSummaryRef),
limit: z.number(),
offset: z.number(),
});
@@ -121,15 +232,16 @@ export function generateOpenApiSpec(): object {
const idParam = z.object({ id: z.string() });
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", 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: "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: "Toggle favorite", 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 } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate recipe (15)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ score: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: Pagination }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.array(CommentRef) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ body: z.string().min(1), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: CommentRef } } }, 401: { description: "Unauthorized", 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: "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 } } } } });
// --- Recipes: bulk, fork, cooked, nutrition, notes, reviews, versions, comment reactions ---
const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
@@ -149,23 +261,23 @@ export function generateOpenApiSpec(): object {
id: z.string(), version: z.number().int(), title: z.string(), createdAt: z.string().datetime(),
}));
registry.registerPath({ method: "delete", path: "/api/v1/recipes/bulk", summary: "Bulk delete recipes (owned only)", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/recipes/bulk", summary: "Bulk delete recipes (owned only)", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional() }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()).nullable() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()).nullable() }) } } }, 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}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.record(z.string(), z.unknown()) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 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}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 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}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 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}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 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}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", 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/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- AI (Phase 3): recipe generation, transforms, chat ---
const AiChatMessageRef = registry.register("AiChatMessage", z.object({
@@ -183,14 +295,25 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/ai/translate/{id}", summary: "Translate a recipe (author only) and save as a new draft", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ targetLanguage: z.string().min(2).max(50), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/variations/{id}", summary: "Suggest creative variations on a recipe (author only)", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(5).default(3), directions: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Variations", content: { "application/json": { schema: z.object({ variations: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).optional(), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/recipe-chat", summary: "Ask a question about a specific recipe (author only)", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string().uuid(), question: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/ai/chat-history", summary: "List your AI chat history (general assistant and/or per-recipe)", description: "Omit both recipeId and scope to search across everything; pass scope=general to restrict to the homepage assistant (no recipe).", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), scope: z.enum(["general"]).optional(), q: z.string().max(200).optional(), limit: z.coerce.number().int().min(1).max(100).default(30) }) }, responses: { 200: { description: "Messages", content: { "application/json": { schema: z.object({ data: z.array(AiChatMessageRef) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/ai/chat-history", summary: "Clear your AI chat history (general assistant and/or per-recipe)", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), scope: z.enum(["general"]).optional() }) }, responses: { 200: { description: "Cleared", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
const FeedRecipeRef = registry.register("FeedRecipe", z.object({
id: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
}));
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { 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: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
const CreateCollectionRef = registry.register("CreateCollection", z.object({
name: z.string().min(1).max(100), description: z.string().max(500).optional(), isPublic: z.boolean().default(false),
}));
registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, 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 } } } } });
// --- Collections: single-resource CRUD, fork, favorite, members ---
const CollectionMemberRef = registry.register("CollectionMember", z.object({
@@ -207,14 +330,14 @@ export function generateOpenApiSpec(): object {
}));
registry.registerPath({ method: "get", path: "/api/v1/collections/{id}", summary: "Get a collection (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Collection with recipes", content: { "application/json": { schema: CollectionRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/collections/{id}", summary: "Update a collection / add-remove recipes (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateCollectionRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/collections/{id}", summary: "Update a collection / add-remove recipes (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateCollectionRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}", summary: "Delete a collection (owner only)", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/fork", summary: "Fork a public (or your own) collection", security, request: { params: idParam }, responses: { 201: { description: "New collection id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/favorite", summary: "Favorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/favorite", summary: "Unfavorite a collection", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections/{id}/members", summary: "List collection members (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(CollectionMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/members", summary: "Invite a member by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/collections/{id}/members", summary: "Invite a member by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get your own meal plan for a week", security, request: { params: weekStartParam }, responses: { 200: { description: "Meal plan with entries", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}", summary: "Create the meal plan for a week (idempotent)", security, request: { params: weekStartParam }, responses: { 200: { description: "Already existed", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } }, 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } } } });
@@ -246,23 +369,23 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "List entries for a week (lean — polled for live updates)", security, request: { params: weekStartParam }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/entries", summary: "Add/replace an entry for a day+mealType slot", description: "Creates the week's plan if it doesn't exist yet. Replaces any existing entry in the same slot.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: CreateMealPlanEntryRef } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Recipe or dish not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/{entryId}", summary: "Delete one entry", security, request: { params: z.object({ weekStart: z.string(), entryId: z.string() }) }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/bulk", summary: "Bulk-delete entries (clear a day or the whole week)", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/entries/bulk", summary: "Bulk-delete entries (clear a day or the whole week)", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/members", summary: "List collaborators on a week's plan (owner only)", security, request: { params: weekStartParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(MealPlanMemberRef) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 400: { description: "Cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } }, 400: { description: "Invalid weekStart", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}", summary: "View a meal plan you're a collaborator on", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Plan with entries, your role, and the owner's name", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "List entries on a shared plan (lean — polled for live updates)", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: LimitOffset }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 400: { description: "entryId or ids required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", security, request: { body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().optional(), expiresAt: z.string().datetime().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 } } } } });
registry.registerPath({ method: "put", path: "/api/v1/pantry/{id}", summary: "Update a pantry item", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200).optional(), quantity: z.string().nullable().optional(), unit: z.string().nullable().optional(), expiresAt: z.string().datetime().nullable().optional() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/pantry/{id}", summary: "Delete a pantry item", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/bulk", summary: "Add several pantry items at once, merging quantities into existing matching items", security, request: { body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().max(50).optional() })).min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", description: "Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Users: profile, export, prefs, nutrition, social ---
const usernameParam = z.object({ username: z.string() });
@@ -340,13 +463,13 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/follow", summary: "Unfollow a user", security, request: { params: usernameParam }, responses: { 200: { description: "Not following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/block", summary: "Block a user", description: "Also removes any existing follow relationship in either direction.", security, request: { params: usernameParam }, responses: { 200: { description: "Blocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 400: { description: "Cannot block yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/users/{username}/block", summary: "Unblock a user", security, request: { params: usernameParam }, responses: { 200: { description: "Unblocked", content: { "application/json": { schema: z.object({ blocked: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", description: "Returns all of your lists, unpaginated.", security, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", description: "Optionally seeded from a meal-plan week's merged ingredients (pantry-aware).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).optional(), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Shopping lists: single-resource CRUD, items, members, export ---
const ShoppingListItemRef = registry.register("ShoppingListItem", z.object({
id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(),
aisle: z.string().nullable(), checked: z.boolean(), sortOrder: z.number().int().optional(),
aisle: z.string().nullable(), checked: z.boolean(), sortOrder: z.number().int(),
}));
const UpdateShoppingListRef = registry.register("UpdateShoppingList", z.object({
completed: z.boolean().optional(), name: z.string().min(1).max(100).optional(),
@@ -364,7 +487,8 @@ export function generateOpenApiSpec(): object {
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
}));
const GroceryExportRef = registry.register("GroceryExport", z.object({
items: z.array(z.object({ rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable() })),
listName: z.string(),
items: z.array(z.object({ name: z.string(), quantity: z.string().nullable(), unit: z.string().nullable() })),
}));
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}", summary: "Get a shopping list with items", security, request: { params: idParam }, responses: { 200: { description: "List", content: { "application/json": { schema: ShoppingListRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -376,12 +500,12 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "put", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Update one item (check/uncheck, rename, re-quantify, move aisle)", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }), body: { content: { "application/json": { schema: UpdateShoppingListItemRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/items/{itemId}", summary: "Delete one item", description: "No auth required if the list is public-editable.", security, request: { params: z.object({ id: z.string(), itemId: z.string() }) }, responses: { 200: { description: "Deleted", content: { "application/json": { schema: z.object({ deleted: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/members", summary: "List collaborators (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(ShoppingListMemberRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/members", summary: "Invite a collaborator by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/members", summary: "Invite a collaborator by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- AI keys: users' own third-party provider keys (BYOK), used instead of app-wide AI credentials ---
@@ -416,7 +540,6 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/webhooks", summary: "List your webhooks", description: "The signing secret is never included — it is only ever returned once, at creation time.", security, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(WebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/webhooks", summary: "Create a webhook", description: "Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security, request: { body: { content: { "application/json": { schema: CreateWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}", summary: "Get a webhook", security, request: { params: idParam }, responses: { 200: { description: "Webhook", content: { "application/json": { schema: WebhookRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/webhooks/{id}", summary: "Update a webhook's URL, events, or active state", description: "Validates the URL against SSRF targets if changed.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: WebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/webhooks/{id}", summary: "Delete a webhook", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -514,7 +637,7 @@ export function generateOpenApiSpec(): object {
targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string().min(1), reason: z.string().min(1).max(1000),
}));
registry.registerPath({ method: "post", path: "/api/v1/reports", summary: "Report a recipe, comment, or user for moderation", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: CreateReportRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Target not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/reports", summary: "Report a recipe, comment, or user for moderation", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: CreateReportRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Target not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Comments: edit & delete ---
registry.registerPath({ method: "put", path: "/api/v1/comments/{id}", summary: "Edit your own comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000) }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or not yours", content: { "application/json": { schema: ApiErrorRef } } } } });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.27.0",
"version": "0.27.1",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.27.0",
"version": "0.27.1",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",