188 lines
5.9 KiB
TypeScript
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'],
|
|
}
|
|
);
|