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, type SetNutritionTargetInput, } from '@meshitrack/shared'; import { NutritionTargetRepository } from './nutrition-target.repository.js'; import { NutritionTargetService, type PresetType } from './nutrition-target.service.js'; import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js'; interface SerializedNutritionTarget { _id: string; userId: string; householdId: string; dailyCalories: number; proteinG: number; carbsG: number; fatG: number; fiberG?: number; sugarG?: number; sodiumMg?: number; isActive: boolean; createdAt: string; updatedAt: string; } function toNutritionTargetResponse(doc: NutritionTargetDocument): SerializedNutritionTarget { const docAny = doc as unknown as { createdAt?: { toISOString?: () => string } | string; updatedAt?: { toISOString?: () => string } | string; fiberG?: number | null; sugarG?: number | null; sodiumMg?: number | null; }; const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => { if (!d) return ''; if (typeof d === 'string') return d; if (typeof d.toISOString === 'function') return d.toISOString(); return String(d); }; const res: SerializedNutritionTarget = { _id: doc._id.toString(), userId: doc.userId, householdId: doc.householdId, dailyCalories: doc.dailyCalories, proteinG: doc.proteinG, carbsG: doc.carbsG, fatG: doc.fatG, isActive: doc.isActive, createdAt: getIsoStr(docAny.createdAt), updatedAt: getIsoStr(docAny.updatedAt), }; if (docAny.fiberG != null) res.fiberG = docAny.fiberG; if (docAny.sugarG != null) res.sugarG = docAny.sugarG; if (docAny.sodiumMg != null) res.sodiumMg = docAny.sodiumMg; return res; } 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 params = request.params as { householdId: string }; const target = await service.getActiveByUser(userId, params.householdId); if (!target) { return reply.status(200).send({ message: 'No active targets defined' }); } return reply.send(toNutritionTargetResponse(target)); }, }); // 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 params = request.params as { householdId: string }; const targets = await service.getAllByUser(userId, params.householdId); return reply.send(targets.map(toNutritionTargetResponse)); }, }); // 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 params = request.params as { householdId: string }; const body = request.body as SetNutritionTargetInput; const target = await service.setTarget(userId, params.householdId, body); return reply.status(201).send(toNutritionTargetResponse(target)); }, }); // 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 body = request.body as { calories: number; strategy: PresetType }; const calculated = service.calculatePreset(body.calories, body.strategy); return reply.send(calculated); }, }); }, { name: 'nutrition-targets-routes', dependencies: ['auth-plugin'], }, );