import fp from 'fastify-plugin'; import { asClass, asValue, Lifetime } from 'awilix'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import { CreateRecipeSchema, UpdateRecipeSchema, RecipeQuerySchema, ScaleRecipeSchema, ImportRecipeTextSchema, ImportRecipeUrlSchema, RecipeResponseSchema, RecipeListResponseSchema, } from '@meshitrack/shared'; import { RecipesRepository } from './recipes.repository.js'; import { RecipesService } from './recipes.service.js'; import { ProductsRepository } from '../products/products.repository.js'; import { NoOpLlmProvider } from '../llm/no-op-llm.provider.js'; import type { NutritionInfo } from '@meshitrack/shared'; type NullableNutritionInfo = { calories: number; protein: number; carbs: number; fat: number; fiber?: number | null; sugar?: number | null; sodium?: number | null; saturatedFat?: number | null; cholesterol?: number | null; }; type AnyIngredient = { productId: string; productName: string; quantity: number; unit: string; originalQuantity?: number | null; originalUnit?: string | null; preparation?: string | null; isOptional: boolean; nutritionContribution: NullableNutritionInfo; }; type AnyRecipeDoc = { _id: string | { toString: () => string }; householdId: string; name: string; description?: string | null; servings: number; prepTime?: number | null; cookTime?: number | null; totalTime?: number | null; ingredients: AnyIngredient[]; steps: Array<{ order: number; instruction: string; duration?: number | null; tip?: string | null; }>; tags: string[]; cuisine?: string | null; imageUrl?: string | null; source?: { type: string; url?: string | null; importedAt?: Date | string | null } | null; totalNutrition: NullableNutritionInfo; perServingNutrition: NullableNutritionInfo; warnings: string[]; isFavorite: boolean; createdBy: string; createdAt: string | Date; updatedAt: string | Date; }; function toStr(v: string | { toString: () => string }): string { return typeof v === 'string' ? v : v.toString(); } function toIso(v: string | Date): string { return typeof v === 'string' ? v : v.toISOString(); } function stripNullNutrition(n: NullableNutritionInfo): NutritionInfo { return { calories: n.calories, protein: n.protein, carbs: n.carbs, fat: n.fat, ...(n.fiber != null ? { fiber: n.fiber } : {}), ...(n.sugar != null ? { sugar: n.sugar } : {}), ...(n.sodium != null ? { sodium: n.sodium } : {}), ...(n.saturatedFat != null ? { saturatedFat: n.saturatedFat } : {}), ...(n.cholesterol != null ? { cholesterol: n.cholesterol } : {}), }; } function toRecipeResponse(doc: AnyRecipeDoc): z.infer { return { _id: toStr(doc._id), householdId: doc.householdId, name: doc.name, ...(doc.description ? { description: doc.description } : {}), servings: doc.servings, ...(doc.prepTime != null ? { prepTime: doc.prepTime } : {}), ...(doc.cookTime != null ? { cookTime: doc.cookTime } : {}), ...(doc.totalTime != null ? { totalTime: doc.totalTime } : {}), ingredients: doc.ingredients.map((ing) => ({ productId: ing.productId, productName: ing.productName, quantity: ing.quantity, unit: ing.unit as 'g' | 'ml' | 'piece' | 'slice', ...(ing.originalQuantity != null ? { originalQuantity: ing.originalQuantity } : {}), ...(ing.originalUnit ? { originalUnit: ing.originalUnit as never } : {}), ...(ing.preparation ? { preparation: ing.preparation } : {}), isOptional: ing.isOptional, nutritionContribution: stripNullNutrition(ing.nutritionContribution), })), steps: doc.steps.map((s) => ({ order: s.order, instruction: s.instruction, ...(s.duration != null ? { duration: s.duration } : {}), ...(s.tip ? { tip: s.tip } : {}), })), tags: doc.tags, ...(doc.cuisine ? { cuisine: doc.cuisine } : {}), ...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}), ...(doc.source ? { source: { type: doc.source.type as 'manual' | 'url' | 'llm_import' | 'text_import', ...(doc.source.url ? { url: doc.source.url } : {}), ...(doc.source.importedAt ? { importedAt: typeof doc.source.importedAt === 'string' ? doc.source.importedAt : (doc.source.importedAt as Date).toISOString(), } : {}), }, } : {}), totalNutrition: stripNullNutrition(doc.totalNutrition), perServingNutrition: stripNullNutrition(doc.perServingNutrition), warnings: doc.warnings as never, isFavorite: doc.isFavorite, createdBy: doc.createdBy, createdAt: toIso(doc.createdAt), updatedAt: toIso(doc.updatedAt), }; } declare module '@fastify/awilix' { interface Cradle { recipesRepository: RecipesRepository; productsRepository: ProductsRepository; recipesService: RecipesService; } } export default fp( async (fastify) => { fastify.diContainer.register({ recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }), productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), recipesService: asClass(RecipesService, { lifetime: Lifetime.SINGLETON }), llmProvider: asValue(new NoOpLlmProvider()), }); const app = fastify.withTypeProvider(); const householdParams = z.object({ householdId: z.string() }); // GET /api/v1/households/:householdId/recipes — list/search app.route({ method: 'GET', url: '/api/v1/households/:householdId/recipes', schema: { params: householdParams, querystring: RecipeQuerySchema, response: { 200: RecipeListResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const result = await service.list(request.params.householdId, request.query); return reply.send({ data: result.data.map(toRecipeResponse), pagination: result.pagination, }); }, }); // GET /api/v1/households/:householdId/recipes/:id — get by id app.route({ method: 'GET', url: '/api/v1/households/:householdId/recipes/:id', schema: { params: householdParams.extend({ id: z.string() }), response: { 200: RecipeResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const recipe = await service.getById(request.params.id, request.params.householdId); return reply.send(toRecipeResponse(recipe)); }, }); // POST /api/v1/households/:householdId/recipes — create app.route({ method: 'POST', url: '/api/v1/households/:householdId/recipes', schema: { params: householdParams, body: CreateRecipeSchema, response: { 201: RecipeResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const recipe = await service.create( request.body, request.params.householdId, request.user.keycloakId, ); return reply.status(201).send(toRecipeResponse(recipe)); }, }); // PATCH /api/v1/households/:householdId/recipes/:id — update app.route({ method: 'PATCH', url: '/api/v1/households/:householdId/recipes/:id', schema: { params: householdParams.extend({ id: z.string() }), body: UpdateRecipeSchema, response: { 200: RecipeResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const recipe = await service.update( request.params.id, request.params.householdId, request.body, ); return reply.send(toRecipeResponse(recipe)); }, }); // DELETE /api/v1/households/:householdId/recipes/:id — soft delete app.route({ method: 'DELETE', url: '/api/v1/households/:householdId/recipes/:id', schema: { params: householdParams.extend({ id: z.string() }), response: { 204: z.undefined() }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); await service.delete(request.params.id, request.params.householdId); return reply.status(204).send(); }, }); // POST /api/v1/households/:householdId/recipes/:id/scale — scale preview app.route({ method: 'POST', url: '/api/v1/households/:householdId/recipes/:id/scale', schema: { params: householdParams.extend({ id: z.string() }), body: ScaleRecipeSchema, response: { 200: RecipeResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const scaled = await service.scale( request.params.id, request.params.householdId, request.body, ); return reply.send(toRecipeResponse(scaled as AnyRecipeDoc)); }, }); // POST /api/v1/households/:householdId/recipes/import-text — LLM import from text app.route({ method: 'POST', url: '/api/v1/households/:householdId/recipes/import-text', schema: { params: householdParams, body: ImportRecipeTextSchema, response: { 200: z.union([ z.object({ available: z.literal(false) }), z.object({ available: z.literal(true), draft: z.unknown() }), ]), }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const result = await service.importFromText(request.body.text, request.params.householdId); return reply.send(result); }, }); // POST /api/v1/households/:householdId/recipes/import-url — LLM import from URL app.route({ method: 'POST', url: '/api/v1/households/:householdId/recipes/import-url', schema: { params: householdParams, body: ImportRecipeUrlSchema, response: { 200: z.union([ z.object({ available: z.literal(false) }), z.object({ available: z.literal(true), draft: z.unknown() }), ]), }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const result = await service.importFromUrl(request.body.url, request.params.householdId); return reply.send(result); }, }); // GET /api/v1/households/:householdId/recipes/by-product/:productId app.route({ method: 'GET', url: '/api/v1/households/:householdId/recipes/by-product/:productId', schema: { params: householdParams.extend({ productId: z.string() }), querystring: z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }), response: { 200: RecipeListResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('recipesService'); const result = await service.findByProduct( request.params.householdId, request.params.productId, request.query, ); return reply.send({ data: result.data.map(toRecipeResponse), pagination: result.pagination, }); }, }); }, { name: 'recipes-routes', dependencies: ['auth-plugin'], }, );