import fp from 'fastify-plugin'; import { asClass, Lifetime } from 'awilix'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import { NutritionTargetSchema, NutritionTargetResponseSchema } from '@meshitrack/shared'; import { NutritionTargetRepository } from './nutrition-target.repository.js'; import { NutritionTargetService } from './nutrition-target.service.js'; type AnyTargetDoc = { _id: string | { toString: () => string }; userId: string; householdId: string; dailyCalories: number; proteinG: number; carbsG: number; fatG: number; fiberG?: number | null; sugarG?: number | null; sodiumMg?: number | null; isActive: boolean; 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 toNutritionTargetResponse( doc: AnyTargetDoc, ): z.infer { return { _id: toStr(doc._id), userId: doc.userId, householdId: doc.householdId, dailyCalories: doc.dailyCalories, proteinG: doc.proteinG, carbsG: doc.carbsG, fatG: doc.fatG, ...(doc.fiberG != null ? { fiberG: doc.fiberG } : {}), ...(doc.sugarG != null ? { sugarG: doc.sugarG } : {}), ...(doc.sodiumMg != null ? { sodiumMg: doc.sodiumMg } : {}), isActive: doc.isActive, createdAt: toIso(doc.createdAt), updatedAt: toIso(doc.updatedAt), }; } declare module '@fastify/awilix' { interface Cradle { nutritionTargetRepository: NutritionTargetRepository; nutritionTargetService: NutritionTargetService; } } export default fp( async (fastify) => { fastify.diContainer.register({ nutritionTargetRepository: asClass(NutritionTargetRepository, { lifetime: Lifetime.SINGLETON, }), nutritionTargetService: asClass(NutritionTargetService, { lifetime: Lifetime.SINGLETON }), }); const app = fastify.withTypeProvider(); const paramsSchema = z.object({ householdId: z.string() }); // GET /api/v1/households/:householdId/nutrition-targets — get current active targets app.route({ method: 'GET', url: '/api/v1/households/:householdId/nutrition-targets', schema: { params: paramsSchema, response: { 200: z.union([ NutritionTargetResponseSchema, z.object({ message: z.literal('No active targets defined') }), ]), }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('nutritionTargetService'); const userId = request.user.keycloakId; const target = await service.getActiveByUser(userId, request.params.householdId); if (!target) { return reply.status(200).send({ message: 'No active targets defined' }); } return reply.send(toNutritionTargetResponse(target as AnyTargetDoc)); }, }); // GET /api/v1/households/:householdId/nutrition-targets/history — get all targets historically app.route({ method: 'GET', url: '/api/v1/households/:householdId/nutrition-targets/history', schema: { params: paramsSchema, response: { 200: z.array(NutritionTargetResponseSchema), }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('nutritionTargetService'); const userId = request.user.keycloakId; const targets = await service.getAllByUser(userId, request.params.householdId); return reply.send(targets.map((t) => toNutritionTargetResponse(t as AnyTargetDoc))); }, }); // POST /api/v1/households/:householdId/nutrition-targets — upsert active targets app.route({ method: 'POST', url: '/api/v1/households/:householdId/nutrition-targets', schema: { params: paramsSchema, body: NutritionTargetSchema, response: { 201: NutritionTargetResponseSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('nutritionTargetService'); const userId = request.user.keycloakId; const target = await service.setTarget(userId, request.params.householdId, request.body); return reply.status(201).send(toNutritionTargetResponse(target as AnyTargetDoc)); }, }); // POST /api/v1/households/:householdId/nutrition-targets/presets — generate macros via preset strategy app.route({ method: 'POST', url: '/api/v1/households/:householdId/nutrition-targets/presets', schema: { params: paramsSchema, body: z.object({ calories: z.number().positive(), strategy: z.enum(['maintenance', 'loss', 'gain']), }), response: { 200: NutritionTargetSchema }, }, handler: async (request, reply) => { const service = fastify.diContainer.resolve('nutritionTargetService'); const calculated = service.calculatePreset(request.body.calories, request.body.strategy); return reply.send(calculated); }, }); }, { name: 'nutrition-targets-routes', dependencies: ['auth-plugin'], }, );