docs: document full API surface in OpenAPI spec (v0.26.0)

Went from 14 to ~100 documented endpoints in lib/openapi.ts — pantry,
meal-plans, shopping-lists, collections, users/social, ai/*, webhooks,
conversations, notifications, and admin routes now all appear in
/docs with accurate request/response schemas. No auth/route changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 10:54:57 +02:00
parent 6096e49008
commit 6c4fc93aab
5 changed files with 489 additions and 6 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.26.0 — 2026-07-14 10:20
### Added
- **API Reference (`/docs`) now covers the full API**: went from 14 documented endpoints to all ~100 — pantry, meal plans, shopping lists, collections, users/social, AI generation, webhooks, conversations, notifications, and admin routes are now all documented with request/response schemas.
## 0.25.1 — 2026-07-14 09:40
### Fixed
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.25.1";
export const APP_VERSION = "0.26.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.26.0",
date: "2026-07-14 10:20",
added: [
"**API Reference (`/docs`) now covers the full API**: went from 14 documented endpoints to all ~100 — pantry, meal plans, shopping lists, collections, users/social, AI generation, webhooks, conversations, notifications, and admin routes are now all documented with request/response schemas.",
],
},
{
version: "0.25.1",
date: "2026-07-14 09:40",
+474 -3
View File
@@ -4,6 +4,7 @@ import {
extendZodWithOpenApi,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
import { USERNAME_PATTERN } from "@/lib/username";
// Nothing at module level — Next.js evaluates route modules at build time to
// determine static vs dynamic, which would run registry.register() before
@@ -118,6 +119,7 @@ export function generateOpenApiSpec(): object {
const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });
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: "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 } } } } });
@@ -128,18 +130,487 @@ export function generateOpenApiSpec(): object {
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 } } } } });
// --- 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) }));
const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", z.object({
ids: z.array(z.string()).min(1).max(100),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
}));
const RecipeNoteRef = registry.register("RecipeNote", z.object({ content: z.string(), updatedAt: z.string().datetime() }).nullable());
const RecipeReviewRef = registry.register("RecipeReview", z.object({
id: z.string(), score: z.number().int(), reviewText: z.string().nullable(), photoKey: z.string().nullable(),
createdAt: z.string().datetime(),
user: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
}));
const RecipeVersionSummaryRef = registry.register("RecipeVersionSummary", z.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: "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}/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 } } } } });
// --- AI (Phase 3): recipe generation, transforms, chat ---
const AiChatMessageRef = registry.register("AiChatMessage", z.object({
id: z.string(), role: z.enum(["user", "assistant"]), content: z.string(),
createdAt: z.string().datetime(), recipeId: z.string().nullable(), recipeTitle: z.string().nullable(),
}));
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: 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/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 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: "Created recipe id", content: { "application/json": { schema: z.object({ id: 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/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(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 } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: 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: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", 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/drinks/{id}", summary: "Suggest drink pairings for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Drinks", content: { "application/json": { schema: z.object({ drinks: 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/pairings/{id}", summary: "Suggest food pairings/side dishes for a recipe", description: "Consumes AI quota.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ count: z.number().int().min(1).max(6).default(4), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } } } }, responses: { 200: { description: "Pairings", content: { "application/json": { schema: z.object({ pairings: 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/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/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 } } } } });
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 } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Collections: single-resource CRUD, fork, favorite, members ---
const CollectionMemberRef = registry.register("CollectionMember", z.object({
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
}));
const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
name: z.string().min(1).max(100).optional(), description: z.string().max(500).optional(), isPublic: z.boolean().optional(),
addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(),
removeRecipeId: z.string().optional(), removeRecipeIds: z.array(z.string()).max(200).optional(),
}));
const InviteMemberRef = registry.register("InviteMember", z.object({
email: z.string().email().optional(), userId: z.string().optional(), role: z.enum(["viewer", "editor"]),
}));
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: "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: "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() }) } } } } });
// --- Meal-plan entries, bulk clear, members, nutrition, .ics export, shared plans ---
const CreateMealPlanEntryRef = registry.register("CreateMealPlanEntry", z.object({
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
recipeId: z.string().optional(), batchDishId: z.string().optional(),
servings: z.number().int().min(1).max(100).default(2), note: z.string().max(500).optional(),
}));
const MealPlanEntryLeanRef = registry.register("MealPlanEntryLean", z.object({
id: z.string(), day: z.string(), mealType: z.string(), servings: z.number(),
recipe: z.object({ id: z.string(), title: z.string() }).nullable(),
batchDish: z.object({ id: z.string(), name: z.string() }).nullable().optional(),
note: z.string().nullable().optional(),
}));
const MealPlanMemberRef = registry.register("MealPlanMember", z.object({
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
user: z.object({ name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }),
}));
const NutritionTotalsRef = registry.register("NutritionTotals", z.object({ calories: z.number(), protein: z.number(), carbs: z.number(), fat: z.number() }));
const WeeklyNutritionRef = registry.register("WeeklyNutrition", z.object({
totals: NutritionTotalsRef, dailyAverage: NutritionTotalsRef,
byDay: z.record(z.string(), NutritionTotalsRef), plannedDays: z.number().int(), unknownCount: z.number().int(),
goals: z.object({ caloriesKcal: z.number().nullable(), proteinG: z.number().nullable(), carbsG: z.number().nullable(), fatG: z.number().nullable() }).nullable(),
coverage: NutritionTotalsRef,
}));
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: "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: "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/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/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 } } } } });
// --- Users: profile, export, prefs, nutrition, social ---
const usernameParam = z.object({ username: z.string() });
const UpdateMeRef = registry.register("UpdateMe", z.object({
name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(),
bio: z.string().max(500).nullable().optional(), privateBio: z.string().max(2000).nullable().optional(),
isPrivate: z.boolean().optional(), username: z.string().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
avatarUrl: z.string().url().max(2048).nullable().optional(), useGravatar: z.boolean().optional(),
}));
const ModelPrefsRef = registry.register("ModelPrefs", z.object({
id: z.string(), userId: z.string(),
textProvider: z.string().nullable(), textModel: z.string().nullable(),
visionProvider: z.string().nullable(), visionModel: z.string().nullable(),
mealPlanProvider: z.string().nullable(), mealPlanModel: z.string().nullable(),
updatedAt: z.string().datetime(),
}));
const UpdateModelPrefsRef = registry.register("UpdateModelPrefs", z.object({
textProvider: z.string().nullable().optional(), textModel: z.string().nullable().optional(),
visionProvider: z.string().nullable().optional(), visionModel: z.string().nullable().optional(),
mealPlanProvider: z.string().nullable().optional(), mealPlanModel: z.string().nullable().optional(),
}));
const NotificationPrefsRef = registry.register("NotificationPrefs", z.object({
follow: z.boolean(), comment: z.boolean(), reply: z.boolean(), reaction: z.boolean(),
rating: z.boolean(), mention: z.boolean(), leftoverExpiring: z.boolean(), shoppingList: z.boolean(),
}));
const UpdateNotificationPrefsRef = registry.register("UpdateNotificationPrefs", z.object({
follow: z.boolean().optional(), comment: z.boolean().optional(), reply: z.boolean().optional(), reaction: z.boolean().optional(),
rating: z.boolean().optional(), mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(),
}));
const NutritionGoalsRef2 = registry.register("NutritionGoalsMe", z.object({
id: z.string(), userId: z.string(),
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(),
carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
updatedAt: z.string().datetime(),
}).nullable());
const UpdateNutritionGoalsRef = registry.register("UpdateNutritionGoals", z.object({
caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
}));
const NutritionDiaryEntryRef = registry.register("NutritionDiaryEntry", z.object({
id: z.string(), recipeId: z.string(), title: z.string(), servings: z.number(),
cookedAt: z.string().datetime(), nutritionKnown: z.boolean(),
}));
const NutritionTotalsExtRef = registry.register("NutritionTotalsExt", z.object({
calories: z.number(), proteinG: z.number(), carbsG: z.number(), fatG: z.number(), fiberG: z.number(), sodiumMg: z.number(),
}));
const NutritionGoalsSummaryRef = registry.register("NutritionGoalsSummary", z.object({
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(), carbsG: z.number().int().nullable(), fatG: z.number().int().nullable(),
}));
const NutritionDiaryRef = registry.register("NutritionDiary", z.object({
date: z.string(), totals: NutritionTotalsExtRef, goals: NutritionGoalsSummaryRef.nullable(),
coverage: NutritionTotalsRef, entries: z.array(NutritionDiaryEntryRef), unknownCount: z.number().int(),
}));
const UserSearchResultRef = registry.register("UserSearchResult", z.object({
id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
}));
const UserProfilePublicRef = registry.register("UserProfilePublic", z.object({
id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable(), bio: z.string().nullable(),
createdAt: z.string().datetime(), followerCount: z.number().int(), followingCount: z.number().int(),
recipeCount: z.number().int(), isFollowing: z.boolean(),
}));
registry.registerPath({ method: "patch", path: "/api/v1/users/me", summary: "Update your profile", description: "Toggling useGravatar or clearing avatarUrl re-derives the effective avatar (Gravatar or initials fallback).", security, request: { body: { content: { "application/json": { schema: UpdateMeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), avatarUrl: z.string().nullable() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Username already taken", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/export", summary: "Export all of your account data as a JSON file", description: "Rate-limited: 3 req/hour. Includes profile, recipes, social graph, collections, meal plans, pantry, shopping lists, webhooks, API keys, messages, and more.", security, responses: { 200: { description: "Downloadable JSON export", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/model-prefs", summary: "Get your AI model provider/model preferences", security, responses: { 200: { description: "Preferences or null", content: { "application/json": { schema: ModelPrefsRef.nullable() } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", security, request: { body: { content: { "application/json": { schema: UpdateModelPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", 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: "get", path: "/api/v1/users/me/notification-prefs", summary: "Get your notification category preferences", description: "Categories with no saved row default to enabled.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: NotificationPrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", 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: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC)") }) }, responses: { 200: { description: "Diary", content: { "application/json": { schema: NutritionDiaryRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", 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: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
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: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: 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(),
}));
const UpdateShoppingListRef = registry.register("UpdateShoppingList", z.object({
completed: z.boolean().optional(), name: z.string().min(1).max(100).optional(),
isPublic: z.boolean().optional(), publicEditable: z.boolean().optional(),
}));
const AddShoppingListItemsRef = registry.register("AddShoppingListItems", z.object({
items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).min(1),
}));
const UpdateShoppingListItemRef = registry.register("UpdateShoppingListItem", z.object({
checked: z.boolean().optional(), rawName: z.string().min(1).optional(), quantity: z.string().nullable().optional(),
unit: z.string().nullable().optional(), aisle: z.string().nullable().optional(), sortOrder: z.number().int().optional(),
}));
const ShoppingListMemberRef = registry.register("ShoppingListMember", z.object({
id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), createdAt: z.string().datetime(),
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() })),
}));
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 } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}", summary: "Rename, mark complete, or toggle public link/public-editable", description: "Renaming, deleting, and toggling the public link are owner-only. Turning off the public link also revokes public editing.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateShoppingListRef } }, 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}", summary: "Delete a shopping list (owner only)", security, request: { params: idParam }, 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/shopping-lists/{id}/items", summary: "List items (lean — polled for live updates)", description: "No auth required if the list is public-editable — access is the link (id) itself.", security, request: { params: idParam }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ items: z.array(ShoppingListItemRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/items", summary: "Add items", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: AddShoppingListItemsRef } }, required: true } }, responses: { 201: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/shopping-lists/{id}/items", summary: "Bulk update sortOrder/aisle (e.g. after drag-and-drop reorder)", description: "No auth required if the list is public-editable.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ id: z.string().min(1), sortOrder: z.number().int().optional(), aisle: z.string().nullable().optional() })).min(1) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
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: "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: "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 ---
const AiKeyRef = registry.register("AiKey", z.object({
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), createdAt: z.string().datetime(),
}).describe("Never includes the encrypted key value itself — only which providers are configured."));
registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API.", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response.", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", 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 } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Webhooks: outbound event notifications to a user-provided URL, plus delivery log/redelivery ---
const WebhookEventEnum = z.enum(["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added"]);
const WebhookRef = registry.register("Webhook", z.object({
id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
}));
const CreateWebhookRef = registry.register("CreateWebhook", z.object({
url: z.string().min(1).max(2048), events: z.array(WebhookEventEnum).default([]),
}));
const CreatedWebhookRef = registry.register("CreatedWebhook", z.object({
id: z.string(), userId: z.string(), url: z.string(), events: z.array(WebhookEventEnum),
secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
active: z.boolean(), createdAt: z.string().datetime(),
}));
const UpdateWebhookRef = registry.register("UpdateWebhook", z.object({
url: z.string().min(1).max(2048).optional(), events: z.array(WebhookEventEnum).optional(), active: z.boolean().optional(),
}));
const WebhookDeliveryRef = registry.register("WebhookDelivery", z.object({
id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
}));
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 } } } } });
registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Conversations: 1:1 direct messages ---
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({
id: z.string(),
otherUser: z.object({ id: z.string(), name: z.string(), username: z.string().nullable(), avatarUrl: z.string().nullable() }).nullable(),
lastMessage: z.string().nullable(), lastMessageAt: z.string().datetime(), unreadCount: z.number(),
}));
const StartConversationRef = registry.register("StartConversation", z.object({
username: z.string().min(1), content: z.string().min(1).max(4000).optional(),
}));
const MessageRef = registry.register("Message", z.object({
id: z.string(), conversationId: z.string(), senderId: z.string(), content: z.string(), createdAt: z.string().datetime(),
}));
const SendMessageRef = registry.register("SendMessage", z.object({ content: z.string().min(1).max(4000) }));
registry.registerPath({ method: "get", path: "/api/v1/conversations", summary: "List your conversations, most recently active first", description: "Up to 50 conversations.", security, responses: { 200: { description: "Conversations", content: { "application/json": { schema: z.object({ conversations: z.array(ConversationSummaryRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/conversations", summary: "Start (or reuse) a conversation with a user by username, optionally sending a first message", description: "Rate-limited: 30 req/min. Fails (404) if either side has blocked the other, target doesn't exist, or you target yourself.", security, request: { body: { content: { "application/json": { schema: StartConversationRef } }, required: true } }, responses: { 201: { description: "Conversation id", content: { "application/json": { schema: z.object({ conversationId: z.string() }) } } }, 400: { description: "Validation error or cannot message yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or blocked", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/conversations/{id}/messages", summary: "List messages in a conversation (cursor-paginated, oldest-first per page)", description: "Loading the newest page (no `before`) also marks the conversation read.", security, request: { params: idParam, query: z.object({ before: z.string().datetime().optional().describe("ISO createdAt cursor — returns the 50 messages just before it") }) }, responses: { 200: { description: "Page of messages", content: { "application/json": { schema: z.object({ messages: z.array(MessageRef), nextCursor: z.string().nullable() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/conversations/{id}/messages", summary: "Send a message in a conversation", description: "Rate-limited: 30 req/min. Fails (404) if the other participant has blocked you or vice versa.", security, request: { params: idParam, body: { content: { "application/json": { schema: SendMessageRef } }, required: true } }, responses: { 201: { description: "Message 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 } } } } });
// --- Notifications ---
const NotificationRef = registry.register("Notification", z.object({
id: z.string(), type: z.enum(["follow", "comment", "reply", "reaction", "rating", "mention"]),
recipeId: z.string().nullable(), commentId: z.string().nullable(), read: z.boolean(), createdAt: z.string().datetime(),
actorId: z.string(), actorName: z.string(), actorUsername: z.string().nullable(), actorAvatarUrl: z.string().nullable(),
}));
registry.registerPath({ method: "get", path: "/api/v1/notifications", summary: "List your notifications, newest first", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated notifications", content: { "application/json": { schema: z.object({ notifications: z.array(NotificationRef), unreadCount: z.number(), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/notifications/{id}/read", summary: "Mark a single notification read", security, request: { params: idParam }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/notifications/read", summary: "Mark notifications read — one by id, or all of yours if omitted", security, request: { body: { content: { "application/json": { schema: z.object({ id: z.string().optional() }) } } } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Feed: trending & personalized ---
const TrendingRecipeRef = registry.register("TrendingRecipe", 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"]),
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
favoriteCount: z.number().int(),
}));
const ForYouRecipeRef = registry.register("ForYouRecipe", 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"]),
aiGenerated: z.boolean(), createdAt: 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/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", 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(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
registry.registerPath({ method: "get", path: "/api/v1/feed/for-you", summary: "Personalized recipe feed, ranked from your favorites and 4+ ratings", description: "Scores a bounded window of the 200 most recent eligible candidates rather than the whole table, so `total` reflects that window, not every eligible recipe.", security, request: { query: z.object({ limit: z.coerce.number().int().max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Ranked, paginated", content: { "application/json": { schema: z.object({ data: z.array(ForYouRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Search ---
const SearchResultRef = registry.register("SearchResult", z.object({
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
avgRating: z.number().nullable(), ratingCount: z.number().int(),
}));
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Invites ---
registry.registerPath({ method: "get", path: "/api/v1/invites/{token}", summary: "Check whether an invite token is valid", description: "Public — no auth required.", security: [], request: { params: z.object({ token: z.string() }) }, responses: { 200: { description: "Validity (and the invited email, if valid)", content: { "application/json": { schema: z.object({ valid: z.boolean(), email: z.string().optional() }) } } } } });
// --- Uploads: S3 presigned URLs ---
const PresignUploadRef = registry.register("PresignUpload", z.object({
contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
recipeId: z.string().uuid(), purpose: z.enum(["recipe", "review"]).default("recipe"),
fileSize: z.number().int().positive().max(10 * 1024 * 1024, "File exceeds 10MB limit"),
}));
const PresignAvatarUploadRef = registry.register("PresignAvatarUpload", z.object({
contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]),
fileSize: z.number().int().positive().max(5 * 1024 * 1024, "File exceeds 5MB limit"),
}));
const PresignResponseRef = registry.register("PresignResponse", z.object({ url: z.string(), key: z.string() }));
registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 URL to upload a recipe or review photo", description: "Recipe photos: recipeId must be your own recipe. Review photos: recipe must be visible to you. Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignUploadRef } }, required: true } }, responses: { 200: { description: "Presigned URL and object key", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage 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/upload/avatar-presign", summary: "Get a presigned S3 URL to upload your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned URL and object key", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Push notifications ---
const PushSubscribeRef = registry.register("PushSubscribe", z.object({
endpoint: z.string().url(), keys: z.object({ p256dh: z.string(), auth: z.string() }),
}));
const PushUnsubscribeRef = registry.register("PushUnsubscribe", z.object({ endpoint: z.string() }));
registry.registerPath({ method: "post", path: "/api/v1/push/subscribe", summary: "Register (or update) a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushSubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/push/subscribe", summary: "Remove a web push subscription for this device", description: "Session only — device-bound, no API key auth.", security: [{ SessionCookie: [] }], request: { body: { content: { "application/json": { schema: PushUnsubscribeRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid body", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Reports ---
const CreateReportRef = registry.register("CreateReport", z.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 } } } } });
// --- 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 } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/comments/{id}", summary: "Delete a comment (author, or an admin/moderator)", description: "Non-author deletes re-check the caller's role against the DB rather than the (up to 5-minute-stale) session cookie cache.", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Admin (Admin only. Session-cookie auth only — requireAdmin() has no API-key support) ---
const adminSecurity: Array<Record<string, string[]>> = [{ SessionCookie: [] }];
const InviteRef = registry.register("Invite", z.object({
id: z.string(), token: z.string(), email: z.string().nullable(),
role: z.enum(["user", "moderator", "admin"]), tier: z.enum(["free", "pro"]),
createdById: z.string(), createdAt: z.string().datetime(),
expiresAt: z.string().datetime().nullable(), usedAt: z.string().datetime().nullable(),
usedById: z.string().nullable(),
}));
const CreateInviteRef = registry.register("CreateInvite", z.object({
email: z.string().optional(), role: z.enum(["user", "moderator", "admin"]).default("user"),
tier: z.enum(["free", "pro"]).default("free"), expiresInDays: z.number().default(7),
}));
const AdminReportRef = registry.register("AdminReport", z.object({
id: z.string(), targetType: z.enum(["recipe", "comment", "user"]), targetId: z.string(),
reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
createdAt: z.string().datetime(), reporterId: z.string(),
reporterName: z.string().nullable(), reporterEmail: z.string(),
}));
const ResolveReportBodyRef = registry.register("ResolveReportBody", z.object({
status: z.enum(["reviewed", "dismissed"]),
}));
const ResolvedReportRef = registry.register("ResolvedReport", z.object({
id: z.string(), reporterId: z.string(), targetType: z.enum(["recipe", "comment", "user"]),
targetId: z.string(), reason: z.string(), status: z.enum(["pending", "reviewed", "dismissed"]),
createdAt: z.string().datetime(), reviewedById: z.string().nullable(), reviewedAt: z.string().datetime().nullable(),
}));
const UpdateSiteSettingsRef = registry.register("UpdateSiteSettings", z.object({
OPENAI_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
ANTHROPIC_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
OPENROUTER_API_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
OPENROUTER_DEFAULT_MODEL: z.string().nullable().optional(),
OLLAMA_BASE_URL: z.string().nullable().optional(),
NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().nullable().optional(),
VAPID_PRIVATE_KEY: z.string().nullable().optional().describe("secret — write-only, never returned"),
SIGNUPS_DISABLED: z.string().nullable().optional(),
}).describe("Unknown keys are silently ignored. Setting a key to null or \"\" deletes it (falls back to env var)."));
const TestEmailBodyRef = registry.register("TestEmailBody", z.object({ to: z.string().min(1) }));
const UpdateTierDefinitionBodyRef = registry.register("UpdateTierDefinitionBody", z.object({
maxRecipes: z.number().int().optional(), aiCallsPerMonth: z.number().int().optional(),
storageMb: z.number().int().optional(), maxPublicRecipes: z.number().int().optional(),
}).describe("Each field must be a non-negative integer, or -1 for unlimited."));
const TierDefinitionRef = registry.register("TierDefinition", z.object({
tier: z.enum(["free", "pro"]), maxRecipes: z.number().int(), aiCallsPerMonth: z.number().int(),
storageMb: z.number().int(), maxPublicRecipes: z.number().int(),
}));
const AdminCreateUserBodyRef = registry.register("AdminCreateUserBody", z.object({
email: z.string(), name: z.string(), role: z.enum(["user", "moderator", "admin"]).default("user"),
tier: z.enum(["free", "pro"]).default("free"),
}));
const AdminCreatedUserRef = registry.register("AdminCreatedUser", z.object({
user: z.object({ id: z.string(), email: z.string(), name: z.string(), role: z.string(), tier: z.string() }),
}));
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro"]).optional(),
}));
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
user: z.object({ id: z.string(), role: z.string(), tier: z.string() }),
}));
const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
id: z.string().optional(), userId: z.string(), month: z.string(),
aiCallsUsed: z.number().int(), recipeCount: z.number().int(), storageUsedMb: z.number().int(),
}));
const tierParam = z.object({ tier: z.enum(["free", "pro"]) });
registry.registerPath({ method: "get", path: "/api/v1/admin/invites", summary: "List invites", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Invites", content: { "application/json": { schema: z.object({ invites: z.array(InviteRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/admin/invites/{id}", summary: "Revoke an invite", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Revoked", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Invite not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", 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: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/test-email", summary: "Send a test verification-style email to a given address", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: TestEmailBodyRef } }, required: true } }, responses: { 200: { description: "Sent", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Missing 'to'", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "BETTER_AUTH_URL not configured, or send failed", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/tiers/{tier}", summary: "Update numeric limits for a tier definition", description: "Admin only.", security: adminSecurity, request: { params: tierParam, body: { content: { "application/json": { schema: UpdateTierDefinitionBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ tierDefinition: TierDefinitionRef }) } } }, 400: { description: "Invalid tier or field value", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Tier not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role and/or tier", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's usage counters for the current month", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
const generator = new OpenApiGeneratorV31(registry.definitions);
cachedSpec = generator.generateDocument({
openapi: "3.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.25.1",
"version": "0.26.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.25.1",
"version": "0.26.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",