import { OpenApiGeneratorV31, OpenAPIRegistry, 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 // extendZodWithOpenApi patches the Zod prototype. Wrap everything lazily. let cachedSpec: object | null = null; export function generateOpenApiSpec(): object { if (cachedSpec) return cachedSpec; extendZodWithOpenApi(z); const registry = new OpenAPIRegistry(); const security: Array> = [{ SessionCookie: [] }, { BearerApiKey: [] }]; registry.registerComponent("securitySchemes", "SessionCookie", { type: "apiKey", in: "cookie", name: "better-auth.session_token" }); registry.registerComponent("securitySchemes", "BearerApiKey", { type: "http", scheme: "bearer", bearerFormat: "ek_..." }); const DietaryTagsRef = registry.register("DietaryTags", z.object({ vegan: z.boolean().optional(), vegetarian: z.boolean().optional(), glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(), nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(), })); const RecipeIngredientRef = registry.register("RecipeIngredient", z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), note: z.string().nullable(), order: z.number().int(), })); const RecipeStepRef = registry.register("RecipeStep", z.object({ id: z.string(), order: z.number().int(), instruction: z.string(), timerSeconds: z.number().int().nullable(), photoUrl: z.string().nullable(), appliesTo: z.array(z.string()), })); const RecipePhotoRef = registry.register("RecipePhoto", z.object({ id: z.string(), storageKey: z.string(), order: z.number().int(), isCover: z.boolean(), sizeMb: z.number().int(), })); const RecipeBatchDishRef = registry.register("RecipeBatchDish", z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), order: z.number().int(), fridgeDays: z.number().int(), freezerFriendly: z.boolean(), freezerNote: z.string().nullable(), dayOfInstructions: z.string(), })); const NutritionInputRef = registry.register("NutritionInput", z.object({ calories: z.number().min(0).max(10000), proteinG: z.number().min(0).max(1000), carbsG: z.number().min(0).max(1000), fatG: z.number().min(0).max(1000), fiberG: z.number().min(0).max(1000), sodiumMg: z.number().min(0).max(100000), }).describe("Per serving.")); const RecipeRef = registry.register("Recipe", z.object({ id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(), baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(), coverIcon: z.string().nullable().describe("Cover placeholder icon key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."), coverColor: z.string().nullable().describe("Cover placeholder gradient key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."), isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(), nutritionManual: z.boolean().describe("True if nutritionData was entered by the author rather than AI-estimated."), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(), ingredients: z.array(RecipeIngredientRef), steps: z.array(RecipeStepRef), photos: z.array(RecipePhotoRef), batchDishes: z.array(RecipeBatchDishRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), })); const RecipeSummaryRef = registry.register("RecipeSummary", z.object({ id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(), baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(), language: z.string().nullable(), sourceUrl: z.string().nullable(), coverIcon: z.string().nullable(), coverColor: z.string().nullable(), isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(), nutritionManual: z.boolean(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }).describe("Recipe list rows — no ingredients/steps/photos/batchDishes (unlike the single-recipe GET).")); const CreateRecipeRef = registry.register("CreateRecipe", z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100).default(4), recipeType: z.enum(["dish", "drink"]).default("dish"), visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), prepMins: z.number().int().min(0).max(1440).optional(), cookMins: z.number().int().min(0).max(1440).optional(), tags: z.array(z.string().min(1).max(50)).max(20).default([]), aiGenerated: z.boolean().optional(), language: z.string().max(10).optional(), sourceUrl: z.string().url().max(2000).optional(), dietaryTags: DietaryTagsRef.optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.number(), z.string()]).optional(), unit: z.string().max(50).optional(), note: z.string().max(500).optional(), order: z.number().int().default(0), })).max(100).default([]), steps: z.array(z.object({ instruction: z.string().min(1).max(2000), timerSeconds: z.number().int().min(0).max(86400).optional(), order: z.number().int().optional(), appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]), })).max(100).default([]), photos: z.array(z.object({ key: z.string().min(1).max(500), isCover: z.boolean().default(false), sizeMb: z.number().int().min(0).max(50).default(0), })).max(20).default([]), coverIcon: z.string().max(50).nullable().optional(), coverColor: z.string().max(50).nullable().optional(), isBatchCook: z.boolean().default(false), dishes: z.array(z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), fridgeDays: z.number().int().min(1).max(14), freezerFriendly: z.boolean().default(false), freezerNote: z.string().max(300).optional(), dayOfInstructions: z.string().min(1).max(1000), })).max(10).default([]), nutrition: NutritionInputRef.optional().describe("Manually-entered nutrition — sets nutritionManual and disables the AI-estimate action on the recipe page."), })); const UpdateRecipeRef = registry.register("UpdateRecipe", z.object({ title: z.string().min(1).max(200).optional(), description: z.string().max(2000).nullable().optional(), baseServings: z.number().int().min(1).max(100).optional(), recipeType: z.enum(["dish", "drink"]).optional(), visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(), prepMins: z.number().int().min(0).max(1440).nullable().optional(), cookMins: z.number().int().min(0).max(1440).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(), language: z.string().max(10).nullable().optional(), sourceUrl: z.string().url().max(2000).nullable().optional(), dietaryTags: DietaryTagsRef.optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.number(), z.string()]).optional(), unit: z.string().max(50).optional(), note: z.string().max(500).optional(), order: z.number().int().default(0), })).max(100).optional(), steps: z.array(z.object({ instruction: z.string().min(1).max(2000), timerSeconds: z.number().int().min(0).max(86400).optional(), order: z.number().int(), appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]), })).max(100).optional(), photos: z.array(z.object({ key: z.string().min(1).max(500), isCover: z.boolean().default(false), sizeMb: z.number().int().min(0).max(50).default(0), })).max(20).optional(), coverIcon: z.string().max(50).nullable().optional(), coverColor: z.string().max(50).nullable().optional(), isBatchCook: z.boolean().optional(), dishes: z.array(z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), fridgeDays: z.number().int().min(1).max(14), freezerFriendly: z.boolean().default(false), freezerNote: z.string().max(300).optional(), dayOfInstructions: z.string().min(1).max(1000), })).max(10).optional(), nutrition: NutritionInputRef.nullable().optional().describe("Set to null to clear manually-entered nutrition and re-enable the AI-estimate action."), })); const ApiErrorRef = registry.register("ApiError", z.object({ error: z.string() })); const CommentRef = registry.register("Comment", z.object({ id: z.string(), userId: z.string(), content: z.string(), parentId: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), userName: z.string().nullable(), userUsername: z.string().nullable(), userAvatarUrl: z.string().nullable(), })); const CollectionRecipeEntryRef = registry.register("CollectionRecipeEntry", z.object({ collectionId: z.string(), recipeId: z.string(), addedAt: z.string().datetime(), recipe: RecipeRef, })); const CollectionRef = registry.register("Collection", z.object({ id: z.string(), userId: z.string(), name: z.string(), description: z.string().nullable(), notes: z.string().nullable(), tags: z.array(z.string()), visibility: z.enum(["private", "unlisted", "public", "followers"]), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), recipes: z.array(CollectionRecipeEntryRef), })); const MealPlanFullEntryRef = registry.register("MealPlanFullEntry", z.object({ id: z.string(), mealPlanId: z.string(), day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]), recipeId: z.string().nullable(), batchDishId: z.string().nullable(), servings: z.number().int(), note: z.string().nullable(), recipe: RecipeRef.nullable(), })); const MealPlanRef = registry.register("MealPlan", z.object({ id: z.string().optional(), userId: z.string().optional(), weekStart: z.string(), createdAt: z.string().datetime().optional(), entries: z.array(MealPlanFullEntryRef), })); const PantryItemRef = registry.register("PantryItem", z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), expiresAt: z.string().datetime().nullable(), })); const ShoppingListRef = registry.register("ShoppingList", z.object({ id: z.string(), userId: z.string().nullable(), name: z.string(), isPublic: z.boolean(), publicEditable: z.boolean(), generatedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(), items: z.array(z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean(), inPantry: z.boolean(), sortOrder: z.number().int() })), })); const ApiKeyRef = registry.register("ApiKey", z.object({ id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), lastUsedAt: z.string().datetime().nullable(), createdAt: z.string().datetime(), })); const CreateApiKeyResponseRef = registry.register("CreateApiKeyResponse", z.object({ id: z.string(), name: z.string(), scope: z.enum(["full", "read"]), key: z.string().describe("Full key — shown once"), createdAt: z.string().datetime(), })); const AiGeneratedRef = registry.register("AiGeneratedRecipe", z.object({ title: z.string(), description: z.string(), baseServings: z.number(), difficulty: z.enum(["easy", "medium", "hard"]), prepMins: z.number().nullable(), cookMins: z.number().nullable(), dietaryTags: DietaryTagsRef, ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().nullable(), unit: z.string().nullable(), note: z.string().nullable() })), steps: z.array(z.object({ instruction: z.string(), timerSeconds: z.number().nullable() })), })); const PaginatedRecipes = z.object({ data: z.array(RecipeSummaryRef), limit: z.number(), offset: z.number(), }); const PaginatedCollections = z.object({ data: z.array(CollectionRef), total: z.number(), limit: z.number(), offset: z.number(), }); 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 your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}", summary: "Delete recipe", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/favorite", summary: "Favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Favorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/recipes/{id}/favorite", summary: "Un-favorite a recipe", security, request: { params: idParam }, responses: { 200: { description: "Unfavorited", content: { "application/json": { schema: z.object({ favorited: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/rate", summary: "Rate/review a recipe (1-5, optional text + photo)", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), photoKey: z.string().max(500).optional(), photoSizeMb: z.number().int().min(0).max(50).default(0) }) } }, required: true } }, responses: { 200: { description: "Rating updated", content: { "application/json": { schema: z.object({ updated: z.literal(true) }) } } }, 201: { description: "Rating created", content: { "application/json": { schema: z.object({ created: z.literal(true) }) } } }, 400: { description: "Validation error or cannot rate your own recipe", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments", summary: "List comments", security, request: { params: idParam, query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Comments", content: { "application/json": { schema: z.object({ data: z.array(CommentRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments", summary: "Post comment", description: "Rate-limited: 20 req/min.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000), parentId: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- 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", "followers"]).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 } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional() }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate or manually-entered values", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }).nullable(), manual: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/regenerate", summary: "Revise a recipe draft per a free-text instruction (recipe editor's \"Regenerate with AI\")", description: "Rate-limited: 10 req/min. Consumes AI quota. Stateless — takes the current draft's fields directly (not a recipeId), so it reflects unsaved editor changes; does not write to the database.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100), difficulty: z.enum(["easy", "medium", "hard"]).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.string(), z.number()]).optional(), unit: z.string().max(50).optional() })).max(100), steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100), instruction: z.string().min(1).max(500), language: z.string().max(10).default("en"), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Revised draft", content: { "application/json": { schema: AiGeneratedRef } } }, 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-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- AI (Phase 3): recipe generation, transforms, chat --- const AiChatMessageRef = registry.register("AiChatMessage", z.object({ 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. Two AI calls internally — a vision model recognizes the photo, then a text model writes the structured recipe — but only counts as one quota unit. Written in the caller's app language, regardless of the language shown in the photo.", 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 } } }, 422: { description: "No recipe recognized in the photo", 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).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/generate-meal", summary: "Generate a themed, coherent set of recipes (one per course) and add them to a collection", description: "Rate-limited: 3 req/min. Consumes AI quota (one call covers the whole meal). Charges your tier's recipe limit once per generated recipe (refunded if the limit is exceeded). The collection must be owned by the caller.", security, request: { body: { content: { "application/json": { schema: z.object({ collectionId: z.string(), theme: z.string().min(1).max(200), courses: z.array(z.enum(["starter", "main", "side", "dessert", "drink"])).min(2).max(6), servings: z.number().int().min(1).max(20).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) } }, required: true } }, responses: { 200: { description: "Created recipes, already added to the collection", content: { "application/json": { schema: z.object({ collectionId: z.string(), recipes: z.array(z.object({ id: z.string(), title: z.string(), course: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Collection not found", 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 } } } } }); const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({ title: z.string(), description: z.string().optional(), baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), difficulty: z.enum(["easy", "medium", "hard"]).optional(), prepMins: z.number().int().optional(), cookMins: z.number().int().optional(), ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })), steps: z.array(z.object({ instruction: z.string() })), })); const ProposedShoppingListRef = registry.register("ProposedShoppingList", z.object({ items: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })), suggestedListName: z.string().optional(), })); 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. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe or add items to a shopping list, the response may include proposedRecipe / proposedShoppingList — drafts the assistant proposed but did NOT save; the caller must POST them itself (to /api/v1/recipes, or /api/v1/shopping-lists + /api/v1/shopping-lists/{id}/items) to actually create anything.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional(), proposedShoppingList: ProposedShoppingListRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); const AiConversationRef = registry.register("AiConversation", z.object({ id: z.string(), title: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), })); registry.registerPath({ method: "get", path: "/api/v1/ai/conversations", summary: "List your general-assistant conversations, most recently active first", security, responses: { 200: { description: "Conversations", content: { "application/json": { schema: z.object({ data: z.array(AiConversationRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/conversations", summary: "Start a new (untitled) general-assistant conversation", security, responses: { 201: { description: "Created", content: { "application/json": { schema: AiConversationRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/ai/conversations/{id}", summary: "Rename a conversation (null clears the title back to auto-generated)", security, request: { params: z.object({ id: z.string() }), body: { content: { "application/json": { schema: z.object({ title: z.string().max(100).nullable() }) } }, required: true } }, responses: { 200: { description: "Renamed", content: { "application/json": { schema: z.object({ id: z.string(), title: z.string().nullable() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/ai/conversations/{id}", summary: "Delete a conversation and its messages", security, request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: "Deleted", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 404: { description: "Not found", 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 recipeId, conversationId, and scope to search across everything; pass conversationId for one general-assistant conversation, or scope=general for the legacy single general thread.", security, request: { query: z.object({ recipeId: z.string().uuid().optional(), conversationId: 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 } } } } }); const FeedRecipeRef = registry.register("FeedRecipe", z.object({ id: z.string(), title: z.string(), description: z.string().nullable(), baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]), tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(), authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(), photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })), dishCount: z.number().int(), isFavorited: z.boolean(), })); registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); const CreateCollectionRef = registry.register("CreateCollection", z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"), })); registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- Collections: single-resource CRUD, fork, favorite, members --- const CollectionMemberRef = registry.register("CollectionMember", z.object({ 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).nullable().optional(), notes: z.string().max(2000).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(), visibility: z.enum(["private", "unlisted", "public", "followers"]).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 ReorderCollectionRecipesRef = registry.register("ReorderCollectionRecipes", z.object({ recipeIds: z.array(z.string()).min(1).max(500).describe("Full ordered list of recipe ids currently in the collection."), })); 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() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}", summary: "Delete a collection (owner only)", description: "Pass ?deleteRecipes=true to also delete the recipes in it — only ones you own; recipes shared into this collection by others are never touched.", security, request: { params: idParam, query: z.object({ deleteRecipes: z.enum(["true", "false"]).optional() }) }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/collections/{id}/reorder", summary: "Reorder the recipes in a collection (owner only)", description: "Pass the full list of recipe ids in the desired order — ids not currently in the collection are ignored.", security, request: { params: idParam, body: { content: { "application/json": { schema: ReorderCollectionRecipesRef } }, required: true } }, responses: { 200: { description: "Reordered", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error or no matching recipes", content: { "application/json": { schema: ApiErrorRef } } }, 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() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get your own meal plan for a week", security, request: { params: weekStartParam }, responses: { 200: { description: "Meal plan with entries", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}", summary: "Create the meal plan for a week (idempotent)", security, request: { params: weekStartParam }, responses: { 200: { description: "Already existed", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } }, 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } } } }); // --- 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() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/members", summary: "List collaborators on a week's plan (owner only)", security, request: { params: weekStartParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(MealPlanMemberRef) } } } } }); registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 400: { description: "Cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } }, 400: { description: "Invalid weekStart", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}", summary: "View a meal plan you're a collaborator on", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Plan with entries, your role, and the owner's name", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "List entries on a shared plan (lean — polled for live updates)", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Add/replace an entry on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), body: { content: { "application/json": { schema: CreateMealPlanEntryRef.omit({ batchDishId: true }) } }, required: true } }, responses: { 201: { description: "Entry id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden (viewer role)", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found / recipe not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "Delete one or more entries on a shared plan (editor role required)", security, request: { params: z.object({ mealPlanId: z.string() }), query: z.object({ entryId: z.string().optional(), ids: z.string().optional().describe("comma-separated entry ids, for clearing a day/week") }) }, responses: { 204: { description: "Deleted" }, 400: { description: "entryId or ids required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.object({ data: z.array(PantryItemRef), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/pantry", summary: "Add a pantry item", security, request: { body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().optional(), expiresAt: z.string().datetime().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/pantry/{id}", summary: "Update a pantry item", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ rawName: z.string().min(1).max(200).optional(), quantity: z.string().nullable().optional(), unit: z.string().nullable().optional(), expiresAt: z.string().datetime().nullable().optional() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/pantry/{id}", summary: "Delete a pantry item", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/pantry/bulk", summary: "Add several pantry items at once, merging quantities into existing matching items", security, request: { body: { content: { "application/json": { schema: z.object({ items: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.string().optional(), unit: z.string().max(50).optional() })).min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/barcode", summary: "Look up a barcode via Open Food Facts to prefill a pantry item", description: "Rate-limited: 20 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/) }) } }, required: true } }, responses: { 200: { description: "Lookup result", content: { "application/json": { schema: z.object({ found: z.boolean(), rawName: z.string().optional(), quantity: z.string().optional(), unit: z.string().optional() }) } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } }, 502: { description: "Lookup service unavailable", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/pantry/scan/photo", summary: "Identify pantry items from a photo using AI vision", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Detected items", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- Users: profile, export, prefs, nutrition, social --- const usernameParam = z.object({ username: z.string() }); 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(), avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), avatarSizeMb: z.number().int().min(0).max(50).default(0).describe("Size (MB, rounded up) of the file behind avatarKey, from avatar-presign's response. Ignored unless avatarKey is set."), useGravatar: z.boolean().optional(), })); const ModelPrefsRef = registry.register("ModelPrefs", z.object({ id: z.string(), userId: z.string(), 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 avatarKey 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, or avatarKey wasn't issued to you by avatar-presign", 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", description: "Returns all of your lists, unpaginated.", security, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/shopping-lists", summary: "Create shopping list", description: "Optionally seeded from a meal-plan week's merged ingredients (pantry-aware).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), items: z.array(z.object({ rawName: z.string().min(1), quantity: z.string().optional(), unit: z.string().optional(), aisle: z.string().optional() })).optional(), fromMealPlanWeek: z.string().optional() }) } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- Shopping lists: single-resource CRUD, items, members, export --- const ShoppingListItemRef = registry.register("ShoppingListItem", z.object({ id: z.string(), rawName: z.string(), quantity: z.string().nullable(), unit: z.string().nullable(), aisle: z.string().nullable(), checked: z.boolean(), sortOrder: z.number().int(), })); 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({ listName: z.string(), items: z.array(z.object({ name: z.string(), quantity: z.string().nullable(), unit: z.string().nullable() })), })); registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}", summary: "Get a shopping list with items", security, request: { params: idParam }, responses: { 200: { description: "List", content: { "application/json": { schema: ShoppingListRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); 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() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- AI keys: users' own third-party provider keys (BYOK), used instead of app-wide AI credentials --- 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: "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 } } } } }); // --- Support: bug reports, suggestions, and questions submitted in-app --- const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]); const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]); const SupportAttachmentRef = registry.register("SupportAttachment", z.object({ id: z.string(), contentType: z.string(), url: z.string(), })); const SupportTicketRef = registry.register("SupportTicket", z.object({ id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(), status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(), attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), })); const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({ type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000), attachments: z.array(z.object({ key: z.string().describe("Storage key returned by POST /api/v1/support/attachments/presign"), contentType: z.string(), sizeBytes: z.number().int().positive(), })).max(5).default([]), })); const PresignSupportAttachmentRef = registry.register("PresignSupportAttachment", z.object({ contentType: z.string(), fileSize: z.number().int().positive().max(10 * 1024 * 1024), })); const PresignedPostRef = registry.register("PresignedSupportPost", z.object({ url: z.string(), fields: z.record(z.string(), z.string()), key: z.string(), contentType: z.string(), fileSize: z.number().int(), })); registry.registerPath({ method: "get", path: "/api/v1/support", summary: "List your support tickets", security, responses: { 200: { description: "Tickets, newest first", content: { "application/json": { schema: z.array(SupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email. Attach files by presigning each one first via POST /api/v1/support/attachments/presign, uploading to the returned URL, then passing the keys here.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/support/attachments/presign", summary: "Get a presigned upload URL for a support ticket attachment", description: "Rate-limited: 20 req/hour. Accepts images, PDF, plain text, JSON, or zip, up to 10MB. Upload the file as multipart form data to the returned url+fields, then include the returned key when submitting the ticket.", security, request: { body: { content: { "application/json": { schema: PresignSupportAttachmentRef } }, required: true } }, responses: { 200: { description: "Presigned upload", content: { "application/json": { schema: PresignedPostRef } } }, 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 } } } } }); const AdminSupportTicketRef = registry.register("AdminSupportTicket", z.object({ id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(), status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), attachments: z.array(z.object({ id: z.string(), contentType: z.string(), storageKey: z.string(), url: z.string() })), })); const UpdateAdminSupportTicketRef = registry.register("UpdateAdminSupportTicket", z.object({ status: SupportTicketStatusEnum.optional(), retryGitea: z.boolean().optional().describe("Re-attempts opening a Gitea issue for this ticket (e.g. after a prior giteaError)."), })); // --- 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", "followers"]), 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", "followers"]), aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(), authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(), tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]), photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })), dishCount: z.number().int(), isFavorited: z.boolean(), })); 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(), visibility: z.enum(["private", "unlisted", "public", "followers"]), tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]), authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(), avgRating: z.number().nullable(), ratingCount: z.number().int(), photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })), dishCount: z.number().int(), isFavorited: z.boolean(), })); 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(), recipeType: z.enum(["dish", "drink"]).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().describe("POST this URL as multipart/form-data — include every field below, then the file itself last."), fields: z.record(z.string(), z.string()).describe("Form fields to include in the upload POST, including a signed content-length-range condition that the storage server enforces (the declared fileSize can't be lied about)."), key: z.string(), })); registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 upload POST (URL + form fields) for 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 POST", 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 upload POST (URL + form fields) for your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", 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 } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- Comments: edit & delete --- registry.registerPath({ method: "put", path: "/api/v1/comments/{id}", summary: "Edit your own comment", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().min(1).max(5000) }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ updated: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found or not yours", content: { "application/json": { schema: ApiErrorRef } } } } }); 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> = [{ 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", "family"]), 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", "family"]).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(), DEFAULT_TEXT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for recipe generation and other text use cases when a user has no personal model preference and no BYOK key."), DEFAULT_TEXT_MODEL: z.string().nullable().optional(), DEFAULT_CHAT_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for the chatbot (general cooking assistant + per-recipe Q&A)."), DEFAULT_CHAT_MODEL: z.string().nullable().optional(), DEFAULT_VISION_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for photo/vision use cases (pantry scan, photo import)."), DEFAULT_VISION_MODEL: z.string().nullable().optional(), DEFAULT_MEAL_PLAN_PROVIDER: z.enum(["openai", "anthropic", "openrouter", "ollama"]).nullable().optional().describe("Site-wide default for AI meal-plan generation."), DEFAULT_MEAL_PLAN_MODEL: z.string().nullable().optional(), AI_TOOL_CALLING_ENABLED: z.string().nullable().optional().describe("\"false\" disables the chatbot's createRecipe/addToShoppingList tools (e.g. for a model that can't reliably call tools); any other value or unset means enabled."), }).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", "family"]), 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", "family"]).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", "family"]).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(), })); const tierParam = z.object({ tier: z.enum(["free", "pro", "family"]) }); 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: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", 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 } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", 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() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 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 } } } } }); const FeatureFlagMatrixRef = registry.register("FeatureFlagMatrix", z.record( z.string(), z.object({ free: z.boolean(), pro: z.boolean(), family: z.boolean() }) ).describe("Feature key -> per-tier enabled state. A feature/tier pair defaults to enabled=true until explicitly disabled.")); const UpdateFeatureFlagRef = registry.register("UpdateFeatureFlag", z.object({ featureKey: z.enum(["recipe_variations", "drink_pairing", "meal_pairing"]), tier: z.enum(["free", "pro", "family"]), enabled: z.boolean(), })); registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", 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 AI call count for the current month", description: "Admin only. Recipe count and storage are lifetime totals derived from real data, not resettable counters.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } }); const generator = new OpenApiGeneratorV31(registry.definitions); cachedSpec = generator.generateDocument({ openapi: "3.1.0", info: { title: "Epicure API", version: "1.0.0", description: "Auth: session cookie or Bearer API key (`ek_...`)." }, servers: [{ url: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000" }], }); return cachedSpec; }