Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
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'],
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue