Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View 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'],
},
);