353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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<typeof RecipeResponseSchema> {
|
|
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<ZodTypeProvider>();
|
|
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'],
|
|
},
|
|
);
|