MeshiTrack/packages/api/src/modules/prices/prices.routes.ts
2026-05-14 18:57:57 +09:00

188 lines
5.9 KiB
TypeScript

import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import {
CreatePriceRecordSchema,
BulkPriceRecordInputSchema,
PriceHistoryQuerySchema,
PriceRecordResponseSchema,
PriceHistoryResponseSchema,
FoodStoreComparisonResponseSchema,
FoodSpendingAnalyticsResponseSchema,
} from '@meshitrack/shared';
import { PricesRepository } from './prices.repository.js';
import { PricesService } from './prices.service.js';
import { ProductsRepository } from '../products/products.repository.js';
import { StoresRepository } from '../stores/stores.repository.js';
type AnyPriceDoc = {
_id: string | { toString: () => string };
householdId: string;
productId: string;
productName: string;
storeId: string;
storeName: string;
price: number;
currency: string;
quantity: number;
unit: string;
pricePerUnit: number;
date: Date | string | { toISOString: () => string };
receiptImageUrl?: string;
notes?: string;
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
};
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
function toPriceRecordResponse(rawDoc: unknown) {
const doc = rawDoc as AnyPriceDoc;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
householdId: doc.householdId,
productId: doc.productId,
productName: doc.productName,
storeId: doc.storeId,
storeName: doc.storeName,
price: doc.price,
currency: doc.currency,
quantity: doc.quantity,
unit: doc.unit,
pricePerUnit: doc.pricePerUnit,
date: toIso(doc.date),
...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}),
...(doc.notes != null ? { notes: doc.notes } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
};
}
declare module '@fastify/awilix' {
interface Cradle {
pricesRepository: PricesRepository;
pricesService: PricesService;
}
}
export default fp(
async (fastify) => {
// Register DI containers
fastify.diContainer.register({
pricesRepository: asClass(PricesRepository, { lifetime: Lifetime.SINGLETON }),
pricesService: asClass(PricesService, { lifetime: Lifetime.SINGLETON }),
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }),
});
const app = fastify.withTypeProvider<ZodTypeProvider>();
const householdParams = z.object({ householdId: z.string() });
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/prices',
schema: {
params: householdParams,
body: CreatePriceRecordSchema,
response: { 201: PriceRecordResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('pricesService');
const record = await service.recordPrice(
request.body,
request.params.householdId,
request.user.keycloakId
);
return reply.status(201).send(toPriceRecordResponse(record));
},
});
app.route({
method: 'POST',
url: '/api/v1/households/:householdId/prices/bulk',
schema: {
params: householdParams,
body: BulkPriceRecordInputSchema,
response: { 201: z.array(PriceRecordResponseSchema) },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('pricesService');
const records = await service.recordBulkPrices(
request.body,
request.params.householdId,
request.user.keycloakId
);
return reply.status(201).send(records.map(toPriceRecordResponse));
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/prices/history/:productId',
schema: {
params: householdParams.extend({ productId: z.string() }),
querystring: PriceHistoryQuerySchema,
response: { 200: PriceHistoryResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('pricesService');
const result = await service.getPriceHistory(
request.params.productId,
request.params.householdId,
request.query
);
return reply.send({
data: result.data.map(toPriceRecordResponse),
pagination: result.pagination,
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/prices/compare/:productId',
schema: {
params: householdParams.extend({ productId: z.string() }),
response: { 200: FoodStoreComparisonResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('pricesService');
const results = await service.compareStores(
request.params.productId,
request.params.householdId
);
return reply.send({
data: results.map((r) => ({
...r,
date: toIso(r.date),
})),
});
},
});
app.route({
method: 'GET',
url: '/api/v1/households/:householdId/prices/analytics',
schema: {
params: householdParams,
response: { 200: FoodSpendingAnalyticsResponseSchema },
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('pricesService');
const analytics = await service.getAnalytics(request.params.householdId);
return reply.send({
...analytics,
priceAlerts: analytics.priceAlerts.map((a) => ({ ...a, date: toIso(a.date) })),
});
},
});
},
{
name: 'prices-routes',
dependencies: ['auth-plugin'],
}
);