import fp from 'fastify-plugin'; import { asClass, Lifetime } from 'awilix'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import { CreatePantryItemSchema, UpdatePantryItemSchema, TransitionPantryItemSchema, BatchTransitionSchema, PantryQuerySchema, ExpiringQuerySchema, WasteStatsQuerySchema, PantryItemResponseSchema, PantryItemListResponseSchema, WasteStatsResponseSchema, BatchTransitionResponseSchema, type ItemStatus, type FreshnessUrgency, type FreshnessSource, } from '@meshitrack/shared'; import type { StorageLocation } from '@meshitrack/shared'; import { PantryRepository } from './pantry.repository.js'; import { PantryService } from './pantry.service.js'; import { ProductsRepository } from '../products/products.repository.js'; import { FreshnessRulesRepository } from '../freshness-rules/freshness-rules.repository.js'; const HouseholdParams = z.object({ householdId: z.string() }); const ItemParams = z.object({ householdId: z.string(), id: z.string() }); type AnyPantryDoc = { _id: string | { toString(): string }; householdId: string; productId: string; productName: string; storageLocation: string; quantity: number; unit: string; purchaseDate: string | Date; expirationDate?: string | Date | null; openedDate?: string | Date | null; preparedDate?: string | Date | null; status: string; freshnessEstimate: { estimatedExpiryDate: string | Date; daysRemaining: number; urgency: string; source: string; }; notes?: string | null; purchasePrice?: number | null; storeId?: string | null; 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 toPantryResponse(doc: AnyPantryDoc): z.infer { return { _id: toStr(doc._id), householdId: doc.householdId, productId: doc.productId, productName: doc.productName, storageLocation: doc.storageLocation as StorageLocation, quantity: doc.quantity, unit: doc.unit, purchaseDate: toIso(doc.purchaseDate), ...(doc.expirationDate ? { expirationDate: toIso(doc.expirationDate) } : {}), ...(doc.openedDate ? { openedDate: toIso(doc.openedDate) } : {}), ...(doc.preparedDate ? { preparedDate: toIso(doc.preparedDate) } : {}), status: doc.status as ItemStatus, freshnessEstimate: { estimatedExpiryDate: toIso(doc.freshnessEstimate.estimatedExpiryDate), daysRemaining: doc.freshnessEstimate.daysRemaining, urgency: doc.freshnessEstimate.urgency as FreshnessUrgency, source: doc.freshnessEstimate.source as FreshnessSource, }, ...(doc.notes ? { notes: doc.notes } : {}), ...(doc.purchasePrice != null ? { purchasePrice: doc.purchasePrice } : {}), ...(doc.storeId ? { storeId: doc.storeId } : {}), createdBy: doc.createdBy, createdAt: toIso(doc.createdAt), updatedAt: toIso(doc.updatedAt), }; } declare module '@fastify/awilix' { interface Cradle { pantryRepository: PantryRepository; pantryService: PantryService; } } export default fp( async (fastify) => { // Only register if not already registered (products repo may be registered by recipes) if (!fastify.diContainer.hasRegistration('productsRepository')) { fastify.diContainer.register({ productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), }); } if (!fastify.diContainer.hasRegistration('freshnessRulesRepository')) { fastify.diContainer.register({ freshnessRulesRepository: asClass(FreshnessRulesRepository, { lifetime: Lifetime.SINGLETON, }), }); } fastify.diContainer.register({ pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }), pantryService: asClass(PantryService, { lifetime: Lifetime.SINGLETON }), }); const app = fastify.withTypeProvider(); // GET /pantry - list pantry items app.route({ method: 'GET', url: '/api/v1/households/:householdId/pantry', schema: { params: HouseholdParams, querystring: PantryQuerySchema, response: { 200: PantryItemListResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const result = await service.list(request.params.householdId, request.query); const mapped = { data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)), pagination: result.pagination, }; return reply.send(mapped); }, }); // GET /pantry/expiring-soon app.route({ method: 'GET', url: '/api/v1/households/:householdId/pantry/expiring-soon', schema: { params: HouseholdParams, querystring: ExpiringQuerySchema, response: { 200: PantryItemListResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const result = await service.getExpiringSoon(request.params.householdId, request.query); const mapped = { data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)), pagination: result.pagination, }; return reply.send(mapped); }, }); // GET /pantry/stats app.route({ method: 'GET', url: '/api/v1/households/:householdId/pantry/stats', schema: { params: HouseholdParams, querystring: WasteStatsQuerySchema, response: { 200: WasteStatsResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const result = await service.getWasteStats(request.params.householdId, request.query); return reply.send(result); }, }); // GET /pantry/:id app.route({ method: 'GET', url: '/api/v1/households/:householdId/pantry/:id', schema: { params: ItemParams, response: { 200: PantryItemResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const item = await service.getById(request.params.id, request.params.householdId); return reply.send(toPantryResponse(item as unknown as AnyPantryDoc)); }, }); // POST /pantry app.route({ method: 'POST', url: '/api/v1/households/:householdId/pantry', schema: { params: HouseholdParams, body: CreatePantryItemSchema, response: { 201: PantryItemResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const item = await service.create( request.body, request.params.householdId, request.user.keycloakId, ); return reply.status(201).send(toPantryResponse(item as unknown as AnyPantryDoc)); }, }); // PATCH /pantry/:id app.route({ method: 'PATCH', url: '/api/v1/households/:householdId/pantry/:id', schema: { params: ItemParams, body: UpdatePantryItemSchema, response: { 200: PantryItemResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const item = await service.update( request.params.id, request.params.householdId, request.body, ); return reply.send(toPantryResponse(item as unknown as AnyPantryDoc)); }, }); // POST /pantry/:id/transition app.route({ method: 'POST', url: '/api/v1/households/:householdId/pantry/:id/transition', schema: { params: ItemParams, body: TransitionPantryItemSchema, response: { 200: PantryItemResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const item = await service.transition( request.params.id, request.params.householdId, request.body, ); return reply.send(toPantryResponse(item as unknown as AnyPantryDoc)); }, }); // POST /pantry/batch-transition app.route({ method: 'POST', url: '/api/v1/households/:householdId/pantry/batch-transition', schema: { params: HouseholdParams, body: BatchTransitionSchema, response: { 200: BatchTransitionResponseSchema }, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); const result = await service.batchTransition(request.params.householdId, request.body); return reply.send(result); }, }); // DELETE /pantry/:id app.route({ method: 'DELETE', url: '/api/v1/households/:householdId/pantry/:id', schema: { params: ItemParams, }, handler: async (request, reply) => { const service = request.diScope.resolve('pantryService'); await service.delete(request.params.id, request.params.householdId); return reply.status(204).send(); }, }); }, { name: 'pantry-routes' }, );