Implement stores and refills, improve testing
This commit is contained in:
parent
9f416903ef
commit
5536acd67d
137 changed files with 21218 additions and 221 deletions
|
|
@ -14,7 +14,8 @@
|
|||
"test:cov": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
||||
"seed": "tsx --env-file ../../.env src/scripts/seed.ts"
|
||||
"seed": "tsx --env-file ../../.env src/scripts/seed.ts",
|
||||
"migrate:dosage-units": "tsx --env-file ../../.env src/scripts/migrate-dosage-units.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/awilix": "^8.2.0",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ import cabinetRoutes from './modules/cabinet/cabinet.routes.js';
|
|||
import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js';
|
||||
import regimensRoutes from './modules/regimens/regimens.routes.js';
|
||||
import organizerRoutes from './modules/organizer/organizer.routes.js';
|
||||
import storesRoutes from './modules/stores/stores.routes.js';
|
||||
import medicinePricesRoutes from './modules/medicine-prices/medicine-prices.routes.js';
|
||||
import refillsRoutes from './modules/refills/refills.routes.js';
|
||||
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
||||
|
||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||
const app = Fastify({
|
||||
|
|
@ -109,6 +113,10 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
|||
await app.register(cabinetEventsRoutes);
|
||||
await app.register(regimensRoutes);
|
||||
await app.register(organizerRoutes);
|
||||
await app.register(storesRoutes);
|
||||
await app.register(medicinePricesRoutes);
|
||||
await app.register(refillsRoutes);
|
||||
await app.register(purchasesRoutes);
|
||||
|
||||
// Global error handler
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockAggregate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/medicine-price.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
const findOneChain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFindOne,
|
||||
});
|
||||
const aggregateChain = () => ({ exec: mockAggregate });
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) { this.data = data; }
|
||||
save = mockSave;
|
||||
toObject() { return this.data; }
|
||||
static find = vi.fn(() => chain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static aggregate = vi.fn(() => aggregateChain());
|
||||
}
|
||||
return { MedicinePriceModel: FakeModel };
|
||||
});
|
||||
|
||||
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||
|
||||
describe(MedicinePricesRepository.name, () => {
|
||||
let repo: MedicinePricesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new MedicinePricesRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns price record', async () => {
|
||||
const data = {
|
||||
householdId: 'hh1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineProductBrand: 'Tylenol',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Acetaminophen',
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
price: 10,
|
||||
currency: 'USD',
|
||||
quantity: 100,
|
||||
unit: 'tablet',
|
||||
pricePerUnit: 0.1,
|
||||
date: new Date(),
|
||||
isInsurancePrice: false,
|
||||
createdBy: 'user-1',
|
||||
};
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(data as never);
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByMedicine', () => {
|
||||
it('returns paginated items', async () => {
|
||||
const items = [{ _id: 'pr-1', medicineId: 'med-1' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = [{ _id: 'pr-1' }, { _id: 'pr-2' }, { _id: 'pr-3' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles cursor', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const cursor = Buffer.from('pr-1').toString('base64');
|
||||
const result = await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('applies storeId filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByMedicine('hh1', 'med-1', { storeId: 'st-1', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies startDate-only filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByMedicine('hh1', 'med-1', {
|
||||
startDate: '2026-01-01T00:00:00.000Z',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies endDate-only filter', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByMedicine('hh1', 'med-1', {
|
||||
endDate: '2026-12-31T00:00:00.000Z',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStores', () => {
|
||||
it('returns store comparison results', async () => {
|
||||
const rows = [
|
||||
{
|
||||
_id: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
latestPrice: 10,
|
||||
latestPricePerUnit: 0.1,
|
||||
currency: 'USD',
|
||||
date: new Date(),
|
||||
isInsurancePrice: false,
|
||||
},
|
||||
];
|
||||
mockAggregate.mockResolvedValue(rows);
|
||||
|
||||
const result = await repo.compareStores('hh1', 'med-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].storeId).toBe('st-1');
|
||||
expect(result[0].storeName).toBe('Walgreens');
|
||||
});
|
||||
|
||||
it('returns empty array when no records', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.compareStores('hh1', 'med-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestForMedicine', () => {
|
||||
it('returns latest record', async () => {
|
||||
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
||||
mockFindOne.mockResolvedValue(record);
|
||||
|
||||
const result = await repo.getLatestForMedicine('hh1', 'med-1');
|
||||
|
||||
expect(result).toEqual(record);
|
||||
});
|
||||
|
||||
it('filters by storeId when provided', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.getLatestForMedicine('hh1', 'med-1', 'st-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.getLatestForMedicine('hh1', 'med-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnalytics', () => {
|
||||
it('returns analytics object with all fields', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
||||
|
||||
expect(result).toHaveProperty('spendingOverTime');
|
||||
expect(result).toHaveProperty('topBySpending');
|
||||
expect(result).toHaveProperty('spendingByStore');
|
||||
expect(result).toHaveProperty('priceAlerts');
|
||||
});
|
||||
|
||||
it('uses quarter date format', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.getAnalytics('hh1', { period: 'quarter' });
|
||||
|
||||
expect(result).toHaveProperty('spendingOverTime');
|
||||
});
|
||||
|
||||
it('uses year date format', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.getAnalytics('hh1', { period: 'year' });
|
||||
|
||||
expect(result).toHaveProperty('spendingOverTime');
|
||||
});
|
||||
|
||||
it('handles non-empty analytics results', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
|
||||
.mockResolvedValueOnce([{ medicineId: 'med-1', medicineName: 'Acetaminophen', totalSpent: 50, avgPricePerUnit: 0.1 }])
|
||||
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await repo.getAnalytics('hh1', { period: 'month' });
|
||||
|
||||
expect(result.spendingOverTime).toHaveLength(1);
|
||||
expect(result.topBySpending).toHaveLength(1);
|
||||
expect(result.spendingByStore).toHaveLength(1);
|
||||
expect(result.priceAlerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
|
||||
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
|
||||
|
||||
export interface CreateMedicinePriceData {
|
||||
householdId: string;
|
||||
medicineProductId: string;
|
||||
medicineProductBrand: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
pricePerUnit: number;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export class MedicinePricesRepository {
|
||||
public async create(data: CreateMedicinePriceData) {
|
||||
const record = new MedicinePriceModel(data);
|
||||
const saved = await record.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async findByMedicine(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
query: MedicinePriceHistoryQueryInput,
|
||||
) {
|
||||
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||
|
||||
if (query.storeId) filter['storeId'] = query.storeId;
|
||||
|
||||
if (query.startDate || query.endDate) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.startDate) dateFilter['$gte'] = new Date(query.startDate);
|
||||
if (query.endDate) dateFilter['$lte'] = new Date(query.endDate);
|
||||
filter['date'] = dateFilter;
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $lt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await MedicinePriceModel.find(filter)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async compareStores(householdId: string, medicineId: string) {
|
||||
// Get the most recent price per store for this medicine
|
||||
const results = await MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId, medicineId } },
|
||||
{ $sort: { storeId: 1, date: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$storeId',
|
||||
storeName: { $first: '$storeName' },
|
||||
latestPrice: { $first: '$price' },
|
||||
latestPricePerUnit: { $first: '$pricePerUnit' },
|
||||
currency: { $first: '$currency' },
|
||||
date: { $first: '$date' },
|
||||
isInsurancePrice: { $first: '$isInsurancePrice' },
|
||||
},
|
||||
},
|
||||
{ $sort: { latestPricePerUnit: 1 } },
|
||||
]).exec();
|
||||
|
||||
return results.map((r) => ({
|
||||
storeId: r._id as string,
|
||||
storeName: r.storeName as string,
|
||||
latestPrice: r.latestPrice as number,
|
||||
latestPricePerUnit: r.latestPricePerUnit as number,
|
||||
currency: r.currency as string,
|
||||
date: r.date as Date,
|
||||
isInsurancePrice: r.isInsurancePrice as boolean,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getLatestForMedicine(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||
if (storeId) filter['storeId'] = storeId;
|
||||
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
||||
}
|
||||
|
||||
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||
const dateFormat =
|
||||
query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y';
|
||||
|
||||
const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([
|
||||
MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { format: dateFormat, date: '$date' } },
|
||||
total: { $sum: '$price' },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
{ $project: { _id: 0, period: '$_id', total: 1 } },
|
||||
]).exec(),
|
||||
|
||||
MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$medicineId',
|
||||
medicineName: { $first: '$medicineName' },
|
||||
totalSpent: { $sum: '$price' },
|
||||
avgPricePerUnit: { $avg: '$pricePerUnit' },
|
||||
},
|
||||
},
|
||||
{ $sort: { totalSpent: -1 } },
|
||||
{ $limit: 10 },
|
||||
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
|
||||
]).exec(),
|
||||
|
||||
MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$storeId',
|
||||
storeName: { $first: '$storeName' },
|
||||
totalSpent: { $sum: '$price' },
|
||||
purchaseCount: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{ $sort: { totalSpent: -1 } },
|
||||
{ $project: { _id: 0, storeId: '$_id', storeName: 1, totalSpent: 1, purchaseCount: 1 } },
|
||||
]).exec(),
|
||||
]);
|
||||
|
||||
// Price alerts: medicines where the most recent price is >10% higher than the previous
|
||||
const priceAlerts = await MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{ $sort: { medicineId: 1, storeId: 1, date: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: { medicineId: '$medicineId', storeId: '$storeId' },
|
||||
medicineName: { $first: '$medicineName' },
|
||||
storeName: { $first: '$storeName' },
|
||||
prices: { $push: '$pricePerUnit' },
|
||||
},
|
||||
},
|
||||
{ $match: { 'prices.1': { $exists: true } } },
|
||||
{
|
||||
$addFields: {
|
||||
currentPrice: { $arrayElemAt: ['$prices', 0] },
|
||||
previousPrice: { $arrayElemAt: ['$prices', 1] },
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
changePercent: {
|
||||
$multiply: [
|
||||
{ $divide: [{ $subtract: ['$currentPrice', '$previousPrice'] }, '$previousPrice'] },
|
||||
100,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $match: { changePercent: { $gt: 10 } } },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
medicineId: '$_id.medicineId',
|
||||
medicineName: 1,
|
||||
storeName: 1,
|
||||
previousPrice: 1,
|
||||
currentPrice: 1,
|
||||
changePercent: 1,
|
||||
},
|
||||
},
|
||||
]).exec();
|
||||
|
||||
return {
|
||||
spendingOverTime: spendingOverTime as { period: string; total: number }[],
|
||||
topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[],
|
||||
spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[],
|
||||
priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockRecordPrice,
|
||||
mockGetPriceHistory,
|
||||
mockCompareStores,
|
||||
mockGetAnalytics,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRecordPrice: vi.fn(),
|
||||
mockGetPriceHistory: vi.fn(),
|
||||
mockCompareStores: vi.fn(),
|
||||
mockGetAnalytics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./medicine-prices.repository.js', () => ({
|
||||
MedicinePricesRepository: class {
|
||||
create = vi.fn();
|
||||
findByMedicine = vi.fn();
|
||||
compareStores = vi.fn();
|
||||
getLatestForMedicine = vi.fn();
|
||||
getAnalytics = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./medicine-prices.service.js', () => ({
|
||||
MedicinePricesService: class {
|
||||
recordPrice = mockRecordPrice;
|
||||
getPriceHistory = mockGetPriceHistory;
|
||||
compareStores = mockCompareStores;
|
||||
getAnalytics = mockGetAnalytics;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../medicine-products/medicine-products.repository.js', () => ({
|
||||
MedicineProductsRepository: class {
|
||||
findById = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../stores/stores.repository.js', () => ({
|
||||
StoresRepository: class {
|
||||
findById = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import medicinePricesRoutes from './medicine-prices.routes.js';
|
||||
|
||||
function makeFakePriceRecord(overrides = {}) {
|
||||
return {
|
||||
_id: 'pr-1',
|
||||
householdId: 'hh1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineProductBrand: 'Tylenol',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Acetaminophen',
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
price: 10,
|
||||
currency: 'USD',
|
||||
quantity: 100,
|
||||
unit: 'tablet',
|
||||
pricePerUnit: 0.1,
|
||||
date: '2026-01-15T00:00:00.000Z',
|
||||
isInsurancePrice: false,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('medicine-prices.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(medicinePricesRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/medicine-prices', () => {
|
||||
const validBody = {
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
storeId: 'st-1',
|
||||
price: 10,
|
||||
currency: 'USD',
|
||||
quantity: 100,
|
||||
unit: 'tablet',
|
||||
isInsurancePrice: false,
|
||||
};
|
||||
|
||||
it('records price and returns 201', async () => {
|
||||
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/medicine-prices',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('pr-1');
|
||||
expect(body.pricePerUnit).toBe(0.1);
|
||||
});
|
||||
|
||||
it('passes householdId and userId to service', async () => {
|
||||
mockRecordPrice.mockResolvedValue(makeFakePriceRecord());
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/medicine-prices',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(mockRecordPrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductId: 'mp-1' }),
|
||||
'hh1',
|
||||
'kc-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes notes in response when present', async () => {
|
||||
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ notes: 'insurance price' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/medicine-prices',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().notes).toBe('insurance price');
|
||||
});
|
||||
|
||||
it('returns 400 for missing required fields', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/medicine-prices',
|
||||
headers: authHeaders,
|
||||
payload: { price: 10 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('handles Date objects in response', async () => {
|
||||
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({
|
||||
_id: { toString: () => 'pr-obj' },
|
||||
date: new Date('2026-01-15T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
}));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/medicine-prices',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('pr-obj');
|
||||
expect(body.date).toBe('2026-01-15T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/medicine-prices/history/:medicineId', () => {
|
||||
it('returns paginated price history', async () => {
|
||||
mockGetPriceHistory.mockResolvedValue({
|
||||
data: [makeFakePriceRecord()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/history/med-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query params to service', async () => {
|
||||
mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/history/med-1?storeId=st-1&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetPriceHistory).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'med-1',
|
||||
expect.objectContaining({ storeId: 'st-1', limit: 10 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/medicine-prices/compare/:medicineId', () => {
|
||||
it('returns store comparison', async () => {
|
||||
mockCompareStores.mockResolvedValue([
|
||||
{
|
||||
storeId: 'st-1',
|
||||
storeName: 'Walgreens',
|
||||
latestPrice: 10,
|
||||
latestPricePerUnit: 0.1,
|
||||
currency: 'USD',
|
||||
date: new Date('2026-01-15T00:00:00.000Z'),
|
||||
isInsurancePrice: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/compare/med-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].storeId).toBe('st-1');
|
||||
expect(body.data[0].date).toBe('2026-01-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes householdId and medicineId to service', async () => {
|
||||
mockCompareStores.mockResolvedValue([]);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/compare/med-99',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockCompareStores).toHaveBeenCalledWith('hh1', 'med-99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/medicine-prices/analytics', () => {
|
||||
it('returns analytics', async () => {
|
||||
mockGetAnalytics.mockResolvedValue({
|
||||
spendingOverTime: [{ period: '2026-01', total: 50 }],
|
||||
topBySpending: [],
|
||||
spendingByStore: [],
|
||||
priceAlerts: [],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/analytics',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.spendingOverTime).toHaveLength(1);
|
||||
expect(body.spendingOverTime[0].total).toBe(50);
|
||||
});
|
||||
|
||||
it('passes period query param to service', async () => {
|
||||
mockGetAnalytics.mockResolvedValue({
|
||||
spendingOverTime: [],
|
||||
topBySpending: [],
|
||||
spendingByStore: [],
|
||||
priceAlerts: [],
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-prices/analytics?period=year',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetAnalytics).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ period: 'year' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateMedicinePriceRecordSchema,
|
||||
MedicinePriceHistoryQuerySchema,
|
||||
MedicinePriceAnalyticsQuerySchema,
|
||||
MedicinePriceRecordResponseSchema,
|
||||
MedicinePriceHistoryResponseSchema,
|
||||
StoreComparisonResponseSchema,
|
||||
MedicineSpendingAnalyticsResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||
import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import { StoresRepository } from '../stores/stores.repository.js';
|
||||
|
||||
type AnyPriceDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
medicineProductId: string;
|
||||
medicineProductBrand: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
pricePerUnit: number;
|
||||
date: Date | string | { toISOString: () => string };
|
||||
isInsurancePrice: boolean;
|
||||
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,
|
||||
medicineProductId: doc.medicineProductId,
|
||||
medicineProductBrand: doc.medicineProductBrand,
|
||||
medicineId: doc.medicineId,
|
||||
medicineName: doc.medicineName,
|
||||
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),
|
||||
isInsurancePrice: doc.isInsurancePrice,
|
||||
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
medicinePricesRepository: MedicinePricesRepository;
|
||||
medicinePricesService: MedicinePricesService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
medicinePricesRepository: asClass(MedicinePricesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
medicinePricesService: asClass(MedicinePricesService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/medicine-prices',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateMedicinePriceRecordSchema,
|
||||
response: { 201: MedicinePriceRecordResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const record = await service.recordPrice(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toPriceRecordResponse(record));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/medicine-prices/history/:medicineId',
|
||||
schema: {
|
||||
params: householdParams.extend({ medicineId: z.string() }),
|
||||
querystring: MedicinePriceHistoryQuerySchema,
|
||||
response: { 200: MedicinePriceHistoryResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const result = await service.getPriceHistory(
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toPriceRecordResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/medicine-prices/compare/:medicineId',
|
||||
schema: {
|
||||
params: householdParams.extend({ medicineId: z.string() }),
|
||||
response: { 200: StoreComparisonResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const results = await service.compareStores(
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
);
|
||||
return reply.send({
|
||||
data: results.map((r) => ({
|
||||
...r,
|
||||
date: toIso(r.date),
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/medicine-prices/analytics',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: MedicinePriceAnalyticsQuerySchema,
|
||||
response: { 200: MedicineSpendingAnalyticsResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const analytics = await service.getAnalytics(request.params.householdId, request.query);
|
||||
return reply.send(analytics);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'medicine-prices-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||
|
||||
describe(MedicinePricesService.name, () => {
|
||||
const mockPricesRepo = {
|
||||
create: vi.fn(),
|
||||
findByMedicine: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
getLatestForMedicine: vi.fn(),
|
||||
getAnalytics: vi.fn(),
|
||||
};
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
const mockStoresRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
let service: MedicinePricesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new MedicinePricesService({
|
||||
medicinePricesRepository: mockPricesRepo as never,
|
||||
medicineProductsRepository: mockProductsRepo as never,
|
||||
storesRepository: mockStoresRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordPrice', () => {
|
||||
const validInput = {
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
storeId: 'st-1',
|
||||
price: 10,
|
||||
currency: 'USD',
|
||||
quantity: 100,
|
||||
unit: 'tablet' as never,
|
||||
isInsurancePrice: false,
|
||||
};
|
||||
|
||||
it('creates price record with computed pricePerUnit', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
|
||||
mockPricesRepo.create.mockResolvedValue(record);
|
||||
|
||||
const result = await service.recordPrice(validInput, 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(record);
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses medicineName as brand fallback when brand is not set', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Generic', brand: undefined });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'CVS' });
|
||||
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
||||
|
||||
await service.recordPrice(validInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductBrand: 'Generic' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses provided date when given', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
|
||||
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
|
||||
|
||||
await service.recordPrice({ ...validInput, date: '2026-01-15T00:00:00.000Z' }, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when product not found', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Medicine product not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when store not found', async () => {
|
||||
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
|
||||
mockStoresRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Store not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriceHistory', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockPricesRepo.findByMedicine.mockResolvedValue(result);
|
||||
|
||||
const response = await service.getPriceHistory('hh1', 'med-1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockPricesRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStores', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const comparisons = [{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 10 }];
|
||||
mockPricesRepo.compareStores.mockResolvedValue(comparisons);
|
||||
|
||||
const result = await service.compareStores('hh1', 'med-1');
|
||||
|
||||
expect(result).toEqual(comparisons);
|
||||
expect(mockPricesRepo.compareStores).toHaveBeenCalledWith('hh1', 'med-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatePrice', () => {
|
||||
it('returns pricePerUnit of latest record', async () => {
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.15 });
|
||||
|
||||
const result = await service.estimatePrice('hh1', 'med-1');
|
||||
|
||||
expect(result).toBe(0.15);
|
||||
});
|
||||
|
||||
it('returns null when no records', async () => {
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
|
||||
const result = await service.estimatePrice('hh1', 'med-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('filters by storeId when provided', async () => {
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.2 });
|
||||
|
||||
await service.estimatePrice('hh1', 'med-1', 'st-1');
|
||||
|
||||
expect(mockPricesRepo.getLatestForMedicine).toHaveBeenCalledWith('hh1', 'med-1', 'st-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnalytics', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const analytics = {
|
||||
spendingOverTime: [],
|
||||
topBySpending: [],
|
||||
spendingByStore: [],
|
||||
priceAlerts: [],
|
||||
};
|
||||
mockPricesRepo.getAnalytics.mockResolvedValue(analytics);
|
||||
|
||||
const result = await service.getAnalytics('hh1', { period: 'month' });
|
||||
|
||||
expect(result).toEqual(analytics);
|
||||
expect(mockPricesRepo.getAnalytics).toHaveBeenCalledWith('hh1', { period: 'month' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { StoresRepository } from '../stores/stores.repository.js';
|
||||
import type {
|
||||
CreateMedicinePriceRecordInput,
|
||||
MedicinePriceHistoryQueryInput,
|
||||
MedicinePriceAnalyticsQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
medicinePricesRepository: MedicinePricesRepository;
|
||||
medicineProductsRepository: MedicineProductsRepository;
|
||||
storesRepository: StoresRepository;
|
||||
}
|
||||
|
||||
export class MedicinePricesService {
|
||||
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||
private readonly storesRepository: StoresRepository;
|
||||
|
||||
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
|
||||
this.medicinePricesRepository = medicinePricesRepository;
|
||||
this.medicineProductsRepository = medicineProductsRepository;
|
||||
this.storesRepository = storesRepository;
|
||||
}
|
||||
|
||||
public async recordPrice(
|
||||
data: CreateMedicinePriceRecordInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const product = await this.medicineProductsRepository.findById(
|
||||
data.medicineProductId,
|
||||
householdId,
|
||||
);
|
||||
if (!product) throw new NotFoundError('Medicine product not found');
|
||||
|
||||
const store = await this.storesRepository.findById(data.storeId, householdId);
|
||||
if (!store) throw new NotFoundError('Store not found');
|
||||
|
||||
const pricePerUnit = data.price / data.quantity;
|
||||
const date = data.date ? new Date(data.date) : new Date();
|
||||
|
||||
return this.medicinePricesRepository.create({
|
||||
householdId,
|
||||
medicineProductId: data.medicineProductId,
|
||||
medicineProductBrand: product.brand ?? product.medicineName,
|
||||
medicineId: data.medicineId,
|
||||
medicineName: product.medicineName,
|
||||
storeId: data.storeId,
|
||||
storeName: store.name,
|
||||
price: data.price,
|
||||
currency: data.currency,
|
||||
quantity: data.quantity,
|
||||
unit: data.unit,
|
||||
pricePerUnit,
|
||||
date,
|
||||
isInsurancePrice: data.isInsurancePrice,
|
||||
notes: data.notes,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
public async getPriceHistory(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
query: MedicinePriceHistoryQueryInput,
|
||||
) {
|
||||
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query);
|
||||
}
|
||||
|
||||
public async compareStores(householdId: string, medicineId: string) {
|
||||
return this.medicinePricesRepository.compareStores(householdId, medicineId);
|
||||
}
|
||||
|
||||
public async estimatePrice(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
storeId?: string,
|
||||
): Promise<number | null> {
|
||||
const record = await this.medicinePricesRepository.getLatestForMedicine(
|
||||
householdId,
|
||||
medicineId,
|
||||
storeId,
|
||||
);
|
||||
return record ? (record.pricePerUnit as number) : null;
|
||||
}
|
||||
|
||||
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||
return this.medicinePricesRepository.getAnalytics(householdId, query);
|
||||
}
|
||||
}
|
||||
|
|
@ -177,6 +177,20 @@ describe('medicine-products.routes', () => {
|
|||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().brand).toBe('CVS Health');
|
||||
});
|
||||
|
||||
it('includes concentration fields in response when present', async () => {
|
||||
mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/medicine-products/mp-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().concentration).toBe(5);
|
||||
expect(res.json().concentrationUnit).toBe('mg/ml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,30 @@ describe(MedicinesService.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses current name when name not provided in update', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'med-1',
|
||||
name: 'Aspirin',
|
||||
strength: 500,
|
||||
strengthUnit: StrengthUnit.MG,
|
||||
form: MedicineForm.TABLET,
|
||||
});
|
||||
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||
mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Aspirin', strength: 250 });
|
||||
|
||||
const result = await service.update('med-1', 'hh1', { strength: 250 });
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(mockRepo.findDuplicate).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'Aspirin',
|
||||
250,
|
||||
StrengthUnit.MG,
|
||||
MedicineForm.TABLET,
|
||||
'med-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips dedup check when no identity fields change', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' });
|
||||
mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' });
|
||||
|
|
|
|||
247
packages/api/src/modules/purchases/purchases.repository.test.ts
Normal file
247
packages/api/src/modules/purchases/purchases.repository.test.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockAggregate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/purchase.schema.js', () => {
|
||||
const findChain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||
const aggregateChain = () => ({ exec: mockAggregate });
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) { this.data = data; }
|
||||
save = mockSave;
|
||||
toObject() { return this.data; }
|
||||
static find = vi.fn(() => findChain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
static aggregate = vi.fn(() => aggregateChain());
|
||||
}
|
||||
return { PurchaseModel: FakeModel };
|
||||
});
|
||||
|
||||
import { PurchasesRepository } from './purchases.repository.js';
|
||||
|
||||
const makeItem = (overrides = {}) => ({
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Tylenol',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe(PurchasesRepository.name, () => {
|
||||
let repo: PurchasesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new PurchasesRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns plain object', async () => {
|
||||
const data = { householdId: 'hh1', storeId: 'st-1', storeName: 'CVS', status: 'in_cabinet', items: [makeItem()], purchasedAt: new Date(), createdBy: 'u-1' };
|
||||
mockSave.mockResolvedValue({ toObject: () => data });
|
||||
|
||||
const result = await repo.create(data);
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated items without hasMore', async () => {
|
||||
const items = [{ _id: { toString: () => 'p-1' }, householdId: 'hh1' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('returns hasMore and cursor when results exceed limit', async () => {
|
||||
const items = Array.from({ length: 21 }, (_, i) => ({ _id: { toString: () => `p-${i}` } }));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toHaveLength(20);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by status when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, status: 'ordered' });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'ordered' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by storeId when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storeId: 'st-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies cursor filter when provided', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
const cursor = Buffer.from('p-1').toString('base64');
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||
|
||||
expect(PurchaseModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: { $lt: 'p-1' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns purchase when found', async () => {
|
||||
const purchase = { _id: 'p-1', householdId: 'hh1' };
|
||||
mockFindOne.mockResolvedValue(purchase);
|
||||
|
||||
const result = await repo.findById('p-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('missing', 'hh1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates notes and returns updated doc', async () => {
|
||||
const updated = { _id: 'p-1', notes: 'new note' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('p-1', 'hh1', { notes: 'new note' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('returns null when purchase not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.update('missing', 'hh1', {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('includes items in update set when provided', async () => {
|
||||
const updated = { _id: 'p-1' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
const items = [{ name: 'X', quantity: 1, unit: 'tablet', addedToCabinet: false }];
|
||||
|
||||
await repo.update('p-1', 'hh1', { items } as never);
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ $set: expect.objectContaining({ items }) }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveAll', () => {
|
||||
it('sets status to in_cabinet and all items addedToCabinet', async () => {
|
||||
const updated = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
|
||||
const result = await repo.receiveAll('p-1', 'hh1');
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({ status: 'in_cabinet' }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markItemsAddedToCabinet', () => {
|
||||
it('builds per-index update set and calls findOneAndUpdate', async () => {
|
||||
const updated = { _id: 'p-1', items: [] };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
const { PurchaseModel } = await import('../../schemas/purchase.schema.js');
|
||||
|
||||
const result = await repo.markItemsAddedToCabinet('p-1', 'hh1', [0, 2]);
|
||||
|
||||
expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'p-1', householdId: 'hh1', isDeleted: false },
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({
|
||||
'items.0.addedToCabinet': true,
|
||||
'items.2.addedToCabinet': true,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('sets isDeleted to true', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'p-1', isDeleted: true });
|
||||
|
||||
const result = await repo.softDelete('p-1', 'hh1');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingMedicineStock', () => {
|
||||
it('returns aggregated stock by medicineId', async () => {
|
||||
const rows = [{ medicineId: 'med-1', totalUnits: 60 }];
|
||||
mockAggregate.mockResolvedValue(rows);
|
||||
|
||||
const result = await repo.getPendingMedicineStock('hh1');
|
||||
|
||||
expect(result).toEqual(rows);
|
||||
});
|
||||
|
||||
it('returns empty array when no pending purchases', async () => {
|
||||
mockAggregate.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.getPendingMedicineStock('hh1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
packages/api/src/modules/purchases/purchases.repository.ts
Normal file
142
packages/api/src/modules/purchases/purchases.repository.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { PurchaseModel } from '../../schemas/purchase.schema.js';
|
||||
import type { PurchaseQueryInput, UpdatePurchaseInput } from '@meshitrack/shared';
|
||||
|
||||
export interface CreatePurchaseItemData {
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
foodProductId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
priceRecordId?: string;
|
||||
addedToCabinet?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePurchaseData {
|
||||
householdId: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
status: string;
|
||||
items: CreatePurchaseItemData[];
|
||||
notes?: string;
|
||||
purchasedAt: Date;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export class PurchasesRepository {
|
||||
public async create(data: CreatePurchaseData) {
|
||||
const purchase = new PurchaseModel(data);
|
||||
const saved = await purchase.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async findByHousehold(householdId: string, query: PurchaseQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId, isDeleted: false };
|
||||
|
||||
if (query.status) filter['status'] = query.status;
|
||||
if (query.storeId) filter['storeId'] = query.storeId;
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $lt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await PurchaseModel.find(filter)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const cursor =
|
||||
data.length > 0
|
||||
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
||||
: null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return PurchaseModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (data.notes !== undefined) updateSet['notes'] = data.notes;
|
||||
if (data.items !== undefined) updateSet['items'] = data.items;
|
||||
|
||||
return PurchaseModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async markItemsAddedToCabinet(
|
||||
purchaseId: string,
|
||||
householdId: string,
|
||||
itemIndices: number[],
|
||||
) {
|
||||
// Build update using positional array filters
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
for (const idx of itemIndices) {
|
||||
updateSet[`items.${idx}.addedToCabinet`] = true;
|
||||
}
|
||||
|
||||
return PurchaseModel.findOneAndUpdate(
|
||||
{ _id: purchaseId, householdId, isDeleted: false },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async receiveAll(purchaseId: string, householdId: string) {
|
||||
return PurchaseModel.findOneAndUpdate(
|
||||
{ _id: purchaseId, householdId, isDeleted: false },
|
||||
{
|
||||
$set: {
|
||||
status: 'in_cabinet',
|
||||
receivedAt: new Date(),
|
||||
'items.$[].addedToCabinet': true,
|
||||
},
|
||||
},
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async getPendingMedicineStock(
|
||||
householdId: string,
|
||||
): Promise<{ medicineId: string; totalUnits: number }[]> {
|
||||
const results = await PurchaseModel.aggregate([
|
||||
{ $match: { householdId, status: 'ordered', isDeleted: false } },
|
||||
{ $unwind: '$items' },
|
||||
{
|
||||
$match: {
|
||||
'items.medicineId': { $exists: true, $ne: null },
|
||||
'items.addedToCabinet': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$items.medicineId',
|
||||
totalUnits: { $sum: '$items.quantity' },
|
||||
},
|
||||
},
|
||||
{ $project: { _id: 0, medicineId: '$_id', totalUnits: 1 } },
|
||||
]).exec();
|
||||
|
||||
return results as { medicineId: string; totalUnits: number }[];
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return PurchaseModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false, status: 'ordered' },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
476
packages/api/src/modules/purchases/purchases.routes.test.ts
Normal file
476
packages/api/src/modules/purchases/purchases.routes.test.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockList,
|
||||
mockGetById,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockReceive,
|
||||
mockDelete,
|
||||
} = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockGetById: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockReceive: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./purchases.repository.js', () => ({
|
||||
PurchasesRepository: class {
|
||||
create = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
update = vi.fn();
|
||||
receiveAll = vi.fn();
|
||||
softDelete = vi.fn();
|
||||
getPendingMedicineStock = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./purchases.service.js', () => ({
|
||||
PurchasesService: class {
|
||||
list = mockList;
|
||||
getById = mockGetById;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
receive = mockReceive;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import purchasesRoutes from './purchases.routes.js';
|
||||
|
||||
function makeFakePurchase(overrides = {}) {
|
||||
return {
|
||||
_id: 'p-1',
|
||||
householdId: 'hh1',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
status: 'in_cabinet',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
purchasedAt: '2026-01-15T00:00:00.000Z',
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('purchases.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(purchasesRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/purchases', () => {
|
||||
it('returns paginated purchase list', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakePurchase()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].storeName).toBe('CVS');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query params to service', async () => {
|
||||
mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases?status=ordered&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ status: 'ordered', limit: 10 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes ObjectId _id to string', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakePurchase({ _id: { toString: () => 'p-obj' } })],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data[0]._id).toBe('p-obj');
|
||||
});
|
||||
|
||||
it('converts Date objects to ISO strings', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
purchasedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
createdAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-15T00:00:00.000Z'),
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0];
|
||||
expect(item.purchasedAt).toBe('2026-01-15T00:00:00.000Z');
|
||||
expect(item.createdAt).toBe('2026-01-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('includes optional fields in item response when present', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
notes: 'picked up on the way home',
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0].items[0];
|
||||
expect(item.actualPrice).toBe(9.99);
|
||||
expect(item.currency).toBe('USD');
|
||||
expect(res.json().data[0].notes).toBe('picked up on the way home');
|
||||
});
|
||||
|
||||
it('handles item with ObjectId _id and priceRecordId', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
items: [
|
||||
{
|
||||
_id: { toString: () => 'item-obj' },
|
||||
name: 'Advil',
|
||||
quantity: 10,
|
||||
unit: 'tablet',
|
||||
priceRecordId: 'pr-1',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const item = res.json().data[0].items[0];
|
||||
expect(item._id).toBe('item-obj');
|
||||
expect(item.priceRecordId).toBe('pr-1');
|
||||
});
|
||||
|
||||
it('handles item without _id and includes receivedAt on purchase', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
makeFakePurchase({
|
||||
status: 'in_cabinet',
|
||||
receivedAt: '2026-01-20T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
name: 'Generic',
|
||||
quantity: 5,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const purchase = res.json().data[0];
|
||||
expect(purchase.items[0]._id).toBe('');
|
||||
expect(purchase.receivedAt).toBe('2026-01-20T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('returns single purchase', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/purchases/p-99',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetById).toHaveBeenCalledWith('p-99', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/purchases', () => {
|
||||
const validBody = {
|
||||
storeId: 'st-1',
|
||||
items: [{ name: 'Advil', quantity: 30, unit: 'tablet' }],
|
||||
};
|
||||
|
||||
it('creates purchase and returns 201', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: validBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('passes body, householdId, and userId to service', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { ...validBody, status: 'ordered' },
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storeId: 'st-1', status: 'ordered' }),
|
||||
'hh1',
|
||||
'kc-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 for missing storeId', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { items: [{ name: 'X', quantity: 1, unit: 'tablet' }] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for empty items array', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases',
|
||||
headers: authHeaders,
|
||||
payload: { storeId: 'st-1', items: [] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('updates purchase and returns 200', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakePurchase({ notes: 'updated note' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
payload: { notes: 'updated note' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().notes).toBe('updated note');
|
||||
});
|
||||
|
||||
it('passes id, householdId, and body to service', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
payload: { notes: 'note' },
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'hh1',
|
||||
expect.objectContaining({ notes: 'note' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/purchases/:id/receive', () => {
|
||||
it('returns addedCount and priceRecordsCreated', async () => {
|
||||
mockReceive.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 1 });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ addedCount: 2, priceRecordsCreated: 1 });
|
||||
});
|
||||
|
||||
it('passes id, householdId, and userId to service', async () => {
|
||||
mockReceive.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/purchases/p-1/receive',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockReceive).toHaveBeenCalledWith('p-1', 'hh1', 'kc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/purchases/:id', () => {
|
||||
it('deletes purchase and returns 200', async () => {
|
||||
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('p-1');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockDelete.mockResolvedValue(makeFakePurchase());
|
||||
|
||||
await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/purchases/p-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
});
|
||||
});
|
||||
});
|
||||
214
packages/api/src/modules/purchases/purchases.routes.ts
Normal file
214
packages/api/src/modules/purchases/purchases.routes.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreatePurchaseSchema,
|
||||
UpdatePurchaseSchema,
|
||||
PurchaseQuerySchema,
|
||||
PurchaseResponseSchema,
|
||||
PurchaseListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { PurchasesRepository } from './purchases.repository.js';
|
||||
import { PurchasesService } from './purchases.service.js';
|
||||
|
||||
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
type AnyPurchaseItem = {
|
||||
_id?: string | { toString: () => string };
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
foodProductId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
priceRecordId?: string;
|
||||
addedToCabinet: boolean;
|
||||
};
|
||||
|
||||
type AnyPurchase = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
status: string;
|
||||
items: AnyPurchaseItem[];
|
||||
notes?: string;
|
||||
purchasedAt: Date | string | { toISOString: () => string };
|
||||
receivedAt?: Date | string | { toISOString: () => string };
|
||||
createdBy: string;
|
||||
createdAt: Date | string | { toISOString: () => string };
|
||||
updatedAt: Date | string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toItemResponse(item: AnyPurchaseItem) {
|
||||
return {
|
||||
_id: item._id
|
||||
? typeof item._id === 'string'
|
||||
? item._id
|
||||
: item._id.toString()
|
||||
: '',
|
||||
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
|
||||
...(item.medicineId ? { medicineId: item.medicineId } : {}),
|
||||
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
|
||||
...(item.currency ? { currency: item.currency } : {}),
|
||||
...(item.priceRecordId ? { priceRecordId: item.priceRecordId } : {}),
|
||||
addedToCabinet: item.addedToCabinet,
|
||||
};
|
||||
}
|
||||
|
||||
function toPurchaseResponse(doc: AnyPurchase) {
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
storeId: doc.storeId,
|
||||
storeName: doc.storeName,
|
||||
status: doc.status as 'ordered' | 'in_cabinet',
|
||||
items: doc.items.map(toItemResponse),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
purchasedAt: toIso(doc.purchasedAt),
|
||||
...(doc.receivedAt ? { receivedAt: toIso(doc.receivedAt) } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
purchasesRepository: PurchasesRepository;
|
||||
purchasesService: PurchasesService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
purchasesRepository: asClass(PurchasesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
purchasesService: asClass(PurchasesService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/purchases',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: PurchaseQuerySchema,
|
||||
response: { 200: PurchaseListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toPurchaseResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/purchases/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: PurchaseResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const purchase = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toPurchaseResponse(purchase));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/purchases',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreatePurchaseSchema,
|
||||
response: { 201: PurchaseResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const purchase = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toPurchaseResponse(purchase));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/purchases/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdatePurchaseSchema,
|
||||
response: { 200: PurchaseResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const purchase = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toPurchaseResponse(purchase));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/purchases/:id/receive',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: {
|
||||
200: z.object({
|
||||
addedCount: z.number(),
|
||||
priceRecordsCreated: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const result = await service.receive(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/purchases/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: PurchaseResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('purchasesService');
|
||||
const purchase = await service.delete(request.params.id, request.params.householdId);
|
||||
return reply.send(toPurchaseResponse(purchase));
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'purchases-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
418
packages/api/src/modules/purchases/purchases.service.test.ts
Normal file
418
packages/api/src/modules/purchases/purchases.service.test.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PurchasesService } from './purchases.service.js';
|
||||
|
||||
describe(PurchasesService.name, () => {
|
||||
const mockPurchasesRepo = {
|
||||
create: vi.fn(),
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
receiveAll: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
getPendingMedicineStock: vi.fn(),
|
||||
};
|
||||
const mockCabinetService = {
|
||||
addItem: vi.fn(),
|
||||
};
|
||||
const mockStoresRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
const mockProductsRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
const mockPricesRepo = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
let service: PurchasesService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PurchasesService({
|
||||
purchasesRepository: mockPurchasesRepo as never,
|
||||
cabinetService: mockCabinetService as never,
|
||||
storesRepository: mockStoresRepo as never,
|
||||
medicineProductsRepository: mockProductsRepo as never,
|
||||
medicinePricesRepository: mockPricesRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
const fakeStore = { _id: 'st-1', name: 'CVS' };
|
||||
const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' };
|
||||
|
||||
describe('create', () => {
|
||||
const validInput = {
|
||||
storeId: 'st-1',
|
||||
status: 'in_cabinet' as const,
|
||||
items: [{ name: 'Advil', quantity: 30, unit: 'tablet', medicineProductId: 'mp-1' }],
|
||||
};
|
||||
|
||||
it('throws NotFoundError when store not found', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow('Store not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when medicine product not found', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Medicine product not found: mp-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates purchase with in_cabinet status and adds items to cabinet', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockPurchasesRepo.create.mockResolvedValue(purchase);
|
||||
|
||||
const result = await service.create(validInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'in_cabinet', storeName: 'CVS' }),
|
||||
);
|
||||
expect(result).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('records price when actualPrice is set and status is in_cabinet', async () => {
|
||||
const inputWithPrice = {
|
||||
...validInput,
|
||||
items: [{ ...validInput.items[0], actualPrice: 9.99, currency: 'USD' }],
|
||||
};
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
price: 9.99,
|
||||
medicineName: 'Ibuprofen',
|
||||
storeName: 'CVS',
|
||||
pricePerUnit: expect.closeTo(0.333, 2),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add to cabinet when status is ordered', async () => {
|
||||
const orderedInput = { ...validInput, status: 'ordered' as const };
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1', status: 'ordered' });
|
||||
|
||||
await service.create(orderedInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles item without medicineProductId for in_cabinet', async () => {
|
||||
const noProductInput = {
|
||||
storeId: 'st-1',
|
||||
status: 'in_cabinet' as const,
|
||||
items: [{ name: 'Generic OTC', quantity: 1, unit: 'tablet' }],
|
||||
};
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(noProductInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses purchasedAt from input when provided', async () => {
|
||||
const inputWithDate = { ...validInput, purchasedAt: '2026-01-15T00:00:00.000Z' };
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithDate, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPurchasesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ purchasedAt: new Date('2026-01-15T00:00:00.000Z') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses medicineName as brand fallback when brand is undefined', async () => {
|
||||
mockStoresRepo.findById.mockResolvedValue(fakeStore);
|
||||
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
const inputWithPrice = {
|
||||
...validInput,
|
||||
items: [{ ...validInput.items[0], actualPrice: 5 }],
|
||||
};
|
||||
mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' });
|
||||
|
||||
await service.create(inputWithPrice, 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receive', () => {
|
||||
it('throws NotFoundError when purchase not found', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
|
||||
it('throws BadRequestError when status is not ordered', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet', items: [] });
|
||||
|
||||
await expect(service.receive('p-1', 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Purchase is not in ordered status',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds medicine items to cabinet and calls receiveAll', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet' });
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledOnce();
|
||||
expect(mockPurchasesRepo.receiveAll).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
expect(result.addedCount).toBe(1);
|
||||
expect(result.priceRecordsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('creates price record when actualPrice is set on item', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue(fakeProduct);
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledOnce();
|
||||
expect(result.priceRecordsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('uses medicineName as brand fallback in price record when brand is undefined', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Ibuprofen',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined });
|
||||
mockPricesRepo.create.mockResolvedValue({});
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips price record creation when product not found in receive', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date(),
|
||||
items: [
|
||||
{
|
||||
medicineProductId: 'mp-1',
|
||||
medicineId: 'med-1',
|
||||
name: 'Advil',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockCabinetService.addItem.mockResolvedValue({});
|
||||
mockProductsRepo.findById.mockResolvedValue(null);
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockPricesRepo.create).not.toHaveBeenCalled();
|
||||
expect(result.priceRecordsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('skips items already added to cabinet', async () => {
|
||||
const purchase = {
|
||||
_id: 'p-1',
|
||||
status: 'ordered',
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
purchasedAt: new Date(),
|
||||
items: [
|
||||
{ medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true },
|
||||
],
|
||||
};
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
mockPurchasesRepo.receiveAll.mockResolvedValue({});
|
||||
|
||||
const result = await service.receive('p-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
expect(result.addedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockPurchasesRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.list('hh1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockPurchasesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns purchase when found', async () => {
|
||||
const purchase = { _id: 'p-1', status: 'in_cabinet' };
|
||||
mockPurchasesRepo.findById.mockResolvedValue(purchase);
|
||||
|
||||
expect(await service.getById('p-1', 'hh1')).toEqual(purchase);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns purchase', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||
const updated = { _id: 'p-1', notes: 'updated' };
|
||||
mockPurchasesRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('p-1', 'hh1', { notes: 'updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when purchase does not exist', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' });
|
||||
mockPurchasesRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('p-1', 'hh1', {})).rejects.toThrow('Purchase not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft-deletes and returns purchase', async () => {
|
||||
const deleted = { _id: 'p-1', isDeleted: true };
|
||||
mockPurchasesRepo.softDelete.mockResolvedValue(deleted);
|
||||
|
||||
const result = await service.delete('p-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(deleted);
|
||||
expect(mockPurchasesRepo.softDelete).toHaveBeenCalledWith('p-1', 'hh1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when purchase not found', async () => {
|
||||
mockPurchasesRepo.softDelete.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('missing', 'hh1')).rejects.toThrow(
|
||||
'Purchase not found or cannot be deleted',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingStockByMedicine', () => {
|
||||
it('returns map of medicineId to totalUnits', async () => {
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||
{ medicineId: 'med-1', totalUnits: 60 },
|
||||
{ medicineId: 'med-2', totalUnits: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getPendingStockByMedicine('hh1');
|
||||
|
||||
expect(result.get('med-1')).toBe(60);
|
||||
expect(result.get('med-2')).toBe(30);
|
||||
});
|
||||
|
||||
it('returns empty map when no pending stock', async () => {
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getPendingStockByMedicine('hh1');
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
272
packages/api/src/modules/purchases/purchases.service.ts
Normal file
272
packages/api/src/modules/purchases/purchases.service.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import type { PurchasesRepository } from './purchases.repository.js';
|
||||
import type { StoresRepository } from '../stores/stores.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
|
||||
import type { CabinetService } from '../cabinet/cabinet.service.js';
|
||||
import type {
|
||||
CreatePurchaseInput,
|
||||
UpdatePurchaseInput,
|
||||
PurchaseQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { DosageUnit } from '@meshitrack/shared';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
purchasesRepository: PurchasesRepository;
|
||||
cabinetService: CabinetService;
|
||||
storesRepository: StoresRepository;
|
||||
medicineProductsRepository: MedicineProductsRepository;
|
||||
medicinePricesRepository: MedicinePricesRepository;
|
||||
}
|
||||
|
||||
export class PurchasesService {
|
||||
private readonly purchasesRepository: PurchasesRepository;
|
||||
private readonly cabinetService: CabinetService;
|
||||
private readonly storesRepository: StoresRepository;
|
||||
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||
|
||||
public constructor({
|
||||
purchasesRepository,
|
||||
cabinetService,
|
||||
storesRepository,
|
||||
medicineProductsRepository,
|
||||
medicinePricesRepository,
|
||||
}: Deps) {
|
||||
this.purchasesRepository = purchasesRepository;
|
||||
this.cabinetService = cabinetService;
|
||||
this.storesRepository = storesRepository;
|
||||
this.medicineProductsRepository = medicineProductsRepository;
|
||||
this.medicinePricesRepository = medicinePricesRepository;
|
||||
}
|
||||
|
||||
public async create(data: CreatePurchaseInput, householdId: string, userId: string) {
|
||||
const store = await this.storesRepository.findById(data.storeId, householdId);
|
||||
if (!store) throw new NotFoundError('Store not found');
|
||||
|
||||
const purchasedAt = data.purchasedAt ? new Date(data.purchasedAt) : new Date();
|
||||
|
||||
const items: Array<{
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
priceRecordId?: string;
|
||||
addedToCabinet: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const item of data.items) {
|
||||
let resolvedName = item.name;
|
||||
let resolvedMedicineId: string | undefined;
|
||||
|
||||
if (item.medicineProductId) {
|
||||
const product = await this.medicineProductsRepository.findById(
|
||||
item.medicineProductId,
|
||||
householdId,
|
||||
);
|
||||
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
|
||||
if (!resolvedName || resolvedName === item.name) {
|
||||
resolvedName = product.brand ?? resolvedName;
|
||||
}
|
||||
resolvedMedicineId = product.medicineId as string;
|
||||
}
|
||||
|
||||
items.push({
|
||||
medicineProductId: item.medicineProductId,
|
||||
medicineId: resolvedMedicineId,
|
||||
name: resolvedName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
actualPrice: item.actualPrice,
|
||||
currency: item.currency,
|
||||
addedToCabinet: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.status === 'in_cabinet') {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.medicineProductId && item.medicineId) {
|
||||
await this.cabinetService.addItem(
|
||||
{
|
||||
medicineId: item.medicineId,
|
||||
medicineProductId: item.medicineProductId,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as DosageUnit,
|
||||
unitPrice:
|
||||
item.actualPrice !== undefined && item.quantity > 0
|
||||
? item.actualPrice / item.quantity
|
||||
: undefined,
|
||||
totalPrice: item.actualPrice,
|
||||
storeId: data.storeId,
|
||||
purchaseDate: purchasedAt.toISOString(),
|
||||
},
|
||||
householdId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (item.actualPrice !== undefined) {
|
||||
const product = await this.medicineProductsRepository.findById(
|
||||
item.medicineProductId,
|
||||
householdId,
|
||||
);
|
||||
if (product) {
|
||||
await this.medicinePricesRepository.create({
|
||||
householdId,
|
||||
medicineProductId: item.medicineProductId,
|
||||
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
|
||||
medicineId: item.medicineId,
|
||||
/* v8 ignore next */
|
||||
medicineName: (product.medicineName as string) ?? '',
|
||||
storeId: data.storeId,
|
||||
storeName: store.name as string,
|
||||
price: item.actualPrice,
|
||||
currency: item.currency ?? 'USD',
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
/* v8 ignore next */
|
||||
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
||||
date: purchasedAt,
|
||||
isInsurancePrice: false,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items[i] = { ...item, addedToCabinet: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.purchasesRepository.create({
|
||||
householdId,
|
||||
storeId: data.storeId,
|
||||
storeName: store.name as string,
|
||||
status: data.status,
|
||||
items,
|
||||
notes: data.notes,
|
||||
purchasedAt,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
public async receive(id: string, householdId: string, userId: string) {
|
||||
const purchase = await this.purchasesRepository.findById(id, householdId);
|
||||
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||
if (purchase.status !== 'ordered') {
|
||||
throw new BadRequestError('Purchase is not in ordered status');
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let priceRecordsCreated = 0;
|
||||
|
||||
const itemsToUpdate: number[] = [];
|
||||
|
||||
const items = purchase.items as Array<{
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
addedToCabinet: boolean;
|
||||
}>;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.medicineProductId && item.medicineId && !item.addedToCabinet) {
|
||||
await this.cabinetService.addItem(
|
||||
{
|
||||
medicineId: item.medicineId,
|
||||
medicineProductId: item.medicineProductId,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as DosageUnit,
|
||||
unitPrice:
|
||||
item.actualPrice !== undefined && item.quantity > 0
|
||||
? item.actualPrice / item.quantity
|
||||
: undefined,
|
||||
totalPrice: item.actualPrice,
|
||||
storeId: purchase.storeId as string,
|
||||
purchaseDate: (purchase.purchasedAt as Date).toISOString(),
|
||||
},
|
||||
householdId,
|
||||
userId,
|
||||
);
|
||||
addedCount++;
|
||||
|
||||
if (item.actualPrice !== undefined) {
|
||||
const product = await this.medicineProductsRepository.findById(
|
||||
item.medicineProductId,
|
||||
householdId,
|
||||
);
|
||||
if (product) {
|
||||
await this.medicinePricesRepository.create({
|
||||
householdId,
|
||||
medicineProductId: item.medicineProductId,
|
||||
medicineProductBrand: (product.brand as string) ?? (product.medicineName as string),
|
||||
medicineId: item.medicineId,
|
||||
/* v8 ignore next */
|
||||
medicineName: (product.medicineName as string) ?? '',
|
||||
storeId: purchase.storeId as string,
|
||||
storeName: purchase.storeName as string,
|
||||
price: item.actualPrice,
|
||||
currency: item.currency ?? 'USD',
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
/* v8 ignore next */
|
||||
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
|
||||
date: purchase.purchasedAt as Date,
|
||||
isInsurancePrice: false,
|
||||
createdBy: userId,
|
||||
});
|
||||
priceRecordsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
itemsToUpdate.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
await this.purchasesRepository.receiveAll(id, householdId);
|
||||
|
||||
return { addedCount, priceRecordsCreated };
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: PurchaseQueryInput) {
|
||||
return this.purchasesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const purchase = await this.purchasesRepository.findById(id, householdId);
|
||||
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdatePurchaseInput) {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.purchasesRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Purchase not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
const deleted = await this.purchasesRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Purchase not found or cannot be deleted');
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async getPendingStockByMedicine(
|
||||
householdId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||
const map = new Map<string, number>();
|
||||
for (const r of results) {
|
||||
map.set(r.medicineId, r.totalUnits);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
203
packages/api/src/modules/refills/refills.repository.test.ts
Normal file
203
packages/api/src/modules/refills/refills.repository.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/refill-list.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) { this.data = data; }
|
||||
save = mockSave;
|
||||
toObject() { return this.data; }
|
||||
static find = vi.fn(() => chain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
}
|
||||
return { RefillListModel: FakeModel };
|
||||
});
|
||||
|
||||
import { RefillsRepository } from './refills.repository.js';
|
||||
|
||||
describe(RefillsRepository.name, () => {
|
||||
let repo: RefillsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new RefillsRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns refill list', async () => {
|
||||
const data = {
|
||||
householdId: 'hh1',
|
||||
name: 'Monthly Refills',
|
||||
status: 'active',
|
||||
createdBy: 'user-1',
|
||||
items: [],
|
||||
};
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(data);
|
||||
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated lists', async () => {
|
||||
const lists = [{ _id: 'rl-1', name: 'Monthly Refills' }];
|
||||
mockFind.mockResolvedValue(lists);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(lists);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('sets hasMore when more lists exist', async () => {
|
||||
const lists = [{ _id: 'rl-1' }, { _id: 'rl-2' }, { _id: 'rl-3' }];
|
||||
mockFind.mockResolvedValue(lists);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles cursor pagination', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const cursor = Buffer.from('rl-1').toString('base64');
|
||||
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by status', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { status: 'active' as never, limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns list when found', async () => {
|
||||
const list = { _id: 'rl-1', name: 'Monthly Refills' };
|
||||
mockFindOne.mockResolvedValue(list);
|
||||
|
||||
const result = await repo.findById('rl-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(list);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.findById('missing', 'hh1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns list', async () => {
|
||||
const updated = { _id: 'rl-1', name: 'Updated' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('rl-1', 'hh1', { name: 'Updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('updates status field', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', status: 'shopping' });
|
||||
|
||||
const result = await repo.update('rl-1', 'hh1', { status: 'shopping' as never });
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates preferredStoreId field', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', preferredStoreId: 'st-1' });
|
||||
|
||||
const result = await repo.update('rl-1', 'hh1', { preferredStoreId: 'st-1' });
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.update('missing', 'hh1', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('updates item and returns list', async () => {
|
||||
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('updates actualPrice, storeId, and notes', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
await repo.updateItem('rl-1', 'hh1', 'item-1', {
|
||||
actualPrice: 9.99,
|
||||
storeId: 'st-1',
|
||||
notes: 'picked up at CVS',
|
||||
});
|
||||
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes checkedAt when provided', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
const checkedAt = new Date();
|
||||
await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true, checkedAt });
|
||||
|
||||
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markItemsAddedToCabinet', () => {
|
||||
it('marks items and returns list', async () => {
|
||||
const updated = { _id: 'rl-1', items: [] };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.markItemsAddedToCabinet('rl-1', 'hh1', ['item-1', 'item-2']);
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
packages/api/src/modules/refills/refills.repository.ts
Normal file
104
packages/api/src/modules/refills/refills.repository.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { RefillListModel } from '../../schemas/refill-list.schema.js';
|
||||
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
|
||||
|
||||
export interface CreateRefillListData {
|
||||
householdId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
preferredStoreId?: string;
|
||||
totalEstimatedCost?: number;
|
||||
createdBy: string;
|
||||
items: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
estimatedPrice?: number;
|
||||
storeId?: string;
|
||||
notes?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class RefillsRepository {
|
||||
public async create(data: CreateRefillListData) {
|
||||
const list = new RefillListModel(data);
|
||||
const saved = await list.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async findByHousehold(householdId: string, query: RefillListQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.status) filter['status'] = query.status;
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $lt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await RefillListModel.find(filter)
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const cursor =
|
||||
data.length > 0
|
||||
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
|
||||
: null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return RefillListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateSet['name'] = data.name;
|
||||
if (data.status !== undefined) updateSet['status'] = data.status;
|
||||
if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId;
|
||||
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
listId: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
data: UpdateRefillListItemInput & { checkedAt?: Date },
|
||||
) {
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked;
|
||||
if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice;
|
||||
if (data.storeId !== undefined) updateSet['items.$.storeId'] = data.storeId;
|
||||
if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes;
|
||||
if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt;
|
||||
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
{ _id: listId, householdId, 'items._id': itemId },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) {
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
{ _id: listId, householdId },
|
||||
{ $set: { 'items.$[elem].addedToCabinet': true } },
|
||||
{
|
||||
arrayFilters: [{ 'elem._id': { $in: itemIds } }],
|
||||
new: true,
|
||||
lean: true,
|
||||
},
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
506
packages/api/src/modules/refills/refills.routes.test.ts
Normal file
506
packages/api/src/modules/refills/refills.routes.test.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockGetAlerts,
|
||||
mockCreateList,
|
||||
mockList,
|
||||
mockGetById,
|
||||
mockUpdateList,
|
||||
mockUpdateItem,
|
||||
mockAddToCabinet,
|
||||
mockGetStoreComparison,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetAlerts: vi.fn(),
|
||||
mockCreateList: vi.fn(),
|
||||
mockList: vi.fn(),
|
||||
mockGetById: vi.fn(),
|
||||
mockUpdateList: vi.fn(),
|
||||
mockUpdateItem: vi.fn(),
|
||||
mockAddToCabinet: vi.fn(),
|
||||
mockGetStoreComparison: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./refills.repository.js', () => ({
|
||||
RefillsRepository: class {
|
||||
create = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
update = vi.fn();
|
||||
updateItem = vi.fn();
|
||||
markItemsAddedToCabinet = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./refills.service.js', () => ({
|
||||
RefillsService: class {
|
||||
getAlerts = mockGetAlerts;
|
||||
createList = mockCreateList;
|
||||
list = mockList;
|
||||
getById = mockGetById;
|
||||
updateList = mockUpdateList;
|
||||
updateItem = mockUpdateItem;
|
||||
addToCabinet = mockAddToCabinet;
|
||||
getStoreComparison = mockGetStoreComparison;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import refillsRoutes from './refills.routes.js';
|
||||
|
||||
function makeFakeRefillList(overrides = {}) {
|
||||
return {
|
||||
_id: 'rl-1',
|
||||
householdId: 'hh1',
|
||||
name: 'Monthly Refills',
|
||||
items: [],
|
||||
status: 'active',
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('refills.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(refillsRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/refills/alerts', () => {
|
||||
it('returns alerts with price options', async () => {
|
||||
mockGetAlerts.mockResolvedValue([
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
daysUntilEmpty: 3,
|
||||
dailyConsumption: 2,
|
||||
currentStock: 6,
|
||||
suggestedQuantity: 60,
|
||||
lastKnownPrice: { price: 10, pricePerUnit: 0.1, storeName: 'CVS', storeId: 'st-1', date: new Date('2026-01-01T00:00:00.000Z') },
|
||||
cheapestOption: { price: 8, pricePerUnit: 0.08, storeName: 'Walmart', storeId: 'st-2', date: new Date('2026-01-02T00:00:00.000Z') },
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/alerts',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].lastKnownPrice.storeName).toBe('CVS');
|
||||
expect(body.data[0].lastKnownPrice.date).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(body.data[0].cheapestOption.storeName).toBe('Walmart');
|
||||
});
|
||||
|
||||
it('returns alerts', async () => {
|
||||
mockGetAlerts.mockResolvedValue([
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
daysUntilEmpty: 3,
|
||||
dailyConsumption: 2,
|
||||
currentStock: 6,
|
||||
suggestedQuantity: 60,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/alerts',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].medicineName).toBe('Aspirin');
|
||||
});
|
||||
|
||||
it('uses requesting user by default', async () => {
|
||||
mockGetAlerts.mockResolvedValue([]);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/alerts',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'kc-1', 7);
|
||||
});
|
||||
|
||||
it('uses userId query param when provided', async () => {
|
||||
mockGetAlerts.mockResolvedValue([]);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/alerts?userId=other-user&thresholdDays=14',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'other-user', 14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/refills/lists', () => {
|
||||
it('creates list and returns 201', async () => {
|
||||
mockCreateList.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/refills/lists',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Monthly Refills', fromAlerts: false, thresholdDays: 7 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Monthly Refills');
|
||||
});
|
||||
|
||||
it('passes householdId and userId to service', async () => {
|
||||
mockCreateList.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/refills/lists',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
|
||||
});
|
||||
|
||||
expect(mockCreateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Auto List', fromAlerts: true }),
|
||||
'hh1',
|
||||
'kc-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 for missing name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/refills/lists',
|
||||
headers: authHeaders,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/refills/lists', () => {
|
||||
it('returns paginated lists', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakeRefillList()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('includes optional list fields in response', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakeRefillList({
|
||||
preferredStoreId: 'st-1',
|
||||
totalEstimatedCost: 25.5,
|
||||
items: [
|
||||
{
|
||||
_id: 'item-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
estimatedPrice: 10,
|
||||
actualPrice: 9.5,
|
||||
checked: true,
|
||||
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
|
||||
addedToCabinet: false,
|
||||
storeId: 'st-1',
|
||||
notes: 'generic brand',
|
||||
},
|
||||
],
|
||||
})],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].preferredStoreId).toBe('st-1');
|
||||
expect(body.data[0].totalEstimatedCost).toBe(25.5);
|
||||
const item = body.data[0].items[0];
|
||||
expect(item.estimatedPrice).toBe(10);
|
||||
expect(item.actualPrice).toBe(9.5);
|
||||
expect(item.checkedAt).toBe('2026-01-10T00:00:00.000Z');
|
||||
expect(item.storeId).toBe('st-1');
|
||||
expect(item.notes).toBe('generic brand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/refills/lists/:id', () => {
|
||||
it('returns single list', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Monthly Refills');
|
||||
});
|
||||
|
||||
it('handles ObjectId-style _id in list and items', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeRefillList({
|
||||
_id: { toString: () => 'rl-obj' },
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
items: [
|
||||
{
|
||||
_id: { toString: () => 'item-obj' },
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Aspirin',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
checked: false,
|
||||
addedToCabinet: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('rl-obj');
|
||||
expect(body.items[0]._id).toBe('item-obj');
|
||||
expect(body.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-99',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetById).toHaveBeenCalledWith('rl-99', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/refills/lists/:id', () => {
|
||||
it('updates list and returns 200', async () => {
|
||||
mockUpdateList.mockResolvedValue(makeFakeRefillList({ name: 'Updated' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Updated' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('passes id, householdId, body to service', async () => {
|
||||
mockUpdateList.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1',
|
||||
headers: authHeaders,
|
||||
payload: { status: 'shopping' },
|
||||
});
|
||||
|
||||
expect(mockUpdateList).toHaveBeenCalledWith(
|
||||
'rl-1',
|
||||
'hh1',
|
||||
expect.objectContaining({ status: 'shopping' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/refills/lists/:id/items/:itemId', () => {
|
||||
it('updates item and returns 200', async () => {
|
||||
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-1',
|
||||
headers: authHeaders,
|
||||
payload: { checked: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('passes listId, householdId, itemId, body to service', async () => {
|
||||
mockUpdateItem.mockResolvedValue(makeFakeRefillList());
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-99',
|
||||
headers: authHeaders,
|
||||
payload: { actualPrice: 9.99 },
|
||||
});
|
||||
|
||||
expect(mockUpdateItem).toHaveBeenCalledWith(
|
||||
'rl-1',
|
||||
'hh1',
|
||||
'item-99',
|
||||
expect.objectContaining({ actualPrice: 9.99 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', () => {
|
||||
it('adds items to cabinet and returns summary', async () => {
|
||||
mockAddToCabinet.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 0 });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.addedCount).toBe(2);
|
||||
expect(body.priceRecordsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('passes listId, householdId, userId to service', async () => {
|
||||
mockAddToCabinet.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 });
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockAddToCabinet).toHaveBeenCalledWith('rl-1', 'hh1', 'kc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/refills/lists/:id/store-comparison', () => {
|
||||
it('returns store comparison data', async () => {
|
||||
mockGetStoreComparison.mockResolvedValue([
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
storeOptions: [
|
||||
{
|
||||
storeId: 'st-1',
|
||||
storeName: 'CVS',
|
||||
latestPrice: 8,
|
||||
latestPricePerUnit: 0.08,
|
||||
currency: 'USD',
|
||||
date: new Date('2026-01-01T00:00:00.000Z'),
|
||||
isInsurancePrice: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-1/store-comparison',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].storeOptions[0].date).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes listId and householdId to service', async () => {
|
||||
mockGetStoreComparison.mockResolvedValue([]);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/refills/lists/rl-99/store-comparison',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetStoreComparison).toHaveBeenCalledWith('rl-99', 'hh1');
|
||||
});
|
||||
});
|
||||
});
|
||||
279
packages/api/src/modules/refills/refills.routes.ts
Normal file
279
packages/api/src/modules/refills/refills.routes.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateRefillListSchema,
|
||||
UpdateRefillListSchema,
|
||||
UpdateRefillListItemSchema,
|
||||
RefillListQuerySchema,
|
||||
RefillAlertQuerySchema,
|
||||
RefillAlertResponseSchema,
|
||||
RefillListResponseSchema,
|
||||
RefillListListResponseSchema,
|
||||
AddToCabinetResponseSchema,
|
||||
StoreComparisonItemSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { RefillsRepository } from './refills.repository.js';
|
||||
import { RefillsService } from './refills.service.js';
|
||||
|
||||
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
type AnyItem = {
|
||||
_id: string | { toString: () => string };
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
estimatedPrice?: number;
|
||||
actualPrice?: number;
|
||||
checked: boolean;
|
||||
checkedAt?: Date | string;
|
||||
addedToCabinet: boolean;
|
||||
storeId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
type AnyRefillList = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
name: string;
|
||||
items: AnyItem[];
|
||||
status: string;
|
||||
preferredStoreId?: string;
|
||||
totalEstimatedCost?: number;
|
||||
createdBy: string;
|
||||
createdAt: Date | string | { toISOString: () => string };
|
||||
updatedAt: Date | string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toItemResponse(rawItem: unknown) {
|
||||
const item = rawItem as AnyItem;
|
||||
return {
|
||||
_id: typeof item._id === 'string' ? item._id : item._id.toString(),
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}),
|
||||
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
|
||||
checked: item.checked,
|
||||
...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}),
|
||||
addedToCabinet: item.addedToCabinet,
|
||||
...(item.storeId != null ? { storeId: item.storeId } : {}),
|
||||
...(item.notes != null ? { notes: item.notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toListResponse(rawDoc: unknown) {
|
||||
const doc = rawDoc as AnyRefillList;
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
items: doc.items.map(toItemResponse),
|
||||
status: doc.status as never,
|
||||
...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}),
|
||||
...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
refillsRepository: RefillsRepository;
|
||||
refillsService: RefillsService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
refillsRepository: asClass(RefillsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
refillsService: asClass(RefillsService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/refills/alerts',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: RefillAlertQuerySchema,
|
||||
response: { 200: z.object({ data: z.array(RefillAlertResponseSchema) }) },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const alerts = await service.getAlerts(
|
||||
request.params.householdId,
|
||||
request.query.userId ?? request.user.keycloakId,
|
||||
request.query.thresholdDays,
|
||||
);
|
||||
return reply.send({
|
||||
data: alerts.map((a) => ({
|
||||
...a,
|
||||
lastKnownPrice: a.lastKnownPrice
|
||||
? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) }
|
||||
: undefined,
|
||||
cheapestOption: a.cheapestOption
|
||||
? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) }
|
||||
: undefined,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/refills/lists',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateRefillListSchema,
|
||||
response: { 201: RefillListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.createList(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/refills/lists',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: RefillListQuerySchema,
|
||||
response: { 200: RefillListListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toListResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/refills/lists/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: RefillListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/refills/lists/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateRefillListSchema,
|
||||
response: { 200: RefillListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.updateList(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/refills/lists/:id/items/:itemId',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string(), itemId: z.string() }),
|
||||
body: UpdateRefillListItemSchema,
|
||||
response: { 200: RefillListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.updateItem(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.params.itemId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/refills/lists/:id/add-to-cabinet',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: AddToCabinetResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const result = await service.addToCabinet(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/refills/lists/:id/store-comparison',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: {
|
||||
200: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
medicineId: z.string(),
|
||||
storeOptions: z.array(StoreComparisonItemSchema),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const comparisons = await service.getStoreComparison(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
);
|
||||
return reply.send({
|
||||
data: comparisons.map((c) => ({
|
||||
medicineId: c.medicineId,
|
||||
storeOptions: c.storeOptions.map((opt) => ({
|
||||
...opt,
|
||||
date: toIso(opt.date),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'refills-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
400
packages/api/src/modules/refills/refills.service.test.ts
Normal file
400
packages/api/src/modules/refills/refills.service.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RefillsService } from './refills.service.js';
|
||||
|
||||
describe(RefillsService.name, () => {
|
||||
const mockRepo = {
|
||||
create: vi.fn(),
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
markItemsAddedToCabinet: vi.fn(),
|
||||
};
|
||||
const mockRegimensService = {
|
||||
calculateBurnRates: vi.fn(),
|
||||
};
|
||||
const mockCabinetRepo = {
|
||||
getAggregateSummary: vi.fn(),
|
||||
};
|
||||
const mockCabinetService = {
|
||||
addItem: vi.fn(),
|
||||
};
|
||||
const mockPricesRepo = {
|
||||
getLatestForMedicine: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
};
|
||||
const mockPurchasesRepo = {
|
||||
getPendingMedicineStock: vi.fn(),
|
||||
};
|
||||
|
||||
let service: RefillsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||
service = new RefillsService({
|
||||
refillsRepository: mockRepo as never,
|
||||
regimensService: mockRegimensService as never,
|
||||
cabinetRepository: mockCabinetRepo as never,
|
||||
cabinetService: mockCabinetService as never,
|
||||
medicinePricesRepository: mockPricesRepo as never,
|
||||
purchasesRepository: mockPurchasesRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlerts', () => {
|
||||
it('returns empty array when no medicines are running low', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 1, totalInCabinet: 100, daysUntilEmpty: 100 },
|
||||
]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns alerts for medicines below threshold', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 },
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
|
||||
]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].medicineId).toBe('med-1');
|
||||
expect(result[0].daysUntilEmpty).toBe(5);
|
||||
expect(result[0].suggestedQuantity).toBe(60); // ceil(2 * 30)
|
||||
});
|
||||
|
||||
it('attaches lastKnownPrice when available', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
|
||||
price: 10,
|
||||
pricePerUnit: 0.1,
|
||||
storeName: 'Walgreens',
|
||||
storeId: 'st-1',
|
||||
date: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result[0].lastKnownPrice).toBeDefined();
|
||||
expect(result[0].lastKnownPrice?.storeName).toBe('Walgreens');
|
||||
});
|
||||
|
||||
it('attaches cheapestOption from compareStores', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([
|
||||
{ storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result[0].cheapestOption).toBeDefined();
|
||||
expect(result[0].cheapestOption?.storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 },
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||
{ medicineId: 'med-1', totalUnits: 60 },
|
||||
]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result[0].pendingOrderStock).toBe(60);
|
||||
expect(result[0].daysUntilEmptyWithOrders).toBe(32); // (4 + 60) / 2
|
||||
});
|
||||
|
||||
it('excludes medicines with null daysUntilEmpty', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null },
|
||||
]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createList', () => {
|
||||
it('creates list with provided items', async () => {
|
||||
const list = { _id: 'rl-1', name: 'My List', items: [], status: 'active' };
|
||||
mockRepo.create.mockResolvedValue(list);
|
||||
|
||||
const result = await service.createList(
|
||||
{
|
||||
name: 'My List',
|
||||
fromAlerts: false,
|
||||
thresholdDays: 7,
|
||||
items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }],
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(list);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'My List', householdId: 'hh1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates list with no items when neither fromAlerts nor items provided', async () => {
|
||||
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
await service.createList({ name: 'Empty List', fromAlerts: false, thresholdDays: 7 }, 'hh1', 'user-1');
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes totalEstimatedCost from items with estimatedPrice', async () => {
|
||||
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
await service.createList(
|
||||
{
|
||||
name: 'Priced List',
|
||||
fromAlerts: false,
|
||||
thresholdDays: 7,
|
||||
items: [
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never, estimatedPrice: 10 },
|
||||
{ medicineId: 'med-2', medicineName: 'Ibuprofen', quantity: 20, unit: 'tablet' as never, estimatedPrice: 8 },
|
||||
],
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ totalEstimatedCost: 18 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates list from alerts when fromAlerts is true', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
|
||||
]);
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
await service.createList({ name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, 'hh1', 'user-1');
|
||||
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ medicineId: 'med-1' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.list('hh1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns list when found', async () => {
|
||||
const list = { _id: 'rl-1', name: 'My List' };
|
||||
mockRepo.findById.mockResolvedValue(list);
|
||||
|
||||
expect(await service.getById('rl-1', 'hh1')).toEqual(list);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Refill list not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateList', () => {
|
||||
it('updates and returns list', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||
const updated = { _id: 'rl-1', name: 'Updated' };
|
||||
mockRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.updateList('rl-1', 'hh1', { name: 'Updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when list not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateList('rl-1', 'hh1', {})).rejects.toThrow('Refill list not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('updates item and returns list', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||
const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] };
|
||||
mockRepo.updateItem.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('sets checkedAt when checked is true', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||
mockRepo.updateItem.mockResolvedValue({ _id: 'rl-1', items: [] });
|
||||
|
||||
await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true });
|
||||
|
||||
expect(mockRepo.updateItem).toHaveBeenCalledWith(
|
||||
'rl-1',
|
||||
'hh1',
|
||||
'item-1',
|
||||
expect.objectContaining({ checkedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when list not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow('Refill list not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when item not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'rl-1' });
|
||||
mockRepo.updateItem.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateItem('rl-1', 'hh1', 'bad-item', {})).rejects.toThrow(
|
||||
'Refill list or item not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToCabinet', () => {
|
||||
it('adds checked items to cabinet', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [
|
||||
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false },
|
||||
],
|
||||
});
|
||||
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
|
||||
|
||||
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result.addedCount).toBe(1);
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('computes unitPrice when actualPrice is set', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [
|
||||
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 9, checked: true, addedToCabinet: false },
|
||||
],
|
||||
});
|
||||
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' });
|
||||
|
||||
await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetService.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ unitPrice: 0.3, totalPrice: 9 }),
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns zero count when no checked items', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [
|
||||
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result.addedCount).toBe(0);
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips already-added items', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [
|
||||
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.addToCabinet('rl-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result.addedCount).toBe(0);
|
||||
expect(mockCabinetService.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoreComparison', () => {
|
||||
it('returns store comparisons for list items', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [{ medicineId: 'med-1' }, { medicineId: 'med-2' }],
|
||||
});
|
||||
mockPricesRepo.compareStores
|
||||
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'CVS' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getStoreComparison('rl-1', 'hh1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].medicineId).toBe('med-1');
|
||||
});
|
||||
|
||||
it('deduplicates medicine ids', async () => {
|
||||
mockRepo.findById.mockResolvedValue({
|
||||
_id: 'rl-1',
|
||||
items: [{ medicineId: 'med-1' }, { medicineId: 'med-1' }],
|
||||
});
|
||||
mockPricesRepo.compareStores.mockResolvedValue([{ storeId: 'st-1', storeName: 'CVS' }]);
|
||||
|
||||
const result = await service.getStoreComparison('rl-1', 'hh1');
|
||||
|
||||
expect(mockPricesRepo.compareStores).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
packages/api/src/modules/refills/refills.service.ts
Normal file
285
packages/api/src/modules/refills/refills.service.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import type { RefillsRepository } from './refills.repository.js';
|
||||
import type { RegimensService } from '../regimens/regimens.service.js';
|
||||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetService } from '../cabinet/cabinet.service.js';
|
||||
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
|
||||
import type { PurchasesRepository } from '../purchases/purchases.repository.js';
|
||||
import type {
|
||||
CreateRefillListInput,
|
||||
UpdateRefillListInput,
|
||||
UpdateRefillListItemInput,
|
||||
RefillListQueryInput,
|
||||
RefillAlertQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { RefillListStatus } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
refillsRepository: RefillsRepository;
|
||||
regimensService: RegimensService;
|
||||
cabinetRepository: CabinetRepository;
|
||||
cabinetService: CabinetService;
|
||||
medicinePricesRepository: MedicinePricesRepository;
|
||||
purchasesRepository: PurchasesRepository;
|
||||
}
|
||||
|
||||
export class RefillsService {
|
||||
private readonly refillsRepository: RefillsRepository;
|
||||
private readonly regimensService: RegimensService;
|
||||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly cabinetService: CabinetService;
|
||||
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||
private readonly purchasesRepository: PurchasesRepository;
|
||||
|
||||
public constructor({
|
||||
refillsRepository,
|
||||
regimensService,
|
||||
cabinetRepository,
|
||||
cabinetService,
|
||||
medicinePricesRepository,
|
||||
purchasesRepository,
|
||||
}: Deps) {
|
||||
this.refillsRepository = refillsRepository;
|
||||
this.regimensService = regimensService;
|
||||
this.cabinetRepository = cabinetRepository;
|
||||
this.cabinetService = cabinetService;
|
||||
this.medicinePricesRepository = medicinePricesRepository;
|
||||
this.purchasesRepository = purchasesRepository;
|
||||
}
|
||||
|
||||
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
|
||||
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
|
||||
|
||||
const triggered = burnRates.filter(
|
||||
(br) => br.daysUntilEmpty !== null && br.daysUntilEmpty <= thresholdDays,
|
||||
);
|
||||
|
||||
if (triggered.length === 0) return [];
|
||||
|
||||
// Get strength data from cabinet aggregate
|
||||
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
|
||||
for (const s of summaries) {
|
||||
summaryMap.set(s._id as string, {
|
||||
medicineStrength: s.medicineStrength as number,
|
||||
medicineStrengthUnit: s.medicineStrengthUnit as string,
|
||||
});
|
||||
}
|
||||
|
||||
// Get pending stock from ordered purchases
|
||||
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||
const pendingStockMap = new Map<string, number>();
|
||||
for (const row of pendingStockRows) {
|
||||
pendingStockMap.set(row.medicineId, row.totalUnits);
|
||||
}
|
||||
|
||||
const alerts = await Promise.all(
|
||||
triggered.map(async (br) => {
|
||||
const summary = summaryMap.get(br.medicineId);
|
||||
const suggestedQuantity = Math.ceil(br.dailyConsumption * 30);
|
||||
const pendingOrderStock = pendingStockMap.get(br.medicineId) ?? 0;
|
||||
const daysUntilEmptyWithOrders =
|
||||
br.dailyConsumption > 0
|
||||
? (br.totalInCabinet + pendingOrderStock) / br.dailyConsumption
|
||||
: null;
|
||||
|
||||
const [latestRecord, comparisons] = await Promise.all([
|
||||
this.medicinePricesRepository.getLatestForMedicine(householdId, br.medicineId),
|
||||
this.medicinePricesRepository.compareStores(householdId, br.medicineId),
|
||||
]);
|
||||
|
||||
return {
|
||||
medicineId: br.medicineId,
|
||||
medicineName: br.medicineName,
|
||||
medicineStrength: summary?.medicineStrength ?? 0,
|
||||
medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never,
|
||||
daysUntilEmpty: br.daysUntilEmpty as number,
|
||||
dailyConsumption: br.dailyConsumption,
|
||||
currentStock: br.totalInCabinet,
|
||||
pendingOrderStock,
|
||||
daysUntilEmptyWithOrders,
|
||||
suggestedQuantity,
|
||||
lastKnownPrice: latestRecord
|
||||
? {
|
||||
price: latestRecord.price as number,
|
||||
pricePerUnit: latestRecord.pricePerUnit as number,
|
||||
storeName: latestRecord.storeName as string,
|
||||
storeId: latestRecord.storeId as string,
|
||||
date: latestRecord.date as Date,
|
||||
}
|
||||
: undefined,
|
||||
cheapestOption:
|
||||
comparisons.length > 0
|
||||
? {
|
||||
price: comparisons[0].latestPrice,
|
||||
pricePerUnit: comparisons[0].latestPricePerUnit,
|
||||
storeName: comparisons[0].storeName,
|
||||
storeId: comparisons[0].storeId,
|
||||
date: comparisons[0].date,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
public async createList(data: CreateRefillListInput, householdId: string, userId: string) {
|
||||
let items: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
estimatedPrice?: number;
|
||||
storeId?: string;
|
||||
notes?: string;
|
||||
}> = [];
|
||||
|
||||
if (data.fromAlerts) {
|
||||
const alerts = await this.getAlerts(householdId, userId, data.thresholdDays);
|
||||
items = alerts.map((alert) => ({
|
||||
medicineId: alert.medicineId,
|
||||
medicineName: alert.medicineName,
|
||||
quantity: alert.suggestedQuantity,
|
||||
unit: 'tablet' as string,
|
||||
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
|
||||
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
|
||||
}));
|
||||
} else if (data.items) {
|
||||
items = data.items.map((item) => ({
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as string,
|
||||
estimatedPrice: item.estimatedPrice,
|
||||
storeId: item.storeId,
|
||||
notes: item.notes,
|
||||
}));
|
||||
}
|
||||
|
||||
const totalEstimatedCost =
|
||||
items.length > 0
|
||||
? items.reduce((sum, item) => sum + (item.estimatedPrice ?? 0), 0) || undefined
|
||||
: undefined;
|
||||
|
||||
return this.refillsRepository.create({
|
||||
householdId,
|
||||
name: data.name,
|
||||
status: RefillListStatus.ACTIVE,
|
||||
preferredStoreId: data.preferredStoreId,
|
||||
totalEstimatedCost,
|
||||
createdBy: userId,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: RefillListQueryInput) {
|
||||
return this.refillsRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const list = await this.refillsRepository.findById(id, householdId);
|
||||
if (!list) throw new NotFoundError('Refill list not found');
|
||||
return list;
|
||||
}
|
||||
|
||||
public async updateList(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.refillsRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Refill list not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
listId: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
data: UpdateRefillListItemInput,
|
||||
) {
|
||||
await this.getById(listId, householdId);
|
||||
|
||||
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
|
||||
if (data.checked === true) {
|
||||
updateData.checkedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.refillsRepository.updateItem(
|
||||
listId,
|
||||
householdId,
|
||||
itemId,
|
||||
updateData,
|
||||
);
|
||||
if (!updated) throw new NotFoundError('Refill list or item not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async addToCabinet(listId: string, householdId: string, userId: string) {
|
||||
const list = await this.getById(listId, householdId);
|
||||
|
||||
const checkedItems = (list.items as Array<{
|
||||
_id: { toString: () => string };
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
storeId?: string;
|
||||
checked: boolean;
|
||||
addedToCabinet: boolean;
|
||||
}>).filter((item) => item.checked && !item.addedToCabinet);
|
||||
|
||||
if (checkedItems.length === 0) {
|
||||
return { addedCount: 0, priceRecordsCreated: 0 };
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
const addedItemIds: string[] = [];
|
||||
|
||||
for (const item of checkedItems) {
|
||||
await this.cabinetService.addItem(
|
||||
{
|
||||
medicineId: item.medicineId,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as never,
|
||||
unitPrice: item.actualPrice !== undefined && item.quantity > 0
|
||||
? item.actualPrice / item.quantity
|
||||
: undefined,
|
||||
totalPrice: item.actualPrice,
|
||||
storeId: item.storeId,
|
||||
purchaseDate: new Date().toISOString(),
|
||||
},
|
||||
householdId,
|
||||
userId,
|
||||
);
|
||||
addedCount++;
|
||||
addedItemIds.push(item._id.toString());
|
||||
}
|
||||
|
||||
await this.refillsRepository.markItemsAddedToCabinet(listId, householdId, addedItemIds);
|
||||
|
||||
return { addedCount, priceRecordsCreated: 0 };
|
||||
}
|
||||
|
||||
public async getStoreComparison(listId: string, householdId: string) {
|
||||
const list = await this.getById(listId, householdId);
|
||||
|
||||
const medicineIds = [
|
||||
...new Set(
|
||||
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
|
||||
),
|
||||
];
|
||||
|
||||
const comparisons = await Promise.all(
|
||||
medicineIds.map(async (medicineId) => {
|
||||
const storeOptions = await this.medicinePricesRepository.compareStores(
|
||||
householdId,
|
||||
medicineId,
|
||||
);
|
||||
return { medicineId, storeOptions };
|
||||
}),
|
||||
);
|
||||
|
||||
return comparisons.filter((c) => c.storeOptions.length > 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -138,6 +138,7 @@ export class RegimensService {
|
|||
const earliestExpiry = stock?.earliestExpiry ?? null;
|
||||
const dailyConsumption = consumption.dailyConsumption;
|
||||
|
||||
/* v8 ignore next */
|
||||
const daysUntilEmpty =
|
||||
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ export class RegimensService {
|
|||
}
|
||||
|
||||
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
|
||||
/* v8 ignore next 6 */
|
||||
burnRates.sort((a, b) => {
|
||||
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
|
||||
if (a.daysUntilEmpty === null) return 1;
|
||||
|
|
|
|||
171
packages/api/src/modules/stores/stores.repository.test.ts
Normal file
171
packages/api/src/modules/stores/stores.repository.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockFindOne: vi.fn(),
|
||||
mockFindOneAndUpdate: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/store.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne });
|
||||
const updateChain = () => ({ exec: mockFindOneAndUpdate });
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) { this.data = data; }
|
||||
save = mockSave;
|
||||
toObject() { return this.data; }
|
||||
static find = vi.fn(() => chain());
|
||||
static findOne = vi.fn(() => findOneChain());
|
||||
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||
}
|
||||
return { StoreModel: FakeModel };
|
||||
});
|
||||
|
||||
import { StoresRepository } from './stores.repository.js';
|
||||
|
||||
describe(StoresRepository.name, () => {
|
||||
let repo: StoresRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new StoresRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated items', async () => {
|
||||
const items = [{ _id: 'st-1', name: 'Walgreens' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = [{ _id: 'st-1' }, { _id: 'st-2' }, { _id: 'st-3' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles cursor pagination', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const cursor = Buffer.from('st-1').toString('base64');
|
||||
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by tags', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { tags: 'pharmacy,online', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by search', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { search: 'cvs', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips tag filter when tags string is empty after trim', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { tags: ' , ', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns store when found', async () => {
|
||||
const store = { _id: 'st-1', name: 'CVS' };
|
||||
mockFindOne.mockResolvedValue(store);
|
||||
|
||||
const result = await repo.findById('st-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(store);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.findById('missing', 'hh1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns store', async () => {
|
||||
const data = { name: 'Walgreens', tags: [], isActive: true };
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(data as never, 'hh1', 'user-1');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns store', async () => {
|
||||
const updated = { _id: 'st-1', name: 'Updated' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('st-1', 'hh1', { name: 'Updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.update('missing', 'hh1', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('sets isActive=false and returns store', async () => {
|
||||
const deactivated = { _id: 'st-1', isActive: false };
|
||||
mockFindOneAndUpdate.mockResolvedValue(deactivated);
|
||||
|
||||
const result = await repo.deactivate('st-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(deactivated);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
expect(await repo.deactivate('missing', 'hh1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/api/src/modules/stores/stores.repository.ts
Normal file
62
packages/api/src/modules/stores/stores.repository.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { StoreModel } from '../../schemas/store.schema.js';
|
||||
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||
|
||||
export class StoresRepository {
|
||||
public async findByHousehold(householdId: string, query: StoreQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.tags) {
|
||||
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
if (tagList.length > 0) filter['tags'] = { $in: tagList };
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
filter['name'] = { $regex: query.search, $options: 'i' };
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await StoreModel.find(filter)
|
||||
.sort({ _id: 1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return StoreModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
public async create(data: CreateStoreInput, householdId: string, createdBy: string) {
|
||||
const store = new StoreModel({ ...data, householdId, createdBy });
|
||||
const saved = await store.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||
return StoreModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async deactivate(id: string, householdId: string) {
|
||||
return StoreModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: { isActive: false } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
319
packages/api/src/modules/stores/stores.routes.test.ts
Normal file
319
packages/api/src/modules/stores/stores.routes.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockList, mockGetById, mockCreate, mockUpdate, mockDeactivate } = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockGetById: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDeactivate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./stores.repository.js', () => ({
|
||||
StoresRepository: class {
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
create = vi.fn();
|
||||
update = vi.fn();
|
||||
deactivate = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./stores.service.js', () => ({
|
||||
StoresService: class {
|
||||
list = mockList;
|
||||
getById = mockGetById;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
deactivate = mockDeactivate;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import storesRoutes from './stores.routes.js';
|
||||
|
||||
function makeFakeStore(overrides = {}) {
|
||||
return {
|
||||
_id: 'st-1',
|
||||
householdId: 'hh1',
|
||||
name: 'Walgreens',
|
||||
tags: ['pharmacy'],
|
||||
isActive: true,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2024-06-01T00:00:00.000Z',
|
||||
updatedAt: '2024-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('stores.routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(storesRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/stores', () => {
|
||||
it('returns paginated store list', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakeStore()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe('Walgreens');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query params to service', async () => {
|
||||
mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores?tags=pharmacy&search=cvs&limit=5',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ tags: 'pharmacy', search: 'cvs', limit: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date in response', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakeStore({
|
||||
_id: { toString: () => 'st-obj' },
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
})],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('st-obj');
|
||||
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('includes optional fields in response when present', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [makeFakeStore({
|
||||
address: '123 Main St',
|
||||
location: { lat: 40.7128, lng: -74.006 },
|
||||
url: 'https://walgreens.com',
|
||||
notes: 'Open 24h',
|
||||
})],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].address).toBe('123 Main St');
|
||||
expect(body.data[0].location).toEqual({ lat: 40.7128, lng: -74.006 });
|
||||
expect(body.data[0].url).toBe('https://walgreens.com');
|
||||
expect(body.data[0].notes).toBe('Open 24h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/stores/:id', () => {
|
||||
it('returns single store', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeStore());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores/st-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Walgreens');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeStore());
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/stores/st-99',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetById).toHaveBeenCalledWith('st-99', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/stores', () => {
|
||||
it('creates store and returns 201', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakeStore());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Walgreens' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().name).toBe('Walgreens');
|
||||
});
|
||||
|
||||
it('passes userId to service', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakeStore());
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'CVS' },
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'CVS' }),
|
||||
'hh1',
|
||||
'kc-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 for missing name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/stores',
|
||||
headers: authHeaders,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/stores/:id', () => {
|
||||
it('updates store and returns 200', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakeStore({ name: 'CVS' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/stores/st-1',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'CVS' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('CVS');
|
||||
});
|
||||
|
||||
it('passes id, householdId, body to service', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakeStore());
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/stores/st-1',
|
||||
headers: authHeaders,
|
||||
payload: { isActive: false },
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'st-1',
|
||||
'hh1',
|
||||
expect.objectContaining({ isActive: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/stores/:id', () => {
|
||||
it('deactivates store and returns 200', async () => {
|
||||
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/stores/st-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false }));
|
||||
|
||||
await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/stores/st-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockDeactivate).toHaveBeenCalledWith('st-1', 'hh1');
|
||||
});
|
||||
});
|
||||
});
|
||||
158
packages/api/src/modules/stores/stores.routes.ts
Normal file
158
packages/api/src/modules/stores/stores.routes.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateStoreSchema,
|
||||
UpdateStoreSchema,
|
||||
StoreQuerySchema,
|
||||
StoreResponseSchema,
|
||||
StoreListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { StoresRepository } from './stores.repository.js';
|
||||
import { StoresService } from './stores.service.js';
|
||||
|
||||
type AnyStoreDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: { lat: number; lng: number };
|
||||
url?: string;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string | Date | { toISOString: () => string };
|
||||
updatedAt: string | Date | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
function toStoreResponse(rawDoc: unknown) {
|
||||
const doc = rawDoc as AnyStoreDoc;
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
...(doc.address != null ? { address: doc.address } : {}),
|
||||
...(doc.location != null ? { location: doc.location } : {}),
|
||||
...(doc.url != null ? { url: doc.url } : {}),
|
||||
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||
tags: doc.tags,
|
||||
isActive: doc.isActive,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
storesRepository: StoresRepository;
|
||||
storesService: StoresService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
storesService: asClass(StoresService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/stores',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: StoreQuerySchema,
|
||||
response: { 200: StoreListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toStoreResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/stores/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: StoreResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/stores',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateStoreSchema,
|
||||
response: { 201: StoreResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/stores/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateStoreSchema,
|
||||
response: { 200: StoreResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/stores/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: StoreResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.deactivate(request.params.id, request.params.householdId);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'stores-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
109
packages/api/src/modules/stores/stores.service.test.ts
Normal file
109
packages/api/src/modules/stores/stores.service.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { StoresService } from './stores.service.js';
|
||||
|
||||
describe(StoresService.name, () => {
|
||||
const mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deactivate: vi.fn(),
|
||||
};
|
||||
|
||||
let service: StoresService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new StoresService({ storesRepository: mockRepo as never });
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.list('hh1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns store when found', async () => {
|
||||
const store = { _id: 'st-1', name: 'Walgreens' };
|
||||
mockRepo.findById.mockResolvedValue(store);
|
||||
|
||||
expect(await service.getById('st-1', 'hh1')).toEqual(store);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('missing', 'hh1')).rejects.toThrow('Store not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const store = { _id: 'st-1', name: 'CVS' };
|
||||
mockRepo.create.mockResolvedValue(store);
|
||||
|
||||
const result = await service.create({ name: 'CVS', tags: [], isActive: true } as never, 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(store);
|
||||
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns store', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||
const updated = { _id: 'st-1', name: 'CVS' };
|
||||
mockRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('st-1', 'hh1', { name: 'CVS' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('throws NotFoundError on initial lookup failure', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Store not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||
mockRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('st-1', 'hh1', {})).rejects.toThrow('Store not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('deactivates and returns store', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||
const deactivated = { _id: 'st-1', isActive: false };
|
||||
mockRepo.deactivate.mockResolvedValue(deactivated);
|
||||
|
||||
const result = await service.deactivate('st-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(deactivated);
|
||||
expect(mockRepo.deactivate).toHaveBeenCalledWith('st-1', 'hh1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when store not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivate('missing', 'hh1')).rejects.toThrow('Store not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when deactivate returns null', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'st-1' });
|
||||
mockRepo.deactivate.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivate('st-1', 'hh1')).rejects.toThrow('Store not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/api/src/modules/stores/stores.service.ts
Normal file
43
packages/api/src/modules/stores/stores.service.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { StoresRepository } from './stores.repository.js';
|
||||
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
storesRepository: StoresRepository;
|
||||
}
|
||||
|
||||
export class StoresService {
|
||||
private readonly storesRepository: StoresRepository;
|
||||
|
||||
public constructor({ storesRepository }: Deps) {
|
||||
this.storesRepository = storesRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: StoreQueryInput) {
|
||||
return this.storesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const store = await this.storesRepository.findById(id, householdId);
|
||||
if (!store) throw new NotFoundError('Store not found');
|
||||
return store;
|
||||
}
|
||||
|
||||
public async create(data: CreateStoreInput, householdId: string, userId: string) {
|
||||
return this.storesRepository.create(data, householdId, userId);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.storesRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Store not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async deactivate(id: string, householdId: string) {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.storesRepository.deactivate(id, householdId);
|
||||
if (!updated) throw new NotFoundError('Store not found');
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
32
packages/api/src/schemas/medicine-price.schema.ts
Normal file
32
packages/api/src/schemas/medicine-price.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
const medicinePriceSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
medicineProductId: { type: String, required: true },
|
||||
medicineProductBrand: { type: String, required: true },
|
||||
medicineId: { type: String, required: true },
|
||||
medicineName: { type: String, required: true },
|
||||
storeId: { type: String, required: true },
|
||||
storeName: { type: String, required: true },
|
||||
price: { type: Number, required: true },
|
||||
currency: { type: String, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
unit: { type: String, required: true },
|
||||
pricePerUnit: { type: Number, required: true },
|
||||
date: { type: Date, required: true },
|
||||
isInsurancePrice: { type: Boolean, required: true, default: false },
|
||||
notes: { type: String },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: { createdAt: true, updatedAt: false } },
|
||||
);
|
||||
|
||||
medicinePriceSchema.index({ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 });
|
||||
medicinePriceSchema.index({ householdId: 1, medicineId: 1, date: -1 });
|
||||
medicinePriceSchema.index({ householdId: 1, storeId: 1, date: -1 });
|
||||
|
||||
export const MedicinePriceModel = mongoose.model('MedicinePrice', medicinePriceSchema);
|
||||
export type MedicinePriceDocument = mongoose.InferSchemaType<typeof medicinePriceSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
67
packages/api/src/schemas/purchase.schema.ts
Normal file
67
packages/api/src/schemas/purchase.schema.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import mongoose, { type Document } from 'mongoose';
|
||||
|
||||
const { Schema, model } = mongoose;
|
||||
|
||||
const purchaseItemSchema = new Schema(
|
||||
{
|
||||
medicineProductId: { type: String },
|
||||
medicineId: { type: String },
|
||||
foodProductId: { type: String },
|
||||
name: { type: String, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
unit: { type: String, required: true },
|
||||
actualPrice: { type: Number },
|
||||
currency: { type: String },
|
||||
priceRecordId: { type: String },
|
||||
addedToCabinet: { type: Boolean, default: false },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const purchaseSchema = new Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
storeId: { type: String, required: true },
|
||||
storeName: { type: String, required: true },
|
||||
status: { type: String, enum: ['ordered', 'in_cabinet'], required: true },
|
||||
items: { type: [purchaseItemSchema], required: true },
|
||||
notes: { type: String },
|
||||
purchasedAt: { type: Date, required: true },
|
||||
receivedAt: { type: Date },
|
||||
createdBy: { type: String, required: true },
|
||||
isDeleted: { type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
purchaseSchema.index({ householdId: 1, status: 1, purchasedAt: -1 });
|
||||
purchaseSchema.index({ householdId: 1, storeId: 1 });
|
||||
purchaseSchema.index({ householdId: 1, 'items.medicineProductId': 1 });
|
||||
|
||||
export type PurchaseDocument = Document & {
|
||||
householdId: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
status: string;
|
||||
items: Array<{
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
foodProductId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
priceRecordId?: string;
|
||||
addedToCabinet: boolean;
|
||||
}>;
|
||||
notes?: string;
|
||||
purchasedAt: Date;
|
||||
receivedAt?: Date;
|
||||
createdBy: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export const PurchaseModel = model<PurchaseDocument>('Purchase', purchaseSchema);
|
||||
46
packages/api/src/schemas/refill-list.schema.ts
Normal file
46
packages/api/src/schemas/refill-list.schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { DosageUnit } from '@meshitrack/shared';
|
||||
import { RefillListStatus } from '@meshitrack/shared';
|
||||
|
||||
const refillListItemSchema = new mongoose.Schema(
|
||||
{
|
||||
medicineId: { type: String, required: true },
|
||||
medicineName: { type: String, required: true },
|
||||
quantity: { type: Number, required: true, min: 0 },
|
||||
unit: { type: String, enum: Object.values(DosageUnit), required: true },
|
||||
estimatedPrice: { type: Number, min: 0 },
|
||||
actualPrice: { type: Number, min: 0 },
|
||||
checked: { type: Boolean, required: true, default: false },
|
||||
checkedAt: { type: Date },
|
||||
addedToCabinet: { type: Boolean, required: true, default: false },
|
||||
storeId: { type: String },
|
||||
notes: { type: String },
|
||||
},
|
||||
{ _id: true },
|
||||
);
|
||||
|
||||
const refillListSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
items: { type: [refillListItemSchema], required: true, default: [] },
|
||||
status: {
|
||||
type: String,
|
||||
enum: Object.values(RefillListStatus),
|
||||
required: true,
|
||||
default: RefillListStatus.ACTIVE,
|
||||
},
|
||||
preferredStoreId: { type: String },
|
||||
totalEstimatedCost: { type: Number },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
refillListSchema.index({ householdId: 1, status: 1 });
|
||||
refillListSchema.index({ householdId: 1, createdAt: -1 });
|
||||
|
||||
export const RefillListModel = mongoose.model('RefillList', refillListSchema);
|
||||
export type RefillListDocument = mongoose.InferSchemaType<typeof refillListSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
32
packages/api/src/schemas/store.schema.ts
Normal file
32
packages/api/src/schemas/store.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
const locationSchema = new mongoose.Schema(
|
||||
{
|
||||
lat: { type: Number, required: true },
|
||||
lng: { type: Number, required: true },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const storeSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
address: { type: String },
|
||||
location: { type: locationSchema },
|
||||
url: { type: String },
|
||||
notes: { type: String },
|
||||
tags: { type: [String], default: [] },
|
||||
isActive: { type: Boolean, required: true, default: true },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
storeSchema.index({ householdId: 1, name: 1 });
|
||||
storeSchema.index({ householdId: 1, tags: 1 });
|
||||
|
||||
export const StoreModel = mongoose.model('Store', storeSchema);
|
||||
export type StoreDocument = mongoose.InferSchemaType<typeof storeSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
107
packages/api/src/scripts/migrate-dosage-units.ts
Normal file
107
packages/api/src/scripts/migrate-dosage-units.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Migration: normalize legacy dosage unit values to current DosageUnit enum.
|
||||
*
|
||||
* Known renames:
|
||||
* pill → tablet
|
||||
* pills → tablet
|
||||
*
|
||||
* Collections updated:
|
||||
* medicineproducts packageUnit
|
||||
* cabinetitems unit
|
||||
* regimens medications[].dosageUnit
|
||||
* refillists items[].unit
|
||||
*/
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI =
|
||||
process.env.SEED_MONGODB_URI ||
|
||||
process.env.MONGODB_URI ||
|
||||
'mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0';
|
||||
|
||||
const UNIT_MAP: Record<string, string> = {
|
||||
pill: 'tablet',
|
||||
pills: 'tablet',
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
console.log('Connecting...');
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
const db = mongoose.connection.db!;
|
||||
|
||||
let total = 0;
|
||||
|
||||
// --- medicineproducts.packageUnit ---
|
||||
for (const [old, next] of Object.entries(UNIT_MAP)) {
|
||||
const result = await db
|
||||
.collection('medicineproducts')
|
||||
.updateMany({ packageUnit: old }, { $set: { packageUnit: next } });
|
||||
if (result.modifiedCount > 0) {
|
||||
console.log(`medicineproducts.packageUnit: ${old} → ${next} (${result.modifiedCount})`);
|
||||
total += result.modifiedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// --- cabinetitems.unit ---
|
||||
for (const [old, next] of Object.entries(UNIT_MAP)) {
|
||||
const result = await db
|
||||
.collection('cabinetitems')
|
||||
.updateMany({ unit: old }, { $set: { unit: next } });
|
||||
if (result.modifiedCount > 0) {
|
||||
console.log(`cabinetitems.unit: ${old} → ${next} (${result.modifiedCount})`);
|
||||
total += result.modifiedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// --- regimens: medications[].dosageUnit ---
|
||||
// MongoDB cannot target a specific array element value with updateMany + $set on nested
|
||||
// fields matching a condition, so we load and rewrite affected documents.
|
||||
const regimens = await db.collection('regimens').find({}).toArray();
|
||||
for (const regimen of regimens) {
|
||||
const medications: Array<Record<string, unknown>> = regimen.medications ?? [];
|
||||
let dirty = false;
|
||||
for (const med of medications) {
|
||||
const mapped = UNIT_MAP[med.dosageUnit as string];
|
||||
if (mapped) {
|
||||
med.dosageUnit = mapped;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
await db
|
||||
.collection('regimens')
|
||||
.updateOne({ _id: regimen._id }, { $set: { medications } });
|
||||
console.log(`regimens: updated medications in regimen ${regimen._id}`);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
|
||||
// --- refillists: items[].unit ---
|
||||
const refillLists = await db.collection('refillists').find({}).toArray();
|
||||
for (const list of refillLists) {
|
||||
const items: Array<Record<string, unknown>> = list.items ?? [];
|
||||
let dirty = false;
|
||||
for (const item of items) {
|
||||
const mapped = UNIT_MAP[item.unit as string];
|
||||
if (mapped) {
|
||||
item.unit = mapped;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
await db
|
||||
.collection('refillists')
|
||||
.updateOne({ _id: list._id }, { $set: { items } });
|
||||
console.log(`refillists: updated items in list ${list._id}`);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Migration complete. ${total} document(s) updated.`);
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue