Implement regimens
This commit is contained in:
parent
1f66fab30f
commit
9f416903ef
66 changed files with 9130 additions and 189 deletions
|
|
@ -31,6 +31,9 @@ import householdsRoutes from './modules/households/households.routes.js';
|
|||
import medicinesRoutes from './modules/medicines/medicines.routes.js';
|
||||
import medicineProductsRoutes from './modules/medicine-products/medicine-products.routes.js';
|
||||
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';
|
||||
|
||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||
const app = Fastify({
|
||||
|
|
@ -103,6 +106,9 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
|||
await app.register(medicinesRoutes);
|
||||
await app.register(medicineProductsRoutes);
|
||||
await app.register(cabinetRoutes);
|
||||
await app.register(cabinetEventsRoutes);
|
||||
await app.register(regimensRoutes);
|
||||
await app.register(organizerRoutes);
|
||||
|
||||
// Global error handler
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockFind, mockSave, mockInsertMany, mockAggregate } = vi.hoisted(() => ({
|
||||
mockFind: vi.fn(),
|
||||
mockSave: vi.fn(),
|
||||
mockInsertMany: vi.fn(),
|
||||
mockAggregate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../schemas/cabinet-event.schema.js', () => {
|
||||
const chain = () => ({
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: mockFind,
|
||||
});
|
||||
|
||||
const aggChain = () => ({
|
||||
exec: mockAggregate,
|
||||
});
|
||||
|
||||
class FakeModel {
|
||||
data: unknown;
|
||||
constructor(data: unknown) {
|
||||
this.data = data;
|
||||
}
|
||||
save = mockSave;
|
||||
toObject() {
|
||||
return this.data;
|
||||
}
|
||||
static find = vi.fn(() => chain());
|
||||
static insertMany = mockInsertMany;
|
||||
static aggregate = vi.fn(() => aggChain());
|
||||
}
|
||||
|
||||
return { CabinetEventModel: FakeModel };
|
||||
});
|
||||
|
||||
import { CabinetEventsRepository } from './cabinet-events.repository.js';
|
||||
|
||||
describe(CabinetEventsRepository.name, () => {
|
||||
let repo: CabinetEventsRepository;
|
||||
|
||||
const baseEventData = {
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
eventType: 'purchased' as const,
|
||||
quantity: 10,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 10,
|
||||
sourceType: 'manual' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new CabinetEventsRepository();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns a cabinet event', async () => {
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(baseEventData);
|
||||
|
||||
expect(result).toEqual(baseEventData);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany', () => {
|
||||
it('inserts multiple events', async () => {
|
||||
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
|
||||
mockInsertMany.mockResolvedValue(events);
|
||||
|
||||
const result = await repo.createMany(events);
|
||||
|
||||
expect(result).toEqual(events);
|
||||
expect(mockInsertMany).toHaveBeenCalledWith(events);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated events with no filters', async () => {
|
||||
const items = [{ _id: 'ev-1', quantity: 10 }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles cursor-based pagination', async () => {
|
||||
const items = [{ _id: 'ev-2', quantity: 5 }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const cursor = Buffer.from('ev-1').toString('base64');
|
||||
const result = await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}`, quantity: i }));
|
||||
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('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by medicineId', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { medicineId: 'med-1', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by eventType', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', { eventType: 'purchased' as never, limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by startDate only', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', {
|
||||
startDate: '2024-01-01T00:00:00.000Z',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by endDate only', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', {
|
||||
endDate: '2024-12-31T00:00:00.000Z',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by both startDate and endDate', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', {
|
||||
startDate: '2024-01-01T00:00:00.000Z',
|
||||
endDate: '2024-12-31T00:00:00.000Z',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByCabinetItem', () => {
|
||||
it('returns paginated events for a cabinet item', async () => {
|
||||
const items = [{ _id: 'ev-1', cabinetItemId: 'ci-1' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles cursor-based pagination', async () => {
|
||||
const items = [{ _id: 'ev-2' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const cursor = Buffer.from('ev-1').toString('base64');
|
||||
const result = await repo.findByCabinetItem('hh1', 'ci-1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `ev-${i}` }));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByCabinetItem('hh1', 'ci-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpendingSummary', () => {
|
||||
it('returns spending summary with default month period', async () => {
|
||||
const byMedicine = [
|
||||
{
|
||||
_id: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 100,
|
||||
totalQuantity: 10,
|
||||
avgUnitPrice: 10,
|
||||
purchaseCount: 2,
|
||||
currency: 'USD',
|
||||
},
|
||||
];
|
||||
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
|
||||
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce(byMedicine)
|
||||
.mockResolvedValueOnce(byPeriod);
|
||||
|
||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||
|
||||
expect(result.totalSpent).toBe(100);
|
||||
expect(result.currency).toBe('USD');
|
||||
expect(result.byMedicine).toHaveLength(1);
|
||||
expect(result.byMedicine[0].medicineId).toBe('med-1');
|
||||
expect(result.byMedicine[0].medicineName).toBe('Metformin');
|
||||
expect(result.byMedicine[0].totalSpent).toBe(100);
|
||||
expect(result.byMedicine[0].totalQuantity).toBe(10);
|
||||
expect(result.byMedicine[0].avgUnitPrice).toBe(10);
|
||||
expect(result.byMedicine[0].purchaseCount).toBe(2);
|
||||
expect(result.byPeriod).toHaveLength(1);
|
||||
expect(result.byPeriod[0].period).toBe('2024-01');
|
||||
expect(result.byPeriod[0].totalSpent).toBe(100);
|
||||
});
|
||||
|
||||
it('returns null currency when no medicine data', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||
|
||||
expect(result.totalSpent).toBe(0);
|
||||
expect(result.currency).toBeNull();
|
||||
expect(result.byMedicine).toHaveLength(0);
|
||||
expect(result.byPeriod).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters by medicineId', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('filters by startDate only', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', {
|
||||
period: 'month',
|
||||
startDate: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('filters by endDate only', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', {
|
||||
period: 'month',
|
||||
endDate: '2024-12-31T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('filters by both startDate and endDate', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', {
|
||||
period: 'month',
|
||||
startDate: '2024-01-01T00:00:00.000Z',
|
||||
endDate: '2024-12-31T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses quarter date format', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', { period: 'quarter' });
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses year date format', async () => {
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await repo.getSpendingSummary('hh1', { period: 'year' });
|
||||
|
||||
expect(mockAggregate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles null currency in first medicine entry', async () => {
|
||||
const byMedicine = [
|
||||
{
|
||||
_id: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 50,
|
||||
totalQuantity: 5,
|
||||
avgUnitPrice: 10,
|
||||
purchaseCount: 1,
|
||||
currency: null,
|
||||
},
|
||||
];
|
||||
mockAggregate
|
||||
.mockResolvedValueOnce(byMedicine)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
|
||||
|
||||
expect(result.currency).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvgUnitPriceByMedicine', () => {
|
||||
it('returns empty map when no medicine ids provided', async () => {
|
||||
const result = await repo.getAvgUnitPriceByMedicine('hh1', []);
|
||||
|
||||
expect(result).toEqual(new Map());
|
||||
expect(mockAggregate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns map of avg unit prices', async () => {
|
||||
const results = [
|
||||
{ _id: 'med-1', avgUnitPrice: 10.5, currency: 'USD', totalSpent: 105, totalQuantity: 10 },
|
||||
{ _id: 'med-2', avgUnitPrice: 5.0, currency: 'EUR', totalSpent: 50, totalQuantity: 10 },
|
||||
];
|
||||
mockAggregate.mockResolvedValue(results);
|
||||
|
||||
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1', 'med-2']);
|
||||
|
||||
expect(map.size).toBe(2);
|
||||
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10.5, currency: 'USD' });
|
||||
expect(map.get('med-2')).toEqual({ avgUnitPrice: 5.0, currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('handles null currency in results', async () => {
|
||||
const results = [
|
||||
{ _id: 'med-1', avgUnitPrice: 10, currency: null, totalSpent: 100, totalQuantity: 10 },
|
||||
];
|
||||
mockAggregate.mockResolvedValue(results);
|
||||
|
||||
const map = await repo.getAvgUnitPriceByMedicine('hh1', ['med-1']);
|
||||
|
||||
expect(map.get('med-1')).toEqual({ avgUnitPrice: 10, currency: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { CabinetEventModel } from '../../schemas/cabinet-event.schema.js';
|
||||
import type {
|
||||
CabinetEventType,
|
||||
CabinetEventSourceType,
|
||||
CabinetEventQueryInput,
|
||||
SpendingSummaryQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
export interface CreateCabinetEventData {
|
||||
householdId: string;
|
||||
userId: string;
|
||||
cabinetItemId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
eventType: CabinetEventType;
|
||||
quantity: number;
|
||||
quantityBefore: number;
|
||||
quantityAfter: number;
|
||||
unitPrice?: number;
|
||||
totalPrice?: number;
|
||||
currency?: string;
|
||||
storeId?: string;
|
||||
storeName?: string;
|
||||
sourceType: CabinetEventSourceType;
|
||||
sourceId?: string;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CabinetEventsRepository {
|
||||
public async create(data: CreateCabinetEventData) {
|
||||
const event = new CabinetEventModel(data);
|
||||
const saved = await event.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async createMany(events: CreateCabinetEventData[]) {
|
||||
return CabinetEventModel.insertMany(events);
|
||||
}
|
||||
|
||||
public async findByHousehold(householdId: string, query: CabinetEventQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.medicineId) filter['medicineId'] = query.medicineId;
|
||||
if (query.eventType) filter['eventType'] = query.eventType;
|
||||
if (query.startDate || query.endDate) {
|
||||
const dateFilter: Record<string, string> = {};
|
||||
if (query.startDate) dateFilter['$gte'] = query.startDate;
|
||||
if (query.endDate) dateFilter['$lte'] = query.endDate;
|
||||
filter['createdAt'] = dateFilter;
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $lt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await CabinetEventModel.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 findByCabinetItem(
|
||||
householdId: string,
|
||||
cabinetItemId: string,
|
||||
query: { cursor?: string; limit: number },
|
||||
) {
|
||||
const filter: Record<string, unknown> = { householdId, cabinetItemId };
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $lt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await CabinetEventModel.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 getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
|
||||
const match: Record<string, unknown> = {
|
||||
householdId,
|
||||
eventType: 'purchased',
|
||||
unitPrice: { $ne: null },
|
||||
};
|
||||
if (query.medicineId) match['medicineId'] = query.medicineId;
|
||||
if (query.startDate || query.endDate) {
|
||||
const dateFilter: Record<string, string> = {};
|
||||
if (query.startDate) dateFilter['$gte'] = query.startDate;
|
||||
if (query.endDate) dateFilter['$lte'] = query.endDate;
|
||||
match['createdAt'] = dateFilter;
|
||||
}
|
||||
|
||||
const dateFormat =
|
||||
query.period === 'year' ? '%Y' : query.period === 'quarter' ? '%Y-Q%q' : '%Y-%m';
|
||||
|
||||
const [byMedicine, byPeriod] = await Promise.all([
|
||||
CabinetEventModel.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: '$medicineId',
|
||||
medicineName: { $first: '$medicineName' },
|
||||
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
|
||||
totalQuantity: { $sum: '$quantity' },
|
||||
purchaseCount: { $sum: 1 },
|
||||
currency: { $first: '$currency' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
avgUnitPrice: {
|
||||
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { totalSpent: -1 } },
|
||||
]).exec(),
|
||||
CabinetEventModel.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
$group: {
|
||||
_id: { $dateToString: { format: dateFormat, date: '$createdAt' } },
|
||||
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
|
||||
},
|
||||
},
|
||||
{ $sort: { _id: 1 } },
|
||||
]).exec(),
|
||||
]);
|
||||
|
||||
const totalSpent = byMedicine.reduce(
|
||||
(sum: number, m: Record<string, unknown>) => sum + (m.totalSpent as number),
|
||||
0,
|
||||
);
|
||||
const currency =
|
||||
byMedicine.length > 0 ? (byMedicine[0].currency as string | null) ?? null : null;
|
||||
|
||||
return {
|
||||
totalSpent,
|
||||
currency,
|
||||
byMedicine: byMedicine.map((m: Record<string, unknown>) => ({
|
||||
medicineId: m._id as string,
|
||||
medicineName: m.medicineName as string,
|
||||
totalSpent: m.totalSpent as number,
|
||||
totalQuantity: m.totalQuantity as number,
|
||||
avgUnitPrice: m.avgUnitPrice as number,
|
||||
purchaseCount: m.purchaseCount as number,
|
||||
})),
|
||||
byPeriod: byPeriod.map((p: Record<string, unknown>) => ({
|
||||
period: p._id as string,
|
||||
totalSpent: p.totalSpent as number,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
|
||||
if (medicineIds.length === 0) return new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
||||
|
||||
const results = await CabinetEventModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
householdId,
|
||||
eventType: 'purchased',
|
||||
medicineId: { $in: medicineIds },
|
||||
unitPrice: { $ne: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$medicineId',
|
||||
totalSpent: { $sum: { $multiply: ['$unitPrice', '$quantity'] } },
|
||||
totalQuantity: { $sum: '$quantity' },
|
||||
currency: { $first: '$currency' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
avgUnitPrice: {
|
||||
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
|
||||
},
|
||||
},
|
||||
},
|
||||
]).exec();
|
||||
|
||||
const map = new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
||||
for (const r of results) {
|
||||
map.set(r._id as string, {
|
||||
avgUnitPrice: r.avgUnitPrice as number,
|
||||
currency: (r.currency as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
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 { mockListEvents, mockGetEventsByItem, mockGetSpendingSummary } = vi.hoisted(() => ({
|
||||
mockListEvents: vi.fn(),
|
||||
mockGetEventsByItem: vi.fn(),
|
||||
mockGetSpendingSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./cabinet-events.repository.js', () => ({
|
||||
CabinetEventsRepository: class {
|
||||
create = vi.fn();
|
||||
createMany = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
findByCabinetItem = vi.fn();
|
||||
getSpendingSummary = vi.fn();
|
||||
getAvgUnitPriceByMedicine = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./cabinet-events.service.js', () => ({
|
||||
CabinetEventsService: class {
|
||||
logEvent = vi.fn();
|
||||
logEvents = vi.fn();
|
||||
listEvents = mockListEvents;
|
||||
getEventsByItem = mockGetEventsByItem;
|
||||
getSpendingSummary = mockGetSpendingSummary;
|
||||
getAvgUnitPrices = 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 cabinetEventsRoutes from './cabinet-events.routes.js';
|
||||
|
||||
function makeFakeEvent(overrides = {}) {
|
||||
return {
|
||||
_id: 'ev-1',
|
||||
householdId: 'hh1',
|
||||
userId: 'kc-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
eventType: 'purchased',
|
||||
quantity: 10,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 10,
|
||||
sourceType: 'manual',
|
||||
createdAt: '2024-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cabinet-events.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(cabinetEventsRoutes);
|
||||
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/cabinet-events', () => {
|
||||
it('returns paginated event list', async () => {
|
||||
const event = makeFakeEvent();
|
||||
mockListEvents.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].medicineName).toBe('Metformin');
|
||||
expect(body.data[0].eventType).toBe('purchased');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date objects in response', async () => {
|
||||
const event = makeFakeEvent({
|
||||
_id: { toString: () => 'ev-obj' },
|
||||
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
|
||||
unitPrice: 5.5,
|
||||
totalPrice: 55,
|
||||
currency: 'USD',
|
||||
storeId: 'store-1',
|
||||
storeName: 'Pharmacy A',
|
||||
sourceId: 'src-1',
|
||||
reason: 'restocking',
|
||||
notes: 'bulk purchase',
|
||||
});
|
||||
mockListEvents.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('ev-obj');
|
||||
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(body.data[0].unitPrice).toBe(5.5);
|
||||
expect(body.data[0].totalPrice).toBe(55);
|
||||
expect(body.data[0].currency).toBe('USD');
|
||||
expect(body.data[0].storeId).toBe('store-1');
|
||||
expect(body.data[0].storeName).toBe('Pharmacy A');
|
||||
expect(body.data[0].sourceId).toBe('src-1');
|
||||
expect(body.data[0].reason).toBe('restocking');
|
||||
expect(body.data[0].notes).toBe('bulk purchase');
|
||||
});
|
||||
|
||||
it('handles Date instances in createdAt', async () => {
|
||||
const event = makeFakeEvent({
|
||||
createdAt: new Date('2024-03-15T12:00:00.000Z'),
|
||||
});
|
||||
mockListEvents.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].createdAt).toBe('2024-03-15T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('omits null optional fields from response', async () => {
|
||||
const event = makeFakeEvent({
|
||||
unitPrice: null,
|
||||
totalPrice: null,
|
||||
currency: null,
|
||||
storeId: null,
|
||||
storeName: null,
|
||||
sourceId: null,
|
||||
reason: null,
|
||||
notes: null,
|
||||
});
|
||||
mockListEvents.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].unitPrice).toBeUndefined();
|
||||
expect(body.data[0].totalPrice).toBeUndefined();
|
||||
expect(body.data[0].currency).toBeUndefined();
|
||||
expect(body.data[0].storeId).toBeUndefined();
|
||||
expect(body.data[0].storeName).toBeUndefined();
|
||||
expect(body.data[0].sourceId).toBeUndefined();
|
||||
expect(body.data[0].reason).toBeUndefined();
|
||||
expect(body.data[0].notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes query parameters to service', async () => {
|
||||
mockListEvents.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events?medicineId=med-1&eventType=purchased&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockListEvents).toHaveBeenCalledWith('hh1', expect.objectContaining({
|
||||
medicineId: 'med-1',
|
||||
eventType: 'purchased',
|
||||
limit: 10,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId', () => {
|
||||
it('returns paginated events for a cabinet item', async () => {
|
||||
const event = makeFakeEvent();
|
||||
mockGetEventsByItem.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].cabinetItemId).toBe('ci-1');
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query parameters to service', async () => {
|
||||
mockGetEventsByItem.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1?limit=5&cursor=abc',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockGetEventsByItem).toHaveBeenCalledWith('hh1', 'ci-1', expect.objectContaining({
|
||||
limit: 5,
|
||||
cursor: 'abc',
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date objects in by-item response', async () => {
|
||||
const event = makeFakeEvent({
|
||||
_id: { toString: () => 'ev-obj-2' },
|
||||
createdAt: new Date('2024-05-01T00:00:00.000Z'),
|
||||
});
|
||||
mockGetEventsByItem.mockResolvedValue({
|
||||
data: [event],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/by-item/ci-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('ev-obj-2');
|
||||
expect(body.data[0].createdAt).toBe('2024-05-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/cabinet-events/spending-summary', () => {
|
||||
it('returns spending summary', async () => {
|
||||
mockGetSpendingSummary.mockResolvedValue({
|
||||
totalSpent: 250,
|
||||
currency: 'USD',
|
||||
byMedicine: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
totalSpent: 250,
|
||||
totalQuantity: 25,
|
||||
avgUnitPrice: 10,
|
||||
purchaseCount: 5,
|
||||
},
|
||||
],
|
||||
byPeriod: [
|
||||
{ period: '2024-01', totalSpent: 100 },
|
||||
{ period: '2024-02', totalSpent: 150 },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.totalSpent).toBe(250);
|
||||
expect(body.currency).toBe('USD');
|
||||
expect(body.byMedicine).toHaveLength(1);
|
||||
expect(body.byMedicine[0].medicineId).toBe('med-1');
|
||||
expect(body.byPeriod).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes query parameters to service', async () => {
|
||||
mockGetSpendingSummary.mockResolvedValue({
|
||||
totalSpent: 0,
|
||||
currency: null,
|
||||
byMedicine: [],
|
||||
byPeriod: [],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/spending-summary?period=quarter&medicineId=med-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.objectContaining({
|
||||
period: 'quarter',
|
||||
medicineId: 'med-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns empty summary with null currency', async () => {
|
||||
mockGetSpendingSummary.mockResolvedValue({
|
||||
totalSpent: 0,
|
||||
currency: null,
|
||||
byMedicine: [],
|
||||
byPeriod: [],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet-events/spending-summary',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.totalSpent).toBe(0);
|
||||
expect(body.currency).toBeNull();
|
||||
expect(body.byMedicine).toHaveLength(0);
|
||||
expect(body.byPeriod).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/api/src/modules/cabinet-events/cabinet-events.routes.ts
Normal file
157
packages/api/src/modules/cabinet-events/cabinet-events.routes.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CabinetEventQuerySchema,
|
||||
CabinetEventListResponseSchema,
|
||||
SpendingSummaryQuerySchema,
|
||||
SpendingSummaryResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { CabinetEventsRepository } from './cabinet-events.repository.js';
|
||||
import { CabinetEventsService } from './cabinet-events.service.js';
|
||||
|
||||
type AnyCabinetEventDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
userId: string;
|
||||
cabinetItemId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
eventType: string;
|
||||
quantity: number;
|
||||
quantityBefore: number;
|
||||
quantityAfter: number;
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
sourceType: string;
|
||||
sourceId?: string | null;
|
||||
reason?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt: string | Date | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
cabinetItemId: doc.cabinetItemId,
|
||||
medicineId: doc.medicineId,
|
||||
medicineName: doc.medicineName,
|
||||
eventType: doc.eventType,
|
||||
quantity: doc.quantity,
|
||||
quantityBefore: doc.quantityBefore,
|
||||
quantityAfter: doc.quantityAfter,
|
||||
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
|
||||
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
|
||||
...(doc.currency ? { currency: doc.currency } : {}),
|
||||
...(doc.storeId ? { storeId: doc.storeId } : {}),
|
||||
...(doc.storeName ? { storeName: doc.storeName } : {}),
|
||||
sourceType: doc.sourceType,
|
||||
...(doc.sourceId ? { sourceId: doc.sourceId } : {}),
|
||||
...(doc.reason ? { reason: doc.reason } : {}),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
cabinetEventsRepository: CabinetEventsRepository;
|
||||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
cabinetEventsRepository: asClass(CabinetEventsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
cabinetEventsService: asClass(CabinetEventsService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/cabinet-events — list events
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/cabinet-events',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: CabinetEventQuerySchema,
|
||||
response: { 200: CabinetEventListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const result = await service.listEvents(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toCabinetEventResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId — events for one item
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/cabinet-events/by-item/:cabinetItemId',
|
||||
schema: {
|
||||
params: householdParams.extend({ cabinetItemId: z.string() }),
|
||||
querystring: z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
response: { 200: CabinetEventListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const result = await service.getEventsByItem(
|
||||
request.params.householdId,
|
||||
request.params.cabinetItemId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toCabinetEventResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/cabinet-events/spending-summary — spending analytics
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/cabinet-events/spending-summary',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: SpendingSummaryQuerySchema,
|
||||
response: { 200: SpendingSummaryResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const summary = await service.getSpendingSummary(
|
||||
request.params.householdId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send(summary);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'cabinet-events-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CabinetEventsService } from './cabinet-events.service.js';
|
||||
|
||||
describe(CabinetEventsService.name, () => {
|
||||
const mockCabinetEventsRepo = {
|
||||
create: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
findByHousehold: vi.fn(),
|
||||
findByCabinetItem: vi.fn(),
|
||||
getSpendingSummary: vi.fn(),
|
||||
getAvgUnitPriceByMedicine: vi.fn(),
|
||||
};
|
||||
|
||||
let service: CabinetEventsService;
|
||||
|
||||
const baseEventData = {
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
eventType: 'purchased' as const,
|
||||
quantity: 10,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 10,
|
||||
sourceType: 'manual' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new CabinetEventsService({
|
||||
cabinetEventsRepository: mockCabinetEventsRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('logEvent', () => {
|
||||
it('delegates to repository create', async () => {
|
||||
const created = { _id: 'ev-1', ...baseEventData };
|
||||
mockCabinetEventsRepo.create.mockResolvedValue(created);
|
||||
|
||||
const result = await service.logEvent(baseEventData);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(mockCabinetEventsRepo.create).toHaveBeenCalledWith(baseEventData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logEvents', () => {
|
||||
it('delegates to repository createMany', async () => {
|
||||
const events = [baseEventData, { ...baseEventData, quantity: 5 }];
|
||||
const inserted = events.map((e, i) => ({ _id: `ev-${i}`, ...e }));
|
||||
mockCabinetEventsRepo.createMany.mockResolvedValue(inserted);
|
||||
|
||||
const result = await service.logEvents(events);
|
||||
|
||||
expect(result).toEqual(inserted);
|
||||
expect(mockCabinetEventsRepo.createMany).toHaveBeenCalledWith(events);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input without calling repository', async () => {
|
||||
const result = await service.logEvents([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockCabinetEventsRepo.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listEvents', () => {
|
||||
it('delegates to repository findByHousehold', async () => {
|
||||
const expected = {
|
||||
data: [{ _id: 'ev-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
};
|
||||
mockCabinetEventsRepo.findByHousehold.mockResolvedValue(expected);
|
||||
|
||||
const query = { limit: 20 };
|
||||
const result = await service.listEvents('hh1', query);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockCabinetEventsRepo.findByHousehold).toHaveBeenCalledWith('hh1', query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventsByItem', () => {
|
||||
it('delegates to repository findByCabinetItem', async () => {
|
||||
const expected = {
|
||||
data: [{ _id: 'ev-1', cabinetItemId: 'ci-1' }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
};
|
||||
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
|
||||
|
||||
const query = { limit: 20 };
|
||||
const result = await service.getEventsByItem('hh1', 'ci-1', query);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
|
||||
});
|
||||
|
||||
it('passes cursor through to repository', async () => {
|
||||
const expected = {
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
};
|
||||
mockCabinetEventsRepo.findByCabinetItem.mockResolvedValue(expected);
|
||||
|
||||
const query = { cursor: 'abc123', limit: 10 };
|
||||
await service.getEventsByItem('hh1', 'ci-1', query);
|
||||
|
||||
expect(mockCabinetEventsRepo.findByCabinetItem).toHaveBeenCalledWith('hh1', 'ci-1', query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpendingSummary', () => {
|
||||
it('delegates to repository getSpendingSummary', async () => {
|
||||
const expected = {
|
||||
totalSpent: 100,
|
||||
currency: 'USD',
|
||||
byMedicine: [],
|
||||
byPeriod: [],
|
||||
};
|
||||
mockCabinetEventsRepo.getSpendingSummary.mockResolvedValue(expected);
|
||||
|
||||
const query = { period: 'month' as const };
|
||||
const result = await service.getSpendingSummary('hh1', query);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockCabinetEventsRepo.getSpendingSummary).toHaveBeenCalledWith('hh1', query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvgUnitPrices', () => {
|
||||
it('delegates to repository getAvgUnitPriceByMedicine', async () => {
|
||||
const expected = new Map([['med-1', { avgUnitPrice: 10, currency: 'USD' }]]);
|
||||
mockCabinetEventsRepo.getAvgUnitPriceByMedicine.mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', ['med-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { CabinetEventsRepository, CreateCabinetEventData } from './cabinet-events.repository.js';
|
||||
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
|
||||
|
||||
interface Deps {
|
||||
cabinetEventsRepository: CabinetEventsRepository;
|
||||
}
|
||||
|
||||
export class CabinetEventsService {
|
||||
private readonly cabinetEventsRepository: CabinetEventsRepository;
|
||||
|
||||
public constructor({ cabinetEventsRepository }: Deps) {
|
||||
this.cabinetEventsRepository = cabinetEventsRepository;
|
||||
}
|
||||
|
||||
public async logEvent(data: CreateCabinetEventData) {
|
||||
return this.cabinetEventsRepository.create(data);
|
||||
}
|
||||
|
||||
public async logEvents(events: CreateCabinetEventData[]) {
|
||||
if (events.length === 0) return [];
|
||||
return this.cabinetEventsRepository.createMany(events);
|
||||
}
|
||||
|
||||
public async listEvents(householdId: string, query: CabinetEventQueryInput) {
|
||||
return this.cabinetEventsRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getEventsByItem(
|
||||
householdId: string,
|
||||
cabinetItemId: string,
|
||||
query: { cursor?: string; limit: number },
|
||||
) {
|
||||
return this.cabinetEventsRepository.findByCabinetItem(householdId, cabinetItemId, query);
|
||||
}
|
||||
|
||||
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
|
||||
return this.cabinetEventsRepository.getSpendingSummary(householdId, query);
|
||||
}
|
||||
|
||||
public async getAvgUnitPrices(householdId: string, medicineIds: string[]) {
|
||||
return this.cabinetEventsRepository.getAvgUnitPriceByMedicine(householdId, medicineIds);
|
||||
}
|
||||
}
|
||||
|
|
@ -251,4 +251,45 @@ describe(CabinetRepository.name, () => {
|
|||
expect(result).toEqual(deleted);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discard', () => {
|
||||
it('zeros quantity, marks depleted and deleted', async () => {
|
||||
const discarded = { _id: 'ci-1', quantity: 0, status: 'depleted', isDeleted: true };
|
||||
mockFindOneAndUpdate.mockResolvedValue(discarded);
|
||||
|
||||
const result = await repo.discard('ci-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(discarded);
|
||||
});
|
||||
|
||||
it('returns null when item not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.discard('ci-missing', 'hh1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByMedicineForFEFO', () => {
|
||||
it('returns active items sorted by expiration date', async () => {
|
||||
const items = [
|
||||
{ _id: 'ci-1', quantity: 10, expirationDate: new Date('2025-06-01') },
|
||||
{ _id: 'ci-2', quantity: 20, expirationDate: new Date('2025-12-01') },
|
||||
];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
|
||||
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('returns empty array when no active items exist', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findActiveByMedicineForFEFO('hh1', 'med-1');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,4 +141,25 @@ export class CabinetRepository {
|
|||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async discard(id: string, householdId: string) {
|
||||
return CabinetItemModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { quantity: 0, status: 'depleted', isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async findActiveByMedicineForFEFO(householdId: string, medicineId: string) {
|
||||
return CabinetItemModel.find({
|
||||
householdId,
|
||||
medicineId,
|
||||
isDeleted: false,
|
||||
status: 'active',
|
||||
quantity: { $gt: 0 },
|
||||
})
|
||||
.sort({ expirationDate: 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const {
|
|||
mockFindExpiringSoon,
|
||||
mockSoftDelete,
|
||||
mockCountByMedicineId,
|
||||
mockDiscard,
|
||||
mockFindActiveByMedicineForFEFO,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
|
|
@ -39,6 +41,8 @@ const {
|
|||
mockFindExpiringSoon: vi.fn(),
|
||||
mockSoftDelete: vi.fn(),
|
||||
mockCountByMedicineId: vi.fn(),
|
||||
mockDiscard: vi.fn(),
|
||||
mockFindActiveByMedicineForFEFO: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./cabinet.repository.js', () => ({
|
||||
|
|
@ -52,6 +56,8 @@ vi.mock('./cabinet.repository.js', () => ({
|
|||
findExpiringSoon = mockFindExpiringSoon;
|
||||
softDelete = mockSoftDelete;
|
||||
countByMedicineId = mockCountByMedicineId;
|
||||
discard = mockDiscard;
|
||||
findActiveByMedicineForFEFO = mockFindActiveByMedicineForFEFO;
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -105,6 +111,28 @@ vi.mock('../medicines/medicines.service.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../cabinet-events/cabinet-events.repository.js', () => ({
|
||||
CabinetEventsRepository: class {
|
||||
create = vi.fn();
|
||||
createMany = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
findByCabinetItem = vi.fn();
|
||||
getSpendingSummary = vi.fn();
|
||||
getAvgUnitPriceByMedicine = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../cabinet-events/cabinet-events.service.js', () => ({
|
||||
CabinetEventsService: class {
|
||||
logEvent = vi.fn();
|
||||
logEvents = vi.fn();
|
||||
listEvents = vi.fn();
|
||||
getEventsByItem = vi.fn();
|
||||
getSpendingSummary = vi.fn();
|
||||
getAvgUnitPrices = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
|
|
@ -117,6 +145,7 @@ import householdPlugin from '../../plugins/household.plugin.js';
|
|||
import usersRoutes from '../users/users.routes.js';
|
||||
import medicinesRoutes from '../medicines/medicines.routes.js';
|
||||
import medicineProductsRoutes from '../medicine-products/medicine-products.routes.js';
|
||||
import cabinetEventsRoutes from '../cabinet-events/cabinet-events.routes.js';
|
||||
import cabinetRoutes from './cabinet.routes.js';
|
||||
|
||||
function makeFakeCabinetItem(overrides = {}) {
|
||||
|
|
@ -155,6 +184,7 @@ describe('cabinet.routes', () => {
|
|||
await instance.register(usersRoutes);
|
||||
await instance.register(medicinesRoutes);
|
||||
await instance.register(medicineProductsRoutes);
|
||||
await instance.register(cabinetEventsRoutes);
|
||||
await instance.register(cabinetRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
|
|
@ -242,6 +272,40 @@ describe('cabinet.routes', () => {
|
|||
expect(body.data[0].expirationDate).toBe('2026-12-31T00:00:00.000Z');
|
||||
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('includes optional purchase and store fields when present', async () => {
|
||||
const item = makeFakeCabinetItem({
|
||||
concentration: 5.0,
|
||||
concentrationUnit: 'mg/mL',
|
||||
purchaseDate: new Date('2024-03-01T00:00:00.000Z'),
|
||||
unitPrice: 1.5,
|
||||
totalPrice: 45.0,
|
||||
currency: 'USD',
|
||||
storeId: 'store-1',
|
||||
storeName: 'Pharmacy Plus',
|
||||
});
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [item],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/cabinet',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].concentration).toBe(5.0);
|
||||
expect(body.data[0].concentrationUnit).toBe('mg/mL');
|
||||
expect(body.data[0].purchaseDate).toBe('2024-03-01T00:00:00.000Z');
|
||||
expect(body.data[0].unitPrice).toBe(1.5);
|
||||
expect(body.data[0].totalPrice).toBe(45.0);
|
||||
expect(body.data[0].currency).toBe('USD');
|
||||
expect(body.data[0].storeId).toBe('store-1');
|
||||
expect(body.data[0].storeName).toBe('Pharmacy Plus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/cabinet/summary', () => {
|
||||
|
|
@ -379,4 +443,32 @@ describe('cabinet.routes', () => {
|
|||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/cabinet/:id/discard', () => {
|
||||
it('discards a cabinet item', async () => {
|
||||
mockFindById.mockResolvedValue(makeFakeCabinetItem({ quantity: 20 }));
|
||||
mockDiscard.mockResolvedValue(makeFakeCabinetItem({ quantity: 0, isDeleted: true }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
|
||||
headers: authHeaders,
|
||||
payload: { reason: 'expired' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().quantity).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 400 for missing reason', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/cabinet/ci-1/discard',
|
||||
headers: authHeaders,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
CabinetItemResponseSchema,
|
||||
CabinetItemListResponseSchema,
|
||||
CabinetSummaryResponseSchema,
|
||||
DiscardCabinetItemSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { CabinetRepository } from './cabinet.repository.js';
|
||||
import { CabinetService } from './cabinet.service.js';
|
||||
|
|
@ -30,6 +31,12 @@ type AnyCabinetDoc = {
|
|||
unit: string;
|
||||
expirationDate?: Date | string | null;
|
||||
status: string;
|
||||
purchaseDate?: Date | string | null;
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
notes?: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
|
|
@ -70,6 +77,12 @@ function toCabinetItemResponse(doc: AnyCabinetDoc): z.infer<typeof CabinetItemRe
|
|||
unit: doc.unit,
|
||||
...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}),
|
||||
status: doc.status,
|
||||
...(doc.purchaseDate ? { purchaseDate: toOptIso(doc.purchaseDate) } : {}),
|
||||
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
|
||||
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
|
||||
...(doc.currency ? { currency: doc.currency } : {}),
|
||||
...(doc.storeId ? { storeId: doc.storeId } : {}),
|
||||
...(doc.storeName ? { storeName: doc.storeName } : {}),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
|
|
@ -198,6 +211,7 @@ export default fp(
|
|||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
|
|
@ -218,6 +232,8 @@ export default fp(
|
|||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body.delta,
|
||||
request.user.keycloakId,
|
||||
request.body.reason,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
|
|
@ -233,10 +249,32 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/cabinet/:id/discard — discard item
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/cabinet/:id/discard',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: DiscardCabinetItemSchema,
|
||||
response: { 200: CabinetItemResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const item = await service.discard(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body.reason,
|
||||
request.body.notes,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'cabinet-routes',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CabinetService } from './cabinet.service.js';
|
||||
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
|
||||
|
||||
describe(CabinetService.name, () => {
|
||||
const mockCabinetRepo = {
|
||||
|
|
@ -12,6 +13,8 @@ describe(CabinetService.name, () => {
|
|||
findExpiringSoon: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
countByMedicineId: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
findActiveByMedicineForFEFO: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMedicinesRepo = {
|
||||
|
|
@ -32,6 +35,15 @@ describe(CabinetService.name, () => {
|
|||
countByMedicineId: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCabinetEventsService = {
|
||||
logEvent: vi.fn(),
|
||||
logEvents: vi.fn(),
|
||||
listEvents: vi.fn(),
|
||||
getEventsByItem: vi.fn(),
|
||||
getSpendingSummary: vi.fn(),
|
||||
getAvgUnitPrices: vi.fn(),
|
||||
};
|
||||
|
||||
let service: CabinetService;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -40,6 +52,7 @@ describe(CabinetService.name, () => {
|
|||
cabinetRepository: mockCabinetRepo as never,
|
||||
medicinesRepository: mockMedicinesRepo as never,
|
||||
medicineProductsRepository: mockProductsRepo as never,
|
||||
cabinetEventsService: mockCabinetEventsService as never,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -150,6 +163,33 @@ describe(CabinetService.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('logs PURCHASED event after creation', async () => {
|
||||
mockMedicinesRepo.findById.mockResolvedValue({
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
});
|
||||
mockCabinetRepo.create.mockResolvedValue({ _id: 'ci-1' });
|
||||
|
||||
await service.addItem(createInput, 'hh1', 'user-1');
|
||||
|
||||
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
eventType: CabinetEventType.PURCHASED,
|
||||
quantity: 30,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: 30,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when medicine not found', async () => {
|
||||
mockMedicinesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
|
|
@ -199,28 +239,53 @@ describe(CabinetService.name, () => {
|
|||
|
||||
describe('update', () => {
|
||||
it('updates and returns item', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
const updated = { _id: 'ci-1', quantity: 25 };
|
||||
mockCabinetRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('ci-1', 'hh1', { quantity: 25 });
|
||||
const result = await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('logs ADJUSTED event when quantity changes', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
|
||||
|
||||
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
|
||||
|
||||
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: CabinetEventType.ADJUSTED,
|
||||
quantity: -5,
|
||||
quantityBefore: 30,
|
||||
quantityAfter: 25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log event when quantity unchanged', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
||||
|
||||
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
|
||||
|
||||
expect(mockCabinetEventsService.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when item does not exist', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('ci-missing', 'hh1', { quantity: 25 })).rejects.toThrow(
|
||||
await expect(service.update('ci-missing', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('ci-1', 'hh1', { quantity: 25 })).rejects.toThrow(
|
||||
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
|
|
@ -228,17 +293,34 @@ describe(CabinetService.name, () => {
|
|||
|
||||
describe('adjustQuantity', () => {
|
||||
it('adjusts quantity and returns item', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
const updated = { _id: 'ci-1', quantity: 27 };
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.adjustQuantity('ci-1', 'hh1', -3);
|
||||
const result = await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1');
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('logs ADJUSTED event', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
|
||||
|
||||
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
|
||||
|
||||
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: CabinetEventType.ADJUSTED,
|
||||
quantity: -3,
|
||||
quantityBefore: 30,
|
||||
quantityAfter: 27,
|
||||
reason: 'took some',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestError when delta is 0', async () => {
|
||||
await expect(service.adjustQuantity('ci-1', 'hh1', 0)).rejects.toThrow(
|
||||
await expect(service.adjustQuantity('ci-1', 'hh1', 0, 'user-1')).rejects.toThrow(
|
||||
'Delta must be non-zero',
|
||||
);
|
||||
});
|
||||
|
|
@ -246,16 +328,16 @@ describe(CabinetService.name, () => {
|
|||
it('throws NotFoundError when item does not exist', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.adjustQuantity('ci-missing', 'hh1', 5)).rejects.toThrow(
|
||||
await expect(service.adjustQuantity('ci-missing', 'hh1', 5, 'user-1')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when adjust returns null', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
|
||||
|
||||
await expect(service.adjustQuantity('ci-1', 'hh1', 5)).rejects.toThrow(
|
||||
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
|
|
@ -274,26 +356,81 @@ describe(CabinetService.name, () => {
|
|||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft deletes item', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
|
||||
it('soft deletes item and logs DELETED event', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
|
||||
|
||||
const result = await service.delete('ci-1', 'hh1');
|
||||
const result = await service.delete('ci-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result.isDeleted).toBe(true);
|
||||
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: CabinetEventType.DELETED,
|
||||
quantity: -10,
|
||||
quantityBefore: 10,
|
||||
quantityAfter: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when item does not exist', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('ci-missing', 'hh1')).rejects.toThrow('Cabinet item not found');
|
||||
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when softDelete returns null', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1' });
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 5, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.softDelete.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('ci-1', 'hh1')).rejects.toThrow('Cabinet item not found');
|
||||
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('discard', () => {
|
||||
it('discards item and logs DISCARDED event', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 20, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
|
||||
|
||||
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
|
||||
|
||||
expect(result.quantity).toBe(0);
|
||||
expect(result.isDeleted).toBe(true);
|
||||
expect(mockCabinetEventsService.logEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: CabinetEventType.DISCARDED,
|
||||
quantity: -20,
|
||||
quantityBefore: 20,
|
||||
quantityAfter: 0,
|
||||
reason: 'expired',
|
||||
notes: 'smelled off',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestError when quantity is zero', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 0, medicineId: 'med-1', medicineName: 'Test' });
|
||||
|
||||
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
||||
'Cannot discard an item with zero quantity',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when item does not exist', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.discard('ci-missing', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when discard returns null', async () => {
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
|
||||
mockCabinetRepo.discard.mockResolvedValue(null);
|
||||
|
||||
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
|
||||
'Cabinet item not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { CabinetRepository } from './cabinet.repository.js';
|
||||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
import {
|
||||
CabinetEventType,
|
||||
CabinetEventSourceType,
|
||||
} from '@meshitrack/shared';
|
||||
import type {
|
||||
CreateCabinetItemInput,
|
||||
UpdateCabinetItemInput,
|
||||
|
|
@ -12,17 +17,25 @@ interface Deps {
|
|||
cabinetRepository: CabinetRepository;
|
||||
medicinesRepository: MedicinesRepository;
|
||||
medicineProductsRepository: MedicineProductsRepository;
|
||||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
export class CabinetService {
|
||||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly medicinesRepository: MedicinesRepository;
|
||||
private readonly medicineProductsRepository: MedicineProductsRepository;
|
||||
private readonly cabinetEventsService: CabinetEventsService;
|
||||
|
||||
public constructor({ cabinetRepository, medicinesRepository, medicineProductsRepository }: Deps) {
|
||||
public constructor({
|
||||
cabinetRepository,
|
||||
medicinesRepository,
|
||||
medicineProductsRepository,
|
||||
cabinetEventsService,
|
||||
}: Deps) {
|
||||
this.cabinetRepository = cabinetRepository;
|
||||
this.medicinesRepository = medicinesRepository;
|
||||
this.medicineProductsRepository = medicineProductsRepository;
|
||||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: CabinetQueryInput) {
|
||||
|
|
@ -74,7 +87,7 @@ export class CabinetService {
|
|||
concentrationUnit = product.concentrationUnit ?? undefined;
|
||||
}
|
||||
|
||||
return this.cabinetRepository.create(
|
||||
const item = await this.cabinetRepository.create(
|
||||
{
|
||||
...data,
|
||||
medicineName: medicine.name,
|
||||
|
|
@ -88,23 +101,80 @@ export class CabinetService {
|
|||
householdId,
|
||||
createdBy,
|
||||
);
|
||||
|
||||
await this.cabinetEventsService.logEvent({
|
||||
householdId,
|
||||
userId: createdBy,
|
||||
cabinetItemId: item._id.toString(),
|
||||
medicineId: data.medicineId,
|
||||
medicineName: medicine.name,
|
||||
eventType: CabinetEventType.PURCHASED,
|
||||
quantity: data.quantity,
|
||||
quantityBefore: 0,
|
||||
quantityAfter: data.quantity,
|
||||
unitPrice: data.unitPrice,
|
||||
totalPrice: data.totalPrice,
|
||||
currency: data.currency,
|
||||
storeId: data.storeId,
|
||||
storeName: data.storeName,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) {
|
||||
await this.getById(id, householdId);
|
||||
public async update(id: string, householdId: string, data: UpdateCabinetItemInput, userId: string) {
|
||||
const existing = await this.getById(id, householdId);
|
||||
const updated = await this.cabinetRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Cabinet item not found');
|
||||
|
||||
if (data.quantity !== undefined && data.quantity !== existing.quantity) {
|
||||
await this.cabinetEventsService.logEvent({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: id,
|
||||
medicineId: existing.medicineId,
|
||||
medicineName: existing.medicineName,
|
||||
eventType: CabinetEventType.ADJUSTED,
|
||||
quantity: data.quantity - existing.quantity,
|
||||
quantityBefore: existing.quantity,
|
||||
quantityAfter: data.quantity,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async adjustQuantity(id: string, householdId: string, delta: number) {
|
||||
public async adjustQuantity(
|
||||
id: string,
|
||||
householdId: string,
|
||||
delta: number,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
if (delta === 0) {
|
||||
throw new BadRequestError('Delta must be non-zero');
|
||||
}
|
||||
|
||||
await this.getById(id, householdId);
|
||||
const existing = await this.getById(id, householdId);
|
||||
const updated = await this.cabinetRepository.adjustQuantity(id, householdId, delta);
|
||||
if (!updated) throw new NotFoundError('Cabinet item not found');
|
||||
|
||||
await this.cabinetEventsService.logEvent({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: id,
|
||||
medicineId: existing.medicineId,
|
||||
medicineName: existing.medicineName,
|
||||
eventType: CabinetEventType.ADJUSTED,
|
||||
quantity: delta,
|
||||
quantityBefore: existing.quantity,
|
||||
quantityAfter: updated.quantity,
|
||||
reason,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
|
@ -112,10 +182,51 @@ export class CabinetService {
|
|||
return this.cabinetRepository.findExpiringSoon(householdId, withinDays);
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
await this.getById(id, householdId);
|
||||
public async delete(id: string, householdId: string, userId: string) {
|
||||
const existing = await this.getById(id, householdId);
|
||||
const deleted = await this.cabinetRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Cabinet item not found');
|
||||
|
||||
await this.cabinetEventsService.logEvent({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: id,
|
||||
medicineId: existing.medicineId,
|
||||
medicineName: existing.medicineName,
|
||||
eventType: CabinetEventType.DELETED,
|
||||
quantity: -existing.quantity,
|
||||
quantityBefore: existing.quantity,
|
||||
quantityAfter: 0,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async discard(id: string, householdId: string, userId: string, reason: string, notes?: string) {
|
||||
const existing = await this.getById(id, householdId);
|
||||
if (existing.quantity === 0) {
|
||||
throw new BadRequestError('Cannot discard an item with zero quantity');
|
||||
}
|
||||
|
||||
const discarded = await this.cabinetRepository.discard(id, householdId);
|
||||
if (!discarded) throw new NotFoundError('Cabinet item not found');
|
||||
|
||||
await this.cabinetEventsService.logEvent({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: id,
|
||||
medicineId: existing.medicineId,
|
||||
medicineName: existing.medicineName,
|
||||
eventType: CabinetEventType.DISCARDED,
|
||||
quantity: -existing.quantity,
|
||||
quantityBefore: existing.quantity,
|
||||
quantityAfter: 0,
|
||||
reason,
|
||||
notes,
|
||||
sourceType: CabinetEventSourceType.MANUAL,
|
||||
});
|
||||
|
||||
return discarded;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
packages/api/src/modules/organizer/organizer.repository.test.ts
Normal file
183
packages/api/src/modules/organizer/organizer.repository.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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/organizer-fill.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 { OrganizerFillModel: FakeModel };
|
||||
});
|
||||
|
||||
import { OrganizerRepository } from './organizer.repository.js';
|
||||
|
||||
describe(OrganizerRepository.name, () => {
|
||||
let repo: OrganizerRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new OrganizerRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated items', async () => {
|
||||
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles cursor-based pagination', async () => {
|
||||
const items = [{ _id: 'fill-2', regimenName: 'Evening' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const cursor = Buffer.from('fill-1').toString('base64');
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `fill-${i}`, regimenName: `R${i}` }));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by regimenId', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', 'user-1', { regimenId: 'reg-1', limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by status', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', 'user-1', { status: 'completed' as never, limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns cursor as null when hasMore is false even with data', async () => {
|
||||
const items = [{ _id: 'fill-1', regimenName: 'Morning' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns fill by id and householdId', async () => {
|
||||
const fill = { _id: 'fill-1', householdId: 'hh1', regimenName: 'Morning' };
|
||||
mockFindOne.mockResolvedValue(fill);
|
||||
|
||||
const result = await repo.findById('fill-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(fill);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('fill-missing', 'hh1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns organizer fill', async () => {
|
||||
const data = {
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning',
|
||||
numberOfDays: 7,
|
||||
fillDate: new Date(),
|
||||
items: [],
|
||||
status: 'completed' as const,
|
||||
};
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(data as never);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('updates and returns fill with new status', async () => {
|
||||
const updated = { _id: 'fill-1', status: 'reversed' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.updateStatus('fill-1', 'hh1', 'reversed' as never);
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('returns null when fill not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.updateStatus('fill-missing', 'hh1', 'reversed' as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
77
packages/api/src/modules/organizer/organizer.repository.ts
Normal file
77
packages/api/src/modules/organizer/organizer.repository.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { OrganizerFillModel } from '../../schemas/organizer-fill.schema.js';
|
||||
import type { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
|
||||
interface OrganizerFillItemData {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
quantityTaken: number;
|
||||
wasShort: boolean;
|
||||
shortage: number;
|
||||
deductions: { cabinetItemId: string; quantityTaken: number }[];
|
||||
}
|
||||
|
||||
interface CreateOrganizerFillData {
|
||||
householdId: string;
|
||||
userId: string;
|
||||
regimenId: string;
|
||||
regimenName: string;
|
||||
numberOfDays: number;
|
||||
fillDate: Date;
|
||||
items: OrganizerFillItemData[];
|
||||
status: OrganizerFillStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface FindByHouseholdQuery {
|
||||
regimenId?: string;
|
||||
status?: OrganizerFillStatus;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class OrganizerRepository {
|
||||
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
|
||||
const filter: Record<string, unknown> = { householdId, userId };
|
||||
|
||||
if (query.regimenId) filter['regimenId'] = query.regimenId;
|
||||
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 OrganizerFillModel.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 OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
public async create(data: CreateOrganizerFillData) {
|
||||
const fill = new OrganizerFillModel(data);
|
||||
const saved = await fill.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async updateStatus(id: string, householdId: string, status: OrganizerFillStatus) {
|
||||
return OrganizerFillModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: { status } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
478
packages/api/src/modules/organizer/organizer.routes.test.ts
Normal file
478
packages/api/src/modules/organizer/organizer.routes.test.ts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
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';
|
||||
import { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
|
||||
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 { mockListFills, mockGetFillById, mockPreview, mockFill, mockUndoFill } = vi.hoisted(() => ({
|
||||
mockListFills: vi.fn(),
|
||||
mockGetFillById: vi.fn(),
|
||||
mockPreview: vi.fn(),
|
||||
mockFill: vi.fn(),
|
||||
mockUndoFill: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./organizer.repository.js', () => ({
|
||||
OrganizerRepository: class {
|
||||
create = vi.fn();
|
||||
update = vi.fn();
|
||||
findById = vi.fn();
|
||||
findByHousehold = vi.fn();
|
||||
updateStatus = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./organizer.service.js', () => ({
|
||||
OrganizerService: class {
|
||||
listFills = mockListFills;
|
||||
getFillById = mockGetFillById;
|
||||
preview = mockPreview;
|
||||
fill = mockFill;
|
||||
undoFill = mockUndoFill;
|
||||
},
|
||||
}));
|
||||
|
||||
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 organizerRoutes from './organizer.routes.js';
|
||||
|
||||
function makeFakeFill(overrides = {}) {
|
||||
return {
|
||||
_id: 'fill-1',
|
||||
householdId: 'hh1',
|
||||
userId: 'kc-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Daily Medications',
|
||||
numberOfDays: 7,
|
||||
fillDate: '2024-06-01T00:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 7,
|
||||
wasShort: false,
|
||||
shortage: 0,
|
||||
deductions: [
|
||||
{ cabinetItemId: 'ci-1', quantityTaken: 7 },
|
||||
],
|
||||
},
|
||||
],
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
notes: null,
|
||||
createdAt: '2024-06-01T00:00:00.000Z',
|
||||
updatedAt: '2024-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePreview(overrides = {}) {
|
||||
return {
|
||||
regimenName: 'Daily Medications',
|
||||
numberOfDays: 7,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityAvailable: 30,
|
||||
isShort: false,
|
||||
shortage: 0,
|
||||
cabinetBreakdown: [
|
||||
{
|
||||
cabinetItemId: 'ci-1',
|
||||
expirationDate: '2025-12-31T00:00:00.000Z',
|
||||
quantityToTake: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
canFillCompletely: true,
|
||||
hasShortages: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('organizer.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(organizerRoutes);
|
||||
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/organizer/fills', () => {
|
||||
it('returns paginated fill list', async () => {
|
||||
const fill = makeFakeFill();
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [fill],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].regimenName).toBe('Daily Medications');
|
||||
expect(body.data[0].status).toBe(OrganizerFillStatus.COMPLETED);
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('passes query parameters to service', async () => {
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills?regimenId=reg-1&status=completed&limit=10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockListFills).toHaveBeenCalledWith('hh1', 'kc-1', expect.objectContaining({
|
||||
regimenId: 'reg-1',
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
limit: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date serialization in fill response', async () => {
|
||||
const fill = makeFakeFill({
|
||||
_id: { toString: () => 'fill-obj' },
|
||||
fillDate: new Date('2024-06-01T00:00:00.000Z'),
|
||||
createdAt: { toISOString: () => '2024-06-01T00:00:00.000Z' },
|
||||
updatedAt: new Date('2024-06-02T00:00:00.000Z'),
|
||||
});
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [fill],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('fill-obj');
|
||||
expect(body.data[0].fillDate).toBe('2024-06-01T00:00:00.000Z');
|
||||
expect(body.data[0].createdAt).toBe('2024-06-01T00:00:00.000Z');
|
||||
expect(body.data[0].updatedAt).toBe('2024-06-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('omits notes from response when null', async () => {
|
||||
const fill = makeFakeFill({ notes: null });
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [fill],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes notes in response when present', async () => {
|
||||
const fill = makeFakeFill({ notes: 'Refilled before holiday' });
|
||||
mockListFills.mockResolvedValue({
|
||||
data: [fill],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].notes).toBe('Refilled before holiday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/organizer/fills/:id', () => {
|
||||
it('returns single fill by id', async () => {
|
||||
const fill = makeFakeFill();
|
||||
mockGetFillById.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills/fill-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('fill-1');
|
||||
expect(body.regimenId).toBe('reg-1');
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0].deductions).toHaveLength(1);
|
||||
expect(body.items[0].deductions[0].cabinetItemId).toBe('ci-1');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service correctly', async () => {
|
||||
const fill = makeFakeFill();
|
||||
mockGetFillById.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/organizer/fills/fill-42',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockGetFillById).toHaveBeenCalledWith('fill-42', 'hh1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/organizer/preview', () => {
|
||||
it('returns preview result with items and shortage info', async () => {
|
||||
const preview = makeFakePreview();
|
||||
mockPreview.mockResolvedValue(preview);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/preview',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-1', numberOfDays: 7 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.regimenName).toBe('Daily Medications');
|
||||
expect(body.canFillCompletely).toBe(true);
|
||||
expect(body.hasShortages).toBe(false);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0].medicineName).toBe('Metformin');
|
||||
expect(body.items[0].cabinetBreakdown).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes regimenId and numberOfDays to service', async () => {
|
||||
const preview = makeFakePreview();
|
||||
mockPreview.mockResolvedValue(preview);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/preview',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-99', numberOfDays: 14 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockPreview).toHaveBeenCalledWith('hh1', 'kc-1', 'reg-99', 14);
|
||||
});
|
||||
|
||||
it('returns preview with hasShortages=true and isShort items', async () => {
|
||||
const preview = makeFakePreview({
|
||||
canFillCompletely: false,
|
||||
hasShortages: true,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 14,
|
||||
quantityAvailable: 5,
|
||||
isShort: true,
|
||||
shortage: 9,
|
||||
cabinetBreakdown: [
|
||||
{
|
||||
cabinetItemId: 'ci-1',
|
||||
expirationDate: '2025-12-31T00:00:00.000Z',
|
||||
quantityToTake: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockPreview.mockResolvedValue(preview);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/preview',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-1', numberOfDays: 14 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.hasShortages).toBe(true);
|
||||
expect(body.canFillCompletely).toBe(false);
|
||||
expect(body.items[0].isShort).toBe(true);
|
||||
expect(body.items[0].shortage).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/organizer/fill', () => {
|
||||
it('creates fill and returns 201 with COMPLETED status', async () => {
|
||||
const fill = makeFakeFill({ status: OrganizerFillStatus.COMPLETED });
|
||||
mockFill.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fill',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('fill-1');
|
||||
expect(body.status).toBe(OrganizerFillStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('returns 201 with PARTIAL status when wasShort items exist', async () => {
|
||||
const fill = makeFakeFill({
|
||||
status: OrganizerFillStatus.PARTIAL,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 14,
|
||||
quantityTaken: 5,
|
||||
wasShort: true,
|
||||
shortage: 9,
|
||||
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 5 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockFill.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fill',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-1', numberOfDays: 14, allowPartial: true },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.status).toBe(OrganizerFillStatus.PARTIAL);
|
||||
expect(body.items[0].wasShort).toBe(true);
|
||||
expect(body.items[0].shortage).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 400 on invalid body (missing regimenId)', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fill',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { numberOfDays: 7, allowPartial: false },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('passes correct args (householdId, userId, body) to service', async () => {
|
||||
const fill = makeFakeFill();
|
||||
mockFill.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fill',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
payload: { regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(mockFill).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'kc-1',
|
||||
expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/organizer/fills/:id/undo', () => {
|
||||
it('reverses fill and returns 200 with REVERSED status', async () => {
|
||||
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
|
||||
mockUndoFill.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fills/fill-1/undo',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('fill-1');
|
||||
expect(body.status).toBe(OrganizerFillStatus.REVERSED);
|
||||
});
|
||||
|
||||
it('passes correct args to service (householdId first, then id, then userId)', async () => {
|
||||
const fill = makeFakeFill({ status: OrganizerFillStatus.REVERSED });
|
||||
mockUndoFill.mockResolvedValue(fill);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/organizer/fills/fill-42/undo',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockUndoFill).toHaveBeenCalledWith('hh1', 'fill-42', 'kc-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
203
packages/api/src/modules/organizer/organizer.routes.ts
Normal file
203
packages/api/src/modules/organizer/organizer.routes.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
OrganizerPreviewSchema,
|
||||
OrganizerFillSchema,
|
||||
OrganizerFillQuerySchema,
|
||||
OrganizerPreviewResponseSchema,
|
||||
OrganizerFillResponseSchema,
|
||||
OrganizerFillListResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { OrganizerRepository } from './organizer.repository.js';
|
||||
import { OrganizerService } from './organizer.service.js';
|
||||
|
||||
type AnyFillDeduction = {
|
||||
cabinetItemId: string;
|
||||
quantityTaken: number;
|
||||
};
|
||||
|
||||
type AnyFillItem = {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
quantityTaken: number;
|
||||
wasShort: boolean;
|
||||
shortage: number;
|
||||
deductions: AnyFillDeduction[];
|
||||
};
|
||||
|
||||
type AnyFillDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
userId: string;
|
||||
regimenId: string;
|
||||
regimenName: string;
|
||||
numberOfDays: number;
|
||||
fillDate: string | Date | { toISOString: () => string };
|
||||
items: AnyFillItem[];
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
function toFillResponse(doc: AnyFillDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
regimenId: doc.regimenId,
|
||||
regimenName: doc.regimenName,
|
||||
numberOfDays: doc.numberOfDays,
|
||||
fillDate: toIso(doc.fillDate),
|
||||
items: doc.items.map((item) => ({
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantityNeeded: item.quantityNeeded,
|
||||
quantityTaken: item.quantityTaken,
|
||||
wasShort: item.wasShort,
|
||||
shortage: item.shortage,
|
||||
deductions: item.deductions.map((d) => ({
|
||||
cabinetItemId: d.cabinetItemId,
|
||||
quantityTaken: d.quantityTaken,
|
||||
})),
|
||||
})),
|
||||
status: doc.status,
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
organizerRepository: OrganizerRepository;
|
||||
organizerService: OrganizerService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
organizerRepository: asClass(OrganizerRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
organizerService: asClass(OrganizerService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/organizer/fills — list fill history
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/organizer/fills',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: OrganizerFillQuerySchema,
|
||||
response: { 200: OrganizerFillListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const result = await service.listFills(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toFillResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/organizer/fills/:id — get fill details
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/organizer/fills/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: OrganizerFillResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.getFillById(request.params.id, request.params.householdId);
|
||||
return reply.send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/organizer/preview — preview fill
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/organizer/preview',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: OrganizerPreviewSchema,
|
||||
response: { 200: OrganizerPreviewResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const preview = await service.preview(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body.regimenId,
|
||||
request.body.numberOfDays,
|
||||
);
|
||||
return reply.send(preview);
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/organizer/fill — execute fill
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/organizer/fill',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: OrganizerFillSchema,
|
||||
response: { 201: OrganizerFillResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.fill(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body,
|
||||
);
|
||||
return reply.status(201).send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/organizer/fills/:id/undo — reverse fill
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/organizer/fills/:id/undo',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: OrganizerFillResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.undoFill(
|
||||
request.params.householdId,
|
||||
request.params.id,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'organizer-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
647
packages/api/src/modules/organizer/organizer.service.test.ts
Normal file
647
packages/api/src/modules/organizer/organizer.service.test.ts
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
CabinetEventType,
|
||||
CabinetEventSourceType,
|
||||
OrganizerFillStatus,
|
||||
DosageFrequency,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
const { mockSession } = vi.hoisted(() => ({
|
||||
mockSession: {
|
||||
startTransaction: vi.fn(),
|
||||
commitTransaction: vi.fn(),
|
||||
abortTransaction: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('mongoose', () => {
|
||||
return { default: { startSession: vi.fn().mockResolvedValue(mockSession) } };
|
||||
});
|
||||
|
||||
import { OrganizerService } from './organizer.service.js';
|
||||
|
||||
describe(OrganizerService.name, () => {
|
||||
const mockOrganizerRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRegimensService = {
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getActiveByUser: vi.fn(),
|
||||
calculateBurnRates: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCabinetRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
getAggregateSummary: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
adjustQuantity: vi.fn(),
|
||||
findExpiringSoon: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
countByMedicineId: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
findActiveByMedicineForFEFO: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCabinetEventsService = {
|
||||
logEvent: vi.fn(),
|
||||
logEvents: vi.fn(),
|
||||
listEvents: vi.fn(),
|
||||
getEventsByItem: vi.fn(),
|
||||
getSpendingSummary: vi.fn(),
|
||||
getAvgUnitPrices: vi.fn(),
|
||||
};
|
||||
|
||||
let service: OrganizerService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new OrganizerService({
|
||||
organizerRepository: mockOrganizerRepo as never,
|
||||
regimensService: mockRegimensService as never,
|
||||
cabinetRepository: mockCabinetRepo as never,
|
||||
cabinetEventsService: mockCabinetEventsService as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFills', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockOrganizerRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFillById', () => {
|
||||
it('returns fill when found', async () => {
|
||||
const fill = { _id: 'fill-1', regimenName: 'Morning' };
|
||||
mockOrganizerRepo.findById.mockResolvedValue(fill);
|
||||
|
||||
const result = await service.getFillById('fill-1', 'hh1');
|
||||
|
||||
expect(result).toEqual(fill);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getFillById('fill-missing', 'hh1')).rejects.toThrow(
|
||||
'Organizer fill not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview', () => {
|
||||
const makeRegimen = (overrides = {}) => ({
|
||||
_id: 'reg-1',
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dosage: 1,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
customFrequencyPerDay: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns preview with no shortages when stock is sufficient', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.regimenName).toBe('Morning Routine');
|
||||
expect(result.numberOfDays).toBe(7);
|
||||
expect(result.canFillCompletely).toBe(true);
|
||||
expect(result.hasShortages).toBe(false);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].medicineId).toBe('med-1');
|
||||
expect(result.items[0].quantityNeeded).toBe(7);
|
||||
expect(result.items[0].quantityAvailable).toBe(30);
|
||||
expect(result.items[0].isShort).toBe(false);
|
||||
expect(result.items[0].shortage).toBe(0);
|
||||
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
|
||||
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
|
||||
expect(result.items[0].cabinetBreakdown[0].quantityBefore).toBe(30);
|
||||
});
|
||||
|
||||
it('returns preview with shortages when stock is insufficient', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.canFillCompletely).toBe(false);
|
||||
expect(result.hasShortages).toBe(true);
|
||||
expect(result.items[0].isShort).toBe(true);
|
||||
expect(result.items[0].shortage).toBe(4);
|
||||
expect(result.items[0].quantityAvailable).toBe(3);
|
||||
});
|
||||
|
||||
it('handles FEFO allocation across multiple cabinet items', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: new Date('2026-06-01') },
|
||||
{ _id: { toString: () => 'ci-2' }, quantity: 5, expirationDate: new Date('2026-12-01') },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.canFillCompletely).toBe(true);
|
||||
expect(result.items[0].cabinetBreakdown).toHaveLength(2);
|
||||
expect(result.items[0].cabinetBreakdown[0].cabinetItemId).toBe('ci-1');
|
||||
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(3);
|
||||
expect(result.items[0].cabinetBreakdown[1].cabinetItemId).toBe('ci-2');
|
||||
expect(result.items[0].cabinetBreakdown[1].quantityToTake).toBe(4);
|
||||
});
|
||||
|
||||
it('skips AS_NEEDED medications', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dosage: 1,
|
||||
frequency: DosageFrequency.AS_NEEDED,
|
||||
customFrequencyPerDay: null,
|
||||
},
|
||||
{
|
||||
medicineId: 'med-2',
|
||||
medicineName: 'Ibuprofen',
|
||||
dosage: 2,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
customFrequencyPerDay: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-2' }, quantity: 20, expirationDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].medicineId).toBe('med-2');
|
||||
});
|
||||
|
||||
it('throws BadRequestError when regimen is not active', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen({ isActive: false }));
|
||||
|
||||
await expect(service.preview('hh1', 'user-1', 'reg-1', 7)).rejects.toThrow(
|
||||
'Regimen is not active',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles null expirationDate in cabinet items', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 10, expirationDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.items[0].cabinetBreakdown[0].expirationDate).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty cabinet (no items available)', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
expect(result.canFillCompletely).toBe(false);
|
||||
expect(result.hasShortages).toBe(true);
|
||||
expect(result.items[0].quantityAvailable).toBe(0);
|
||||
expect(result.items[0].shortage).toBe(7);
|
||||
expect(result.items[0].cabinetBreakdown).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses customFrequencyPerDay when present', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Custom Med',
|
||||
dosage: 2,
|
||||
frequency: DosageFrequency.CUSTOM,
|
||||
customFrequencyPerDay: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 100, expirationDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
// dosage(2) * customFrequencyPerDay(3) * numberOfDays(7) = 42
|
||||
expect(result.items[0].quantityNeeded).toBe(42);
|
||||
});
|
||||
|
||||
it('stops taking from cabinet items once remaining is zero', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 7, expirationDate: null },
|
||||
{ _id: { toString: () => 'ci-2' }, quantity: 10, expirationDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.preview('hh1', 'user-1', 'reg-1', 7);
|
||||
|
||||
// Needs 7, first item has 7 -- second item should not be touched
|
||||
expect(result.items[0].cabinetBreakdown).toHaveLength(1);
|
||||
expect(result.items[0].cabinetBreakdown[0].quantityToTake).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fill', () => {
|
||||
const fillInput = {
|
||||
regimenId: 'reg-1',
|
||||
numberOfDays: 7,
|
||||
allowPartial: false,
|
||||
notes: 'Weekly fill',
|
||||
};
|
||||
|
||||
const makeRegimen = () => ({
|
||||
_id: 'reg-1',
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dosage: 1,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
customFrequencyPerDay: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('executes fill successfully with no shortages', async () => {
|
||||
// preview dependencies
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: new Date('2027-01-01') },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
regimenId: 'reg-1',
|
||||
regimenName: 'Morning Routine',
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
const result = await service.fill('hh1', 'user-1', fillInput);
|
||||
|
||||
expect(result.status).toBe(OrganizerFillStatus.COMPLETED);
|
||||
expect(mockSession.startTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.commitTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.endSession).toHaveBeenCalled();
|
||||
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', -7);
|
||||
expect(mockCabinetEventsService.logEvents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestError when shortages exist and allowPartial is false', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
|
||||
]);
|
||||
|
||||
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow(
|
||||
'Not enough stock to fill completely',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows partial fill when allowPartial is true', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 3, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 0 });
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
status: OrganizerFillStatus.PARTIAL,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
|
||||
|
||||
expect(result.status).toBe(OrganizerFillStatus.PARTIAL);
|
||||
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: OrganizerFillStatus.PARTIAL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates CONSUMED events for each deduction', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.fill('hh1', 'user-1', fillInput);
|
||||
|
||||
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
eventType: CabinetEventType.CONSUMED,
|
||||
quantity: -7,
|
||||
quantityBefore: 30,
|
||||
quantityAfter: 23,
|
||||
sourceType: CabinetEventSourceType.ORGANIZER_FILL,
|
||||
sourceId: 'fill-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles adjustQuantity returning null (skips deduction)', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
status: OrganizerFillStatus.PARTIAL,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
const result = await service.fill('hh1', 'user-1', { ...fillInput, allowPartial: true });
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
// No events logged since adjustQuantity returned null
|
||||
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('aborts transaction and rethrows on error', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
|
||||
|
||||
await expect(service.fill('hh1', 'user-1', fillInput)).rejects.toThrow('DB failure');
|
||||
|
||||
expect(mockSession.abortTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates fill with notes when provided', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.fill('hh1', 'user-1', fillInput);
|
||||
|
||||
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notes: 'Weekly fill',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets COMPLETED status when no items have shortages', async () => {
|
||||
mockRegimensService.getById.mockResolvedValue(makeRegimen());
|
||||
mockCabinetRepo.findActiveByMedicineForFEFO.mockResolvedValue([
|
||||
{ _id: { toString: () => 'ci-1' }, quantity: 30, expirationDate: null },
|
||||
]);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockOrganizerRepo.create.mockResolvedValue({
|
||||
_id: { toString: () => 'fill-1' },
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.fill('hh1', 'user-1', fillInput);
|
||||
|
||||
expect(mockOrganizerRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undoFill', () => {
|
||||
const makeFill = (overrides = {}) => ({
|
||||
_id: 'fill-1',
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
status: OrganizerFillStatus.COMPLETED,
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 7,
|
||||
wasShort: false,
|
||||
shortage: 0,
|
||||
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('reverses fill and restores quantities', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
||||
mockOrganizerRepo.updateStatus.mockResolvedValue({
|
||||
...makeFill(),
|
||||
status: OrganizerFillStatus.REVERSED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
const result = await service.undoFill('hh1', 'fill-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe(OrganizerFillStatus.REVERSED);
|
||||
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledWith('ci-1', 'hh1', 7);
|
||||
expect(mockOrganizerRepo.updateStatus).toHaveBeenCalledWith(
|
||||
'fill-1',
|
||||
'hh1',
|
||||
OrganizerFillStatus.REVERSED,
|
||||
);
|
||||
expect(mockSession.startTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.commitTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates RESTORED events for each deduction', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
|
||||
mockOrganizerRepo.updateStatus.mockResolvedValue({
|
||||
...makeFill(),
|
||||
status: OrganizerFillStatus.REVERSED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.undoFill('hh1', 'fill-1', 'user-1');
|
||||
|
||||
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
householdId: 'hh1',
|
||||
userId: 'user-1',
|
||||
cabinetItemId: 'ci-1',
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
eventType: CabinetEventType.RESTORED,
|
||||
quantity: 7,
|
||||
quantityBefore: 23,
|
||||
quantityAfter: 30,
|
||||
sourceType: CabinetEventSourceType.ORGANIZER_UNDO,
|
||||
sourceId: 'fill-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestError when fill is already reversed', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(
|
||||
makeFill({ status: OrganizerFillStatus.REVERSED }),
|
||||
);
|
||||
|
||||
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
|
||||
'Fill has already been reversed',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when fill does not exist', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.undoFill('hh1', 'fill-missing', 'user-1')).rejects.toThrow(
|
||||
'Organizer fill not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts transaction and rethrows on error', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
|
||||
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 23 });
|
||||
mockCabinetRepo.adjustQuantity.mockRejectedValue(new Error('DB failure'));
|
||||
|
||||
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow('DB failure');
|
||||
|
||||
expect(mockSession.abortTransaction).toHaveBeenCalled();
|
||||
expect(mockSession.endSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles cabinet item not found when restoring (uses 0 as quantityBefore)', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(makeFill());
|
||||
mockCabinetRepo.findById.mockResolvedValue(null);
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 7 });
|
||||
mockOrganizerRepo.updateStatus.mockResolvedValue({
|
||||
...makeFill(),
|
||||
status: OrganizerFillStatus.REVERSED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.undoFill('hh1', 'fill-1', 'user-1');
|
||||
|
||||
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
|
||||
expect(events[0].quantityBefore).toBe(0);
|
||||
expect(events[0].quantityAfter).toBe(7);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when updateStatus returns null', async () => {
|
||||
mockOrganizerRepo.findById.mockResolvedValue(makeFill({ items: [] }));
|
||||
mockOrganizerRepo.updateStatus.mockResolvedValue(null);
|
||||
|
||||
await expect(service.undoFill('hh1', 'fill-1', 'user-1')).rejects.toThrow(
|
||||
'Organizer fill not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores multiple deductions across items', async () => {
|
||||
const fill = makeFill({
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantityNeeded: 7,
|
||||
quantityTaken: 7,
|
||||
wasShort: false,
|
||||
shortage: 0,
|
||||
deductions: [
|
||||
{ cabinetItemId: 'ci-1', quantityTaken: 4 },
|
||||
{ cabinetItemId: 'ci-2', quantityTaken: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
medicineId: 'med-2',
|
||||
medicineName: 'Aspirin',
|
||||
quantityNeeded: 14,
|
||||
quantityTaken: 14,
|
||||
wasShort: false,
|
||||
shortage: 0,
|
||||
deductions: [{ cabinetItemId: 'ci-3', quantityTaken: 14 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockOrganizerRepo.findById.mockResolvedValue(fill);
|
||||
mockCabinetRepo.findById.mockResolvedValue({ quantity: 10 });
|
||||
mockCabinetRepo.adjustQuantity.mockResolvedValue({ quantity: 20 });
|
||||
mockOrganizerRepo.updateStatus.mockResolvedValue({
|
||||
...fill,
|
||||
status: OrganizerFillStatus.REVERSED,
|
||||
});
|
||||
mockCabinetEventsService.logEvents.mockResolvedValue([]);
|
||||
|
||||
await service.undoFill('hh1', 'fill-1', 'user-1');
|
||||
|
||||
expect(mockCabinetRepo.adjustQuantity).toHaveBeenCalledTimes(3);
|
||||
expect(mockCabinetRepo.findById).toHaveBeenCalledTimes(3);
|
||||
const events = mockCabinetEventsService.logEvents.mock.calls[0][0];
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
290
packages/api/src/modules/organizer/organizer.service.ts
Normal file
290
packages/api/src/modules/organizer/organizer.service.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import mongoose from 'mongoose';
|
||||
import type { OrganizerRepository } from './organizer.repository.js';
|
||||
import type { RegimensService } from '../regimens/regimens.service.js';
|
||||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
import type { OrganizerFillInput, OrganizerFillQueryInput } from '@meshitrack/shared';
|
||||
import {
|
||||
CabinetEventType,
|
||||
CabinetEventSourceType,
|
||||
OrganizerFillStatus,
|
||||
DosageFrequency,
|
||||
calculateQuantityNeeded,
|
||||
} from '@meshitrack/shared';
|
||||
import type { CreateCabinetEventData } from '../cabinet-events/cabinet-events.repository.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
organizerRepository: OrganizerRepository;
|
||||
regimensService: RegimensService;
|
||||
cabinetRepository: CabinetRepository;
|
||||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
interface PreviewDeduction {
|
||||
cabinetItemId: string;
|
||||
expirationDate: string | null;
|
||||
quantityToTake: number;
|
||||
quantityBefore: number;
|
||||
}
|
||||
|
||||
interface PreviewItem {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
quantityAvailable: number;
|
||||
isShort: boolean;
|
||||
shortage: number;
|
||||
cabinetBreakdown: PreviewDeduction[];
|
||||
}
|
||||
|
||||
export class OrganizerService {
|
||||
private readonly organizerRepository: OrganizerRepository;
|
||||
private readonly regimensService: RegimensService;
|
||||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly cabinetEventsService: CabinetEventsService;
|
||||
|
||||
public constructor({
|
||||
organizerRepository,
|
||||
regimensService,
|
||||
cabinetRepository,
|
||||
cabinetEventsService,
|
||||
}: Deps) {
|
||||
this.organizerRepository = organizerRepository;
|
||||
this.regimensService = regimensService;
|
||||
this.cabinetRepository = cabinetRepository;
|
||||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async listFills(householdId: string, userId: string, query: OrganizerFillQueryInput) {
|
||||
return this.organizerRepository.findByHousehold(householdId, userId, query);
|
||||
}
|
||||
|
||||
public async getFillById(id: string, householdId: string) {
|
||||
const fill = await this.organizerRepository.findById(id, householdId);
|
||||
if (!fill) throw new NotFoundError('Organizer fill not found');
|
||||
return fill;
|
||||
}
|
||||
|
||||
public async preview(householdId: string, userId: string, regimenId: string, numberOfDays: number) {
|
||||
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
|
||||
if (!regimen.isActive) {
|
||||
throw new BadRequestError('Regimen is not active');
|
||||
}
|
||||
|
||||
const items: PreviewItem[] = [];
|
||||
let hasShortages = false;
|
||||
|
||||
for (const med of regimen.medications) {
|
||||
if (med.frequency === DosageFrequency.AS_NEEDED) continue;
|
||||
|
||||
const quantityNeeded = calculateQuantityNeeded(
|
||||
med.dosage,
|
||||
med.frequency as DosageFrequency,
|
||||
numberOfDays,
|
||||
med.customFrequencyPerDay ?? undefined,
|
||||
);
|
||||
|
||||
// Get cabinet items for FEFO allocation
|
||||
const cabinetItems = await this.cabinetRepository.findActiveByMedicineForFEFO(
|
||||
householdId,
|
||||
med.medicineId,
|
||||
);
|
||||
|
||||
let quantityAvailable = 0;
|
||||
const breakdown: PreviewDeduction[] = [];
|
||||
let remaining = quantityNeeded;
|
||||
|
||||
for (const ci of cabinetItems) {
|
||||
if (remaining <= 0) break;
|
||||
const toTake = Math.min(remaining, ci.quantity);
|
||||
quantityAvailable += ci.quantity;
|
||||
breakdown.push({
|
||||
cabinetItemId: ci._id.toString(),
|
||||
expirationDate: ci.expirationDate ? ci.expirationDate.toISOString() : null,
|
||||
quantityToTake: toTake,
|
||||
quantityBefore: ci.quantity,
|
||||
});
|
||||
remaining -= toTake;
|
||||
}
|
||||
|
||||
const isShort = remaining > 0;
|
||||
if (isShort) hasShortages = true;
|
||||
|
||||
items.push({
|
||||
medicineId: med.medicineId,
|
||||
medicineName: med.medicineName,
|
||||
quantityNeeded,
|
||||
quantityAvailable,
|
||||
isShort,
|
||||
shortage: Math.max(0, remaining),
|
||||
cabinetBreakdown: breakdown,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
regimenName: regimen.name,
|
||||
numberOfDays,
|
||||
items,
|
||||
canFillCompletely: !hasShortages,
|
||||
hasShortages,
|
||||
};
|
||||
}
|
||||
|
||||
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
|
||||
const previewResult = await this.preview(householdId, userId, input.regimenId, input.numberOfDays);
|
||||
|
||||
if (!input.allowPartial && previewResult.hasShortages) {
|
||||
throw new BadRequestError(
|
||||
'Not enough stock to fill completely. Use allowPartial=true to allow partial fills.',
|
||||
);
|
||||
}
|
||||
|
||||
const regimen = await this.regimensService.getById(input.regimenId, householdId, userId);
|
||||
|
||||
// Execute deductions within a transaction
|
||||
const session = await mongoose.startSession();
|
||||
const events: CreateCabinetEventData[] = [];
|
||||
const fillItems = [];
|
||||
|
||||
try {
|
||||
session.startTransaction();
|
||||
|
||||
for (const previewItem of previewResult.items) {
|
||||
const deductions = [];
|
||||
let totalTaken = 0;
|
||||
|
||||
for (const bd of previewItem.cabinetBreakdown) {
|
||||
const updated = await this.cabinetRepository.adjustQuantity(
|
||||
bd.cabinetItemId,
|
||||
householdId,
|
||||
-bd.quantityToTake,
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
deductions.push({
|
||||
cabinetItemId: bd.cabinetItemId,
|
||||
quantityTaken: bd.quantityToTake,
|
||||
});
|
||||
totalTaken += bd.quantityToTake;
|
||||
|
||||
events.push({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: bd.cabinetItemId,
|
||||
medicineId: previewItem.medicineId,
|
||||
medicineName: previewItem.medicineName,
|
||||
eventType: CabinetEventType.CONSUMED,
|
||||
quantity: -bd.quantityToTake,
|
||||
quantityBefore: bd.quantityBefore,
|
||||
quantityAfter: bd.quantityBefore - bd.quantityToTake,
|
||||
sourceType: CabinetEventSourceType.ORGANIZER_FILL,
|
||||
sourceId: '', // Will be set after fill is created
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const wasShort = totalTaken < previewItem.quantityNeeded;
|
||||
fillItems.push({
|
||||
medicineId: previewItem.medicineId,
|
||||
medicineName: previewItem.medicineName,
|
||||
quantityNeeded: previewItem.quantityNeeded,
|
||||
quantityTaken: totalTaken,
|
||||
wasShort,
|
||||
shortage: previewItem.quantityNeeded - totalTaken,
|
||||
deductions,
|
||||
});
|
||||
}
|
||||
|
||||
const hasAnyShortage = fillItems.some((fi) => fi.wasShort);
|
||||
const fillRecord = await this.organizerRepository.create({
|
||||
householdId,
|
||||
userId,
|
||||
regimenId: input.regimenId,
|
||||
regimenName: regimen.name,
|
||||
numberOfDays: input.numberOfDays,
|
||||
fillDate: new Date(),
|
||||
items: fillItems,
|
||||
status: hasAnyShortage ? OrganizerFillStatus.PARTIAL : OrganizerFillStatus.COMPLETED,
|
||||
notes: input.notes,
|
||||
});
|
||||
|
||||
await session.commitTransaction();
|
||||
|
||||
// Set sourceId on events and log them
|
||||
const fillId = fillRecord._id.toString();
|
||||
for (const event of events) {
|
||||
event.sourceId = fillId;
|
||||
}
|
||||
await this.cabinetEventsService.logEvents(events);
|
||||
|
||||
return fillRecord;
|
||||
} catch (err) {
|
||||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
public async undoFill(householdId: string, fillId: string, userId: string) {
|
||||
const fill = await this.getFillById(fillId, householdId);
|
||||
|
||||
if (fill.status === OrganizerFillStatus.REVERSED) {
|
||||
throw new BadRequestError('Fill has already been reversed');
|
||||
}
|
||||
|
||||
const session = await mongoose.startSession();
|
||||
const events: CreateCabinetEventData[] = [];
|
||||
|
||||
try {
|
||||
session.startTransaction();
|
||||
|
||||
for (const item of fill.items) {
|
||||
for (const deduction of item.deductions) {
|
||||
// Get current quantity before restoring
|
||||
const current = await this.cabinetRepository.findById(deduction.cabinetItemId, householdId);
|
||||
const quantityBefore = current?.quantity ?? 0;
|
||||
|
||||
await this.cabinetRepository.adjustQuantity(
|
||||
deduction.cabinetItemId,
|
||||
householdId,
|
||||
deduction.quantityTaken,
|
||||
);
|
||||
|
||||
events.push({
|
||||
householdId,
|
||||
userId,
|
||||
cabinetItemId: deduction.cabinetItemId,
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
eventType: CabinetEventType.RESTORED,
|
||||
quantity: deduction.quantityTaken,
|
||||
quantityBefore,
|
||||
quantityAfter: quantityBefore + deduction.quantityTaken,
|
||||
sourceType: CabinetEventSourceType.ORGANIZER_UNDO,
|
||||
sourceId: fillId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.organizerRepository.updateStatus(
|
||||
fillId,
|
||||
householdId,
|
||||
OrganizerFillStatus.REVERSED,
|
||||
);
|
||||
|
||||
await session.commitTransaction();
|
||||
|
||||
await this.cabinetEventsService.logEvents(events);
|
||||
|
||||
if (!updated) throw new NotFoundError('Organizer fill not found');
|
||||
return updated;
|
||||
} catch (err) {
|
||||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
230
packages/api/src/modules/regimens/regimens.repository.test.ts
Normal file
230
packages/api/src/modules/regimens/regimens.repository.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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/regimen.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 { RegimenModel: FakeModel };
|
||||
});
|
||||
|
||||
import { RegimensRepository } from './regimens.repository.js';
|
||||
|
||||
describe(RegimensRepository.name, () => {
|
||||
let repo: RegimensRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new RegimensRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('returns paginated items', async () => {
|
||||
const items = [{ _id: 'reg-1', name: 'Morning' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles cursor-based pagination', async () => {
|
||||
const items = [{ _id: 'reg-2', name: 'Evening' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const cursor = Buffer.from('reg-1').toString('base64');
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { cursor, limit: 20 });
|
||||
|
||||
expect(result.data).toEqual(items);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasMore when more items exist', async () => {
|
||||
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `reg-${i}`, name: `Reg ${i}` }));
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.cursor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns null cursor when no data', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by isActive', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', 'user-1', { isActive: true, limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not add isActive filter when undefined', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns cursor only when hasMore is true', async () => {
|
||||
const items = [{ _id: 'reg-1', name: 'Morning' }];
|
||||
mockFind.mockResolvedValue(items);
|
||||
|
||||
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(result.pagination.cursor).toBeNull();
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns regimen by id, householdId, and userId', async () => {
|
||||
const regimen = { _id: 'reg-1', householdId: 'hh1', userId: 'user-1', name: 'Morning' };
|
||||
mockFindOne.mockResolvedValue(regimen);
|
||||
|
||||
const result = await repo.findById('reg-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(regimen);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFindOne.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('reg-missing', 'hh1', 'user-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByUser', () => {
|
||||
it('returns active regimens for user', async () => {
|
||||
const regimens = [
|
||||
{ _id: 'reg-1', isActive: true },
|
||||
{ _id: 'reg-2', isActive: true },
|
||||
];
|
||||
mockFind.mockResolvedValue(regimens);
|
||||
|
||||
const result = await repo.findActiveByUser('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(regimens);
|
||||
});
|
||||
|
||||
it('returns empty array when no active regimens', async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findActiveByUser('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns regimen', async () => {
|
||||
const data = {
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockSave.mockImplementation(function (this: { toObject: () => unknown }) {
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
|
||||
const result = await repo.create(data, 'hh1', 'user-1', 'user-1');
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates and returns regimen', async () => {
|
||||
const updated = { _id: 'reg-1', name: 'Updated' };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const result = await repo.update('reg-1', 'hh1', 'user-1', { name: 'Updated' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('returns null when regimen not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDelete', () => {
|
||||
it('soft deletes and returns regimen', async () => {
|
||||
const deleted = { _id: 'reg-1', isDeleted: true };
|
||||
mockFindOneAndUpdate.mockResolvedValue(deleted);
|
||||
|
||||
const result = await repo.softDelete('reg-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(deleted);
|
||||
});
|
||||
|
||||
it('returns null when regimen not found', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.softDelete('reg-missing', 'hh1', 'user-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/api/src/modules/regimens/regimens.repository.ts
Normal file
92
packages/api/src/modules/regimens/regimens.repository.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { RegimenModel } from '../../schemas/regimen.schema.js';
|
||||
|
||||
interface FindByHouseholdQuery {
|
||||
isActive?: boolean;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface RegimenMedicationData {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
dosage: number;
|
||||
dosageUnit: string;
|
||||
frequency: string;
|
||||
customFrequencyPerDay?: number;
|
||||
timeOfDay?: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
interface CreateRegimenData {
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
medications: RegimenMedicationData[];
|
||||
}
|
||||
|
||||
interface UpdateRegimenData {
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
medications?: RegimenMedicationData[];
|
||||
}
|
||||
|
||||
export class RegimensRepository {
|
||||
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
|
||||
const filter: Record<string, unknown> = { householdId, userId, isDeleted: false };
|
||||
|
||||
if (query.isActive !== undefined) filter['isActive'] = query.isActive;
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await RegimenModel.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, userId: string) {
|
||||
return RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false }).lean().exec();
|
||||
}
|
||||
|
||||
public async findActiveByUser(householdId: string, userId: string) {
|
||||
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async create(data: CreateRegimenData, householdId: string, userId: string, createdBy: string) {
|
||||
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
|
||||
const saved = await regimen.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenData) {
|
||||
return RegimenModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, userId, isDeleted: false },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string, userId: string) {
|
||||
return RegimenModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, userId, isDeleted: false },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
488
packages/api/src/modules/regimens/regimens.routes.test.ts
Normal file
488
packages/api/src/modules/regimens/regimens.routes.test.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
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';
|
||||
import { DosageFrequency, DosageUnit, StrengthUnit, MedicineForm } from '@meshitrack/shared';
|
||||
|
||||
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, mockDelete, mockCalculateBurnRates } = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockGetById: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockCalculateBurnRates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./regimens.repository.js', () => ({
|
||||
RegimensRepository: class {
|
||||
findByHousehold = vi.fn();
|
||||
findById = vi.fn();
|
||||
findActiveByUser = vi.fn();
|
||||
create = vi.fn();
|
||||
update = vi.fn();
|
||||
softDelete = vi.fn();
|
||||
delete = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./regimens.service.js', () => ({
|
||||
RegimensService: class {
|
||||
list = mockList;
|
||||
getById = mockGetById;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
getActiveByUser = vi.fn();
|
||||
calculateBurnRates = mockCalculateBurnRates;
|
||||
},
|
||||
}));
|
||||
|
||||
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 regimensRoutes from './regimens.routes.js';
|
||||
|
||||
function makeFakeRegimen(overrides = {}) {
|
||||
return {
|
||||
_id: 'reg-1',
|
||||
householdId: 'hh1',
|
||||
userId: 'kc-1',
|
||||
name: 'Daily Medications',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: StrengthUnit.MG,
|
||||
medicineForm: MedicineForm.TABLET,
|
||||
dosage: 1,
|
||||
dosageUnit: DosageUnit.TABLET,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
customFrequencyPerDay: null,
|
||||
timeOfDay: null,
|
||||
instructions: null,
|
||||
},
|
||||
],
|
||||
createdBy: 'kc-1',
|
||||
createdAt: '2024-06-01T00:00:00.000Z',
|
||||
updatedAt: '2024-06-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const validPostBody = {
|
||||
name: 'Daily Medications',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('regimens.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(regimensRoutes);
|
||||
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/regimens', () => {
|
||||
it('returns paginated list of regimens', async () => {
|
||||
const regimen = makeFakeRegimen();
|
||||
mockList.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].name).toBe('Daily Medications');
|
||||
expect(body.data[0].isActive).toBe(true);
|
||||
expect(body.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles ObjectId and Date objects in response', async () => {
|
||||
const regimen = makeFakeRegimen({
|
||||
_id: { toString: () => 'reg-obj' },
|
||||
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
|
||||
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
|
||||
});
|
||||
mockList.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0]._id).toBe('reg-obj');
|
||||
expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(body.data[0].updatedAt).toBe('2024-01-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('handles actual Date objects for createdAt/updatedAt', async () => {
|
||||
const regimen = makeFakeRegimen({
|
||||
createdAt: new Date('2024-03-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-03-02T00:00:00.000Z'),
|
||||
});
|
||||
mockList.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].createdAt).toBe('2024-03-01T00:00:00.000Z');
|
||||
expect(body.data[0].updatedAt).toBe('2024-03-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('includes optional medication fields when present', async () => {
|
||||
const regimen = makeFakeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: StrengthUnit.MG,
|
||||
medicineForm: MedicineForm.TABLET,
|
||||
dosage: 2,
|
||||
dosageUnit: DosageUnit.TABLET,
|
||||
frequency: DosageFrequency.CUSTOM,
|
||||
customFrequencyPerDay: 4,
|
||||
timeOfDay: 'morning',
|
||||
instructions: 'Take with food',
|
||||
},
|
||||
],
|
||||
});
|
||||
mockList.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
const med = body.data[0].medications[0];
|
||||
expect(med.customFrequencyPerDay).toBe(4);
|
||||
expect(med.timeOfDay).toBe('morning');
|
||||
expect(med.instructions).toBe('Take with food');
|
||||
});
|
||||
|
||||
it('omits null optional medication fields from response', async () => {
|
||||
const regimen = makeFakeRegimen();
|
||||
mockList.mockResolvedValue({
|
||||
data: [regimen],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
const med = body.data[0].medications[0];
|
||||
expect(med.customFrequencyPerDay).toBeUndefined();
|
||||
expect(med.timeOfDay).toBeUndefined();
|
||||
expect(med.instructions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes query parameters to service', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens?isActive=true&limit=5',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockList).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'kc-1',
|
||||
expect.objectContaining({ isActive: true, limit: 5 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/regimens/burn-rate', () => {
|
||||
it('returns burn rate data array', async () => {
|
||||
const burnRateItem = {
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dailyConsumption: 1,
|
||||
totalInCabinet: 30,
|
||||
daysUntilEmpty: 30,
|
||||
earliestExpiry: '2025-01-01T00:00:00.000Z',
|
||||
avgUnitPrice: 2.5,
|
||||
projectedDailyCost: 2.5,
|
||||
projectedMonthlyCost: 75,
|
||||
projectedYearlyCost: 912.5,
|
||||
currency: 'USD',
|
||||
};
|
||||
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens/burn-rate',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].medicineId).toBe('med-1');
|
||||
expect(body.data[0].medicineName).toBe('Metformin');
|
||||
expect(body.data[0].dailyConsumption).toBe(1);
|
||||
expect(body.data[0].daysUntilEmpty).toBe(30);
|
||||
expect(body.data[0].currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('returns empty array when no active regimens', async () => {
|
||||
mockCalculateBurnRates.mockResolvedValue([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens/burn-rate',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles null monetary fields correctly', async () => {
|
||||
const burnRateItem = {
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dailyConsumption: 1,
|
||||
totalInCabinet: 30,
|
||||
daysUntilEmpty: 30,
|
||||
earliestExpiry: null,
|
||||
avgUnitPrice: null,
|
||||
projectedDailyCost: null,
|
||||
projectedMonthlyCost: null,
|
||||
projectedYearlyCost: null,
|
||||
currency: null,
|
||||
};
|
||||
mockCalculateBurnRates.mockResolvedValue([burnRateItem]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens/burn-rate',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data[0].avgUnitPrice).toBeNull();
|
||||
expect(body.data[0].projectedDailyCost).toBeNull();
|
||||
expect(body.data[0].projectedMonthlyCost).toBeNull();
|
||||
expect(body.data[0].projectedYearlyCost).toBeNull();
|
||||
expect(body.data[0].currency).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/regimens/:id', () => {
|
||||
it('returns single regimen by id', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeRegimen());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens/reg-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.name).toBe('Daily Medications');
|
||||
expect(body._id).toBe('reg-1');
|
||||
});
|
||||
|
||||
it('passes id and householdId to service', async () => {
|
||||
mockGetById.mockResolvedValue(makeFakeRegimen());
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/regimens/reg-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(mockGetById).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/regimens', () => {
|
||||
it('creates regimen and returns 201', async () => {
|
||||
mockCreate.mockResolvedValue(makeFakeRegimen());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
payload: validPostBody,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.name).toBe('Daily Medications');
|
||||
expect(body._id).toBe('reg-1');
|
||||
});
|
||||
|
||||
it('returns 400 on invalid body with empty medications array', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Bad Regimen', isActive: true, medications: [] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on invalid body with missing name', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/regimens',
|
||||
headers: authHeaders,
|
||||
payload: {
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/households/:householdId/regimens/:id', () => {
|
||||
it('updates regimen and returns 200', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakeRegimen({ name: 'Updated Regimen' }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/regimens/reg-1',
|
||||
headers: authHeaders,
|
||||
payload: { name: 'Updated Regimen' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated Regimen');
|
||||
});
|
||||
|
||||
it('passes id, householdId, and body to service', async () => {
|
||||
mockUpdate.mockResolvedValue(makeFakeRegimen({ isActive: false }));
|
||||
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/hh1/regimens/reg-1',
|
||||
headers: authHeaders,
|
||||
payload: { isActive: false },
|
||||
});
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1', expect.objectContaining({ isActive: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/households/:householdId/regimens/:id', () => {
|
||||
it('deletes regimen and returns 204', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/hh1/regimens/reg-1',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(mockDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
211
packages/api/src/modules/regimens/regimens.routes.ts
Normal file
211
packages/api/src/modules/regimens/regimens.routes.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateRegimenSchema,
|
||||
UpdateRegimenSchema,
|
||||
RegimenQuerySchema,
|
||||
RegimenResponseSchema,
|
||||
RegimenListResponseSchema,
|
||||
BurnRateResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { RegimensRepository } from './regimens.repository.js';
|
||||
import { RegimensService } from './regimens.service.js';
|
||||
|
||||
type AnyRegimenMedication = {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
dosage: number;
|
||||
dosageUnit: string;
|
||||
frequency: string;
|
||||
customFrequencyPerDay?: number | null;
|
||||
timeOfDay?: string | null;
|
||||
instructions?: string | null;
|
||||
};
|
||||
|
||||
type AnyRegimenDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
medications: AnyRegimenMedication[];
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
function toRegimenResponse(doc: AnyRegimenDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
name: doc.name,
|
||||
isActive: doc.isActive,
|
||||
medications: doc.medications.map((med) => ({
|
||||
medicineId: med.medicineId,
|
||||
medicineName: med.medicineName,
|
||||
medicineStrength: med.medicineStrength,
|
||||
medicineStrengthUnit: med.medicineStrengthUnit,
|
||||
medicineForm: med.medicineForm,
|
||||
dosage: med.dosage,
|
||||
dosageUnit: med.dosageUnit,
|
||||
frequency: med.frequency,
|
||||
...(med.customFrequencyPerDay != null ? { customFrequencyPerDay: med.customFrequencyPerDay } : {}),
|
||||
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
|
||||
...(med.instructions ? { instructions: med.instructions } : {}),
|
||||
})),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
regimensRepository: RegimensRepository;
|
||||
regimensService: RegimensService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
regimensRepository: asClass(RegimensRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
regimensService: asClass(RegimensService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/regimens — list user's regimens
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/regimens',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: RegimenQuerySchema,
|
||||
response: { 200: RegimenListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const result = await service.list(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toRegimenResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/regimens/burn-rate — burn rate + spending projection
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/regimens/burn-rate',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
response: { 200: BurnRateResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const data = await service.calculateBurnRates(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send({ data });
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/regimens/:id — get single regimen
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/regimens/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: RegimenResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const regimen = await service.getById(request.params.id, request.params.householdId, request.user.keycloakId);
|
||||
return reply.send(toRegimenResponse(regimen));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/regimens — create regimen
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/regimens',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateRegimenSchema,
|
||||
response: { 201: RegimenResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const regimen = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toRegimenResponse(regimen));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId/regimens/:id — update regimen
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/regimens/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateRegimenSchema,
|
||||
response: { 200: RegimenResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const regimen = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body,
|
||||
);
|
||||
return reply.send(toRegimenResponse(regimen));
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/v1/households/:householdId/regimens/:id — delete regimen
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/regimens/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 204: z.undefined() },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'regimens-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
},
|
||||
);
|
||||
757
packages/api/src/modules/regimens/regimens.service.test.ts
Normal file
757
packages/api/src/modules/regimens/regimens.service.test.ts
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RegimensService } from './regimens.service.js';
|
||||
import { DosageFrequency } from '@meshitrack/shared';
|
||||
|
||||
describe(RegimensService.name, () => {
|
||||
const mockRegimensRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findActiveByUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMedicinesRepo = {
|
||||
findById: vi.fn(),
|
||||
findByHousehold: vi.fn(),
|
||||
findDuplicate: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCabinetRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
getAggregateSummary: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
adjustQuantity: vi.fn(),
|
||||
findExpiringSoon: vi.fn(),
|
||||
softDelete: vi.fn(),
|
||||
countByMedicineId: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
findActiveByMedicineForFEFO: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCabinetEventsService = {
|
||||
logEvent: vi.fn(),
|
||||
logEvents: vi.fn(),
|
||||
listEvents: vi.fn(),
|
||||
getEventsByItem: vi.fn(),
|
||||
getSpendingSummary: vi.fn(),
|
||||
getAvgUnitPrices: vi.fn(),
|
||||
};
|
||||
|
||||
let service: RegimensService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new RegimensService({
|
||||
regimensRepository: mockRegimensRepo as never,
|
||||
medicinesRepository: mockMedicinesRepo as never,
|
||||
cabinetRepository: mockCabinetRepo as never,
|
||||
cabinetEventsService: mockCabinetEventsService as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const result = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRegimensRepo.findByHousehold.mockResolvedValue(result);
|
||||
|
||||
const response = await service.list('hh1', 'user-1', { limit: 20 });
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(mockRegimensRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns regimen when found', async () => {
|
||||
const regimen = { _id: 'reg-1', name: 'Morning' };
|
||||
mockRegimensRepo.findById.mockResolvedValue(regimen);
|
||||
|
||||
const result = await service.getById('reg-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(regimen);
|
||||
expect(mockRegimensRepo.findById).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when not found', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createInput = {
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('creates regimen with denormalized medications', async () => {
|
||||
mockMedicinesRepo.findById.mockResolvedValue({
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
});
|
||||
const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }] };
|
||||
mockRegimensRepo.create.mockResolvedValue(created);
|
||||
|
||||
const result = await service.create(createInput, 'hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Morning Routine',
|
||||
isActive: true,
|
||||
medications: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
medicineStrength: 500,
|
||||
medicineStrengthUnit: 'mg',
|
||||
medicineForm: 'tablet',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: DosageFrequency.DAILY,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
'hh1',
|
||||
'user-1',
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves optional medication fields', async () => {
|
||||
mockMedicinesRepo.findById.mockResolvedValue({
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
});
|
||||
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
|
||||
|
||||
await service.create(
|
||||
{
|
||||
name: 'Morning',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 2,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.CUSTOM,
|
||||
customFrequencyPerDay: 4,
|
||||
timeOfDay: 'morning' as never,
|
||||
instructions: 'Take with food',
|
||||
},
|
||||
],
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
medications: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
customFrequencyPerDay: 4,
|
||||
timeOfDay: 'morning',
|
||||
instructions: 'Take with food',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
'hh1',
|
||||
'user-1',
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when medicine not found', async () => {
|
||||
mockMedicinesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(createInput, 'hh1', 'user-1')).rejects.toThrow(
|
||||
'Medicine not found: med-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('denormalizes multiple medications', async () => {
|
||||
mockMedicinesRepo.findById
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'med-2',
|
||||
name: 'Aspirin',
|
||||
strength: 100,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
});
|
||||
mockRegimensRepo.create.mockResolvedValue({ _id: 'reg-1' });
|
||||
|
||||
await service.create(
|
||||
{
|
||||
name: 'Full Routine',
|
||||
isActive: true,
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
},
|
||||
{
|
||||
medicineId: 'med-2',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.TWICE_DAILY,
|
||||
},
|
||||
],
|
||||
},
|
||||
'hh1',
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(mockRegimensRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
medications: expect.arrayContaining([
|
||||
expect.objectContaining({ medicineId: 'med-1', medicineName: 'Metformin' }),
|
||||
expect.objectContaining({ medicineId: 'med-2', medicineName: 'Aspirin' }),
|
||||
]),
|
||||
}),
|
||||
'hh1',
|
||||
'user-1',
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates name only', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
|
||||
const updated = { _id: 'reg-1', name: 'Evening' };
|
||||
mockRegimensRepo.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening' });
|
||||
});
|
||||
|
||||
it('updates isActive only', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', isActive: true });
|
||||
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1', isActive: false });
|
||||
|
||||
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
|
||||
|
||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false });
|
||||
});
|
||||
|
||||
it('updates medications with denormalization', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
|
||||
mockMedicinesRepo.findById.mockResolvedValue({
|
||||
_id: 'med-1',
|
||||
name: 'Metformin',
|
||||
strength: 500,
|
||||
strengthUnit: 'mg',
|
||||
form: 'tablet',
|
||||
});
|
||||
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
|
||||
|
||||
await service.update('reg-1', 'hh1', 'user-1', {
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
dosage: 2,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith(
|
||||
'reg-1',
|
||||
'hh1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
medications: expect.arrayContaining([
|
||||
expect.objectContaining({ medicineName: 'Metformin' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
|
||||
'Regimen not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when update returns null', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1', name: 'Morning' });
|
||||
mockRegimensRepo.update.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('reg-1', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
|
||||
'Regimen not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips undefined fields in updateData', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
|
||||
mockRegimensRepo.update.mockResolvedValue({ _id: 'reg-1' });
|
||||
|
||||
await service.update('reg-1', 'hh1', 'user-1', {});
|
||||
|
||||
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {});
|
||||
});
|
||||
|
||||
it('throws NotFoundError when medicine in medications not found', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
|
||||
mockMedicinesRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('reg-1', 'hh1', 'user-1', {
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-missing',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet' as const,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Medicine not found: med-missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('soft deletes regimen', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
|
||||
mockRegimensRepo.softDelete.mockResolvedValue({ _id: 'reg-1', isDeleted: true });
|
||||
|
||||
const result = await service.delete('reg-1', 'hh1', 'user-1');
|
||||
|
||||
expect(result.isDeleted).toBe(true);
|
||||
expect(mockRegimensRepo.softDelete).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when regimen not found on initial lookup', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
|
||||
});
|
||||
|
||||
it('throws NotFoundError when softDelete returns null', async () => {
|
||||
mockRegimensRepo.findById.mockResolvedValue({ _id: 'reg-1' });
|
||||
mockRegimensRepo.softDelete.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('reg-1', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveByUser', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const regimens = [{ _id: 'reg-1', isActive: true }];
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue(regimens);
|
||||
|
||||
const result = await service.getActiveByUser('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual(regimens);
|
||||
expect(mockRegimensRepo.findActiveByUser).toHaveBeenCalledWith('hh1', 'user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBurnRates', () => {
|
||||
it('returns empty array when no active regimens', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when regimens have no medications', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([{ _id: 'reg-1', medications: [] }]);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('calculates burn rates for single medicine', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
dosage: 1,
|
||||
frequency: DosageFrequency.DAILY,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: new Date('2026-06-01T00:00:00.000Z') },
|
||||
]);
|
||||
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
|
||||
new Map([['med-1', { avgUnitPrice: 0.5, currency: 'USD' }]]),
|
||||
);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].medicineId).toBe('med-1');
|
||||
expect(result[0].medicineName).toBe('Metformin');
|
||||
expect(result[0].dailyConsumption).toBe(1);
|
||||
expect(result[0].totalInCabinet).toBe(30);
|
||||
expect(result[0].daysUntilEmpty).toBe(30);
|
||||
expect(result[0].earliestExpiry).toBe('2026-06-01T00:00:00.000Z');
|
||||
expect(result[0].avgUnitPrice).toBe(0.5);
|
||||
expect(result[0].projectedDailyCost).toBe(0.5);
|
||||
expect(result[0].projectedMonthlyCost).toBe(15);
|
||||
expect(result[0].projectedYearlyCost).toBe(182.5);
|
||||
expect(result[0].currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('sums daily consumption across multiple regimens', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'reg-2',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
|
||||
]);
|
||||
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// 1*1 + 1*2 = 3 daily
|
||||
expect(result[0].dailyConsumption).toBe(3);
|
||||
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
|
||||
expect(result[0].earliestExpiry).toBeNull();
|
||||
expect(result[0].avgUnitPrice).toBeNull();
|
||||
expect(result[0].projectedDailyCost).toBeNull();
|
||||
expect(result[0].projectedMonthlyCost).toBeNull();
|
||||
expect(result[0].projectedYearlyCost).toBeNull();
|
||||
expect(result[0].currency).toBeNull();
|
||||
});
|
||||
|
||||
it('handles medicine not in cabinet stock', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result[0].totalInCabinet).toBe(0);
|
||||
expect(result[0].daysUntilEmpty).toBe(0); // Math.floor(0/1) = 0
|
||||
});
|
||||
|
||||
it('excludes AS_NEEDED frequency from burn rate results', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles CUSTOM frequency with customFrequencyPerDay', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Custom Med',
|
||||
dosage: 2,
|
||||
frequency: DosageFrequency.CUSTOM,
|
||||
customFrequencyPerDay: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// 2 * 3 = 6 daily
|
||||
expect(result[0].dailyConsumption).toBe(6);
|
||||
expect(result[0].daysUntilEmpty).toBe(10); // 60 / 6 = 10
|
||||
});
|
||||
|
||||
it('excludes CUSTOM frequency without customFrequencyPerDay (zero consumption)', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Custom Med',
|
||||
dosage: 2,
|
||||
frequency: DosageFrequency.CUSTOM,
|
||||
// no customFrequencyPerDay -> 0 daily consumption -> excluded
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts by daysUntilEmpty ascending (most urgent first, nulls last)', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 30, earliestExpiry: null },
|
||||
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// AS_NEEDED (med-3) excluded; med-2: 10 days, med-1: 30 days
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].medicineId).toBe('med-2');
|
||||
expect(result[0].daysUntilEmpty).toBe(10);
|
||||
expect(result[1].medicineId).toBe('med-1');
|
||||
expect(result[1].daysUntilEmpty).toBe(30);
|
||||
});
|
||||
|
||||
it('returns empty array when all medications are AS_NEEDED', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes AS_NEEDED from results even when mixed with scheduled frequencies', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
|
||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-2', totalQuantity: 10, earliestExpiry: null },
|
||||
{ _id: 'med-3', totalQuantity: 30, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// AS_NEEDED (med-1) excluded; med-2: 10 days, med-3: 30 days
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].medicineId).toBe('med-2');
|
||||
expect(result[0].daysUntilEmpty).toBe(10);
|
||||
expect(result[1].medicineId).toBe('med-3');
|
||||
expect(result[1].daysUntilEmpty).toBe(30);
|
||||
});
|
||||
|
||||
it('handles WEEKLY frequency', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 4, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// 1 * (1/7) ~= 0.1429 daily
|
||||
expect(result[0].dailyConsumption).toBeCloseTo(1 / 7);
|
||||
expect(result[0].daysUntilEmpty).toBe(28); // Math.floor(4 / (1/7)) = 28
|
||||
});
|
||||
|
||||
it('handles EVERY_OTHER_DAY frequency', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 15, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// 1 * 0.5 = 0.5 daily
|
||||
expect(result[0].dailyConsumption).toBe(0.5);
|
||||
expect(result[0].daysUntilEmpty).toBe(30); // Math.floor(15 / 0.5) = 30
|
||||
});
|
||||
|
||||
it('handles THREE_TIMES_DAILY frequency', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 90, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
expect(result[0].dailyConsumption).toBe(3);
|
||||
expect(result[0].daysUntilEmpty).toBe(30); // 90 / 3 = 30
|
||||
});
|
||||
|
||||
it('calculates projected costs correctly', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 60, earliestExpiry: null },
|
||||
]);
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
|
||||
new Map([['med-1', { avgUnitPrice: 1.5, currency: 'EUR' }]]),
|
||||
);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// dailyConsumption = 2, avgUnitPrice = 1.5
|
||||
expect(result[0].projectedDailyCost).toBe(3); // 1.5 * 2
|
||||
expect(result[0].projectedMonthlyCost).toBe(90); // 3 * 30
|
||||
expect(result[0].projectedYearlyCost).toBe(1095); // 3 * 365
|
||||
expect(result[0].currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('handles multiple medicines with different stock and price data', async () => {
|
||||
mockRegimensRepo.findActiveByUser.mockResolvedValue([
|
||||
{
|
||||
_id: 'reg-1',
|
||||
medications: [
|
||||
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
|
||||
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
|
||||
{ _id: 'med-1', totalQuantity: 10, earliestExpiry: new Date('2026-03-01T00:00:00.000Z') },
|
||||
{ _id: 'med-2', totalQuantity: 60, earliestExpiry: new Date('2026-12-01T00:00:00.000Z') },
|
||||
]);
|
||||
|
||||
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
|
||||
new Map([
|
||||
['med-1', { avgUnitPrice: 2.0, currency: 'USD' }],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.calculateBurnRates('hh1', 'user-1');
|
||||
|
||||
// med-1: 10 days until empty, med-2: 30 days
|
||||
expect(result[0].medicineId).toBe('med-1');
|
||||
expect(result[0].daysUntilEmpty).toBe(10);
|
||||
expect(result[0].avgUnitPrice).toBe(2.0);
|
||||
expect(result[1].medicineId).toBe('med-2');
|
||||
expect(result[1].daysUntilEmpty).toBe(30);
|
||||
expect(result[1].avgUnitPrice).toBeNull();
|
||||
expect(result[1].currency).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
202
packages/api/src/modules/regimens/regimens.service.ts
Normal file
202
packages/api/src/modules/regimens/regimens.service.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import type { RegimensRepository } from './regimens.repository.js';
|
||||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
import type { CreateRegimenInput, UpdateRegimenInput, RegimenQueryInput } from '@meshitrack/shared';
|
||||
import { getFrequencyMultiplier } from '@meshitrack/shared';
|
||||
import type { DosageFrequency } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
regimensRepository: RegimensRepository;
|
||||
medicinesRepository: MedicinesRepository;
|
||||
cabinetRepository: CabinetRepository;
|
||||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
export class RegimensService {
|
||||
private readonly regimensRepository: RegimensRepository;
|
||||
private readonly medicinesRepository: MedicinesRepository;
|
||||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly cabinetEventsService: CabinetEventsService;
|
||||
|
||||
public constructor({
|
||||
regimensRepository,
|
||||
medicinesRepository,
|
||||
cabinetRepository,
|
||||
cabinetEventsService,
|
||||
}: Deps) {
|
||||
this.regimensRepository = regimensRepository;
|
||||
this.medicinesRepository = medicinesRepository;
|
||||
this.cabinetRepository = cabinetRepository;
|
||||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async list(householdId: string, userId: string, query: RegimenQueryInput) {
|
||||
return this.regimensRepository.findByHousehold(householdId, userId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string, userId: string) {
|
||||
const regimen = await this.regimensRepository.findById(id, householdId, userId);
|
||||
if (!regimen) throw new NotFoundError('Regimen not found');
|
||||
return regimen;
|
||||
}
|
||||
|
||||
public async create(data: CreateRegimenInput, householdId: string, userId: string) {
|
||||
const medications = await this.denormalizeMedications(data.medications, householdId);
|
||||
return this.regimensRepository.create(
|
||||
{ name: data.name, isActive: data.isActive, medications },
|
||||
householdId,
|
||||
userId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenInput) {
|
||||
await this.getById(id, householdId, userId);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData['name'] = data.name;
|
||||
if (data.isActive !== undefined) updateData['isActive'] = data.isActive;
|
||||
if (data.medications !== undefined) {
|
||||
updateData['medications'] = await this.denormalizeMedications(data.medications, householdId);
|
||||
}
|
||||
|
||||
const updated = await this.regimensRepository.update(id, householdId, userId, updateData);
|
||||
if (!updated) throw new NotFoundError('Regimen not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string, userId: string) {
|
||||
await this.getById(id, householdId, userId);
|
||||
const deleted = await this.regimensRepository.softDelete(id, householdId, userId);
|
||||
if (!deleted) throw new NotFoundError('Regimen not found');
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async getActiveByUser(householdId: string, userId: string) {
|
||||
return this.regimensRepository.findActiveByUser(householdId, userId);
|
||||
}
|
||||
|
||||
public async calculateBurnRates(householdId: string, userId: string) {
|
||||
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
|
||||
|
||||
// Sum daily consumption per medicine across all active regimens
|
||||
const consumptionMap = new Map<
|
||||
string,
|
||||
{ medicineName: string; dailyConsumption: number }
|
||||
>();
|
||||
|
||||
for (const regimen of regimens) {
|
||||
for (const med of regimen.medications) {
|
||||
const multiplier = getFrequencyMultiplier(
|
||||
med.frequency as DosageFrequency,
|
||||
med.customFrequencyPerDay ?? undefined,
|
||||
);
|
||||
const dailyDose = med.dosage * multiplier;
|
||||
const existing = consumptionMap.get(med.medicineId);
|
||||
if (existing) {
|
||||
existing.dailyConsumption += dailyDose;
|
||||
} else {
|
||||
consumptionMap.set(med.medicineId, {
|
||||
medicineName: med.medicineName,
|
||||
dailyConsumption: dailyDose,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude medicines with zero daily consumption (e.g., AS_NEEDED frequency)
|
||||
for (const [id, consumption] of consumptionMap) {
|
||||
if (consumption.dailyConsumption === 0) consumptionMap.delete(id);
|
||||
}
|
||||
|
||||
if (consumptionMap.size === 0) return [];
|
||||
|
||||
// Get cabinet summary for all medicines in regimens
|
||||
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const stockMap = new Map<
|
||||
string,
|
||||
{ totalQuantity: number; earliestExpiry: Date | null }
|
||||
>();
|
||||
for (const s of summaryResults) {
|
||||
stockMap.set(s._id as string, {
|
||||
totalQuantity: s.totalQuantity as number,
|
||||
earliestExpiry: (s.earliestExpiry as Date | null) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Get average unit prices from purchase events
|
||||
const medicineIds = [...consumptionMap.keys()];
|
||||
const priceMap = await this.cabinetEventsService.getAvgUnitPrices(householdId, medicineIds);
|
||||
|
||||
// Build burn rate array
|
||||
const burnRates = [];
|
||||
for (const [medicineId, consumption] of consumptionMap) {
|
||||
const stock = stockMap.get(medicineId);
|
||||
const totalInCabinet = stock?.totalQuantity ?? 0;
|
||||
const earliestExpiry = stock?.earliestExpiry ?? null;
|
||||
const dailyConsumption = consumption.dailyConsumption;
|
||||
|
||||
const daysUntilEmpty =
|
||||
dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null;
|
||||
|
||||
const priceData = priceMap.get(medicineId);
|
||||
const avgUnitPrice = priceData?.avgUnitPrice ?? null;
|
||||
const currency = priceData?.currency ?? null;
|
||||
const projectedDailyCost =
|
||||
avgUnitPrice !== null && dailyConsumption > 0 ? avgUnitPrice * dailyConsumption : null;
|
||||
|
||||
burnRates.push({
|
||||
medicineId,
|
||||
medicineName: consumption.medicineName,
|
||||
dailyConsumption,
|
||||
totalInCabinet,
|
||||
daysUntilEmpty,
|
||||
earliestExpiry: earliestExpiry ? earliestExpiry.toISOString() : null,
|
||||
avgUnitPrice,
|
||||
projectedDailyCost,
|
||||
projectedMonthlyCost: projectedDailyCost !== null ? projectedDailyCost * 30 : null,
|
||||
projectedYearlyCost: projectedDailyCost !== null ? projectedDailyCost * 365 : null,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by daysUntilEmpty ASC (most urgent first, nulls last)
|
||||
burnRates.sort((a, b) => {
|
||||
if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0;
|
||||
if (a.daysUntilEmpty === null) return 1;
|
||||
if (b.daysUntilEmpty === null) return -1;
|
||||
return a.daysUntilEmpty - b.daysUntilEmpty;
|
||||
});
|
||||
|
||||
return burnRates;
|
||||
}
|
||||
|
||||
private async denormalizeMedications(
|
||||
medications: CreateRegimenInput['medications'],
|
||||
householdId: string,
|
||||
) {
|
||||
const result = [];
|
||||
for (const med of medications) {
|
||||
const medicine = await this.medicinesRepository.findById(med.medicineId, householdId);
|
||||
if (!medicine) {
|
||||
throw new NotFoundError(`Medicine not found: ${med.medicineId}`);
|
||||
}
|
||||
result.push({
|
||||
medicineId: med.medicineId,
|
||||
medicineName: medicine.name,
|
||||
medicineStrength: medicine.strength,
|
||||
medicineStrengthUnit: medicine.strengthUnit,
|
||||
medicineForm: medicine.form,
|
||||
dosage: med.dosage,
|
||||
dosageUnit: med.dosageUnit,
|
||||
frequency: med.frequency,
|
||||
customFrequencyPerDay: med.customFrequencyPerDay,
|
||||
timeOfDay: med.timeOfDay,
|
||||
instructions: med.instructions,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
40
packages/api/src/schemas/cabinet-event.schema.ts
Normal file
40
packages/api/src/schemas/cabinet-event.schema.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
|
||||
|
||||
const cabinetEventSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
userId: { type: String, required: true },
|
||||
cabinetItemId: { type: String, required: true },
|
||||
medicineId: { type: String, required: true },
|
||||
medicineName: { type: String, required: true },
|
||||
eventType: { type: String, enum: Object.values(CabinetEventType), required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
quantityBefore: { type: Number, required: true },
|
||||
quantityAfter: { type: Number, required: true },
|
||||
unitPrice: { type: Number },
|
||||
totalPrice: { type: Number },
|
||||
currency: { type: String },
|
||||
storeId: { type: String },
|
||||
storeName: { type: String },
|
||||
sourceType: { type: String, enum: Object.values(CabinetEventSourceType), required: true },
|
||||
sourceId: { type: String },
|
||||
reason: { type: String },
|
||||
notes: { type: String },
|
||||
},
|
||||
{
|
||||
timestamps: { createdAt: true, updatedAt: false },
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
cabinetEventSchema.index({ householdId: 1, createdAt: -1 });
|
||||
cabinetEventSchema.index({ householdId: 1, cabinetItemId: 1, createdAt: -1 });
|
||||
cabinetEventSchema.index({ householdId: 1, medicineId: 1, createdAt: -1 });
|
||||
cabinetEventSchema.index({ householdId: 1, eventType: 1, createdAt: -1 });
|
||||
|
||||
export const CabinetEventModel = mongoose.model('CabinetEvent', cabinetEventSchema);
|
||||
export type CabinetEventDocument = mongoose.InferSchemaType<typeof cabinetEventSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
|
|
@ -22,6 +22,12 @@ const cabinetItemSchema = new mongoose.Schema(
|
|||
quantity: { type: Number, required: true, min: 0 },
|
||||
unit: { type: String, enum: Object.values(DosageUnit), required: true },
|
||||
expirationDate: { type: Date },
|
||||
purchaseDate: { type: Date },
|
||||
unitPrice: { type: Number },
|
||||
totalPrice: { type: Number },
|
||||
currency: { type: String },
|
||||
storeId: { type: String },
|
||||
storeName: { type: String },
|
||||
status: {
|
||||
type: String,
|
||||
enum: Object.values(CabinetItemStatus),
|
||||
|
|
|
|||
55
packages/api/src/schemas/organizer-fill.schema.ts
Normal file
55
packages/api/src/schemas/organizer-fill.schema.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
|
||||
const organizerDeductionSchema = new mongoose.Schema(
|
||||
{
|
||||
cabinetItemId: { type: String, required: true },
|
||||
quantityTaken: { type: Number, required: true },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const organizerFillItemSchema = new mongoose.Schema(
|
||||
{
|
||||
medicineId: { type: String, required: true },
|
||||
medicineName: { type: String, required: true },
|
||||
quantityNeeded: { type: Number, required: true },
|
||||
quantityTaken: { type: Number, required: true },
|
||||
wasShort: { type: Boolean, required: true },
|
||||
shortage: { type: Number, required: true },
|
||||
deductions: { type: [organizerDeductionSchema], required: true },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const organizerFillSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
userId: { type: String, required: true },
|
||||
regimenId: { type: String, required: true },
|
||||
regimenName: { type: String, required: true },
|
||||
numberOfDays: { type: Number, required: true },
|
||||
fillDate: { type: Date, required: true },
|
||||
items: { type: [organizerFillItemSchema], required: true },
|
||||
status: {
|
||||
type: String,
|
||||
enum: Object.values(OrganizerFillStatus),
|
||||
required: true,
|
||||
},
|
||||
notes: { type: String },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
organizerFillSchema.index({ householdId: 1, userId: 1, fillDate: -1 });
|
||||
organizerFillSchema.index({ householdId: 1, regimenId: 1, fillDate: -1 });
|
||||
organizerFillSchema.index({ householdId: 1, status: 1 });
|
||||
|
||||
export const OrganizerFillModel = mongoose.model('OrganizerFill', organizerFillSchema);
|
||||
export type OrganizerFillDocument = mongoose.InferSchemaType<typeof organizerFillSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
50
packages/api/src/schemas/regimen.schema.ts
Normal file
50
packages/api/src/schemas/regimen.schema.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import mongoose from 'mongoose';
|
||||
import {
|
||||
DosageFrequency,
|
||||
DosageUnit,
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
TimeOfDay,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
const regimenMedicationSchema = new mongoose.Schema(
|
||||
{
|
||||
medicineId: { type: String, required: true },
|
||||
medicineName: { type: String, required: true },
|
||||
medicineStrength: { type: Number, required: true },
|
||||
medicineStrengthUnit: { type: String, enum: Object.values(StrengthUnit), required: true },
|
||||
medicineForm: { type: String, enum: Object.values(MedicineForm), required: true },
|
||||
dosage: { type: Number, required: true },
|
||||
dosageUnit: { type: String, enum: Object.values(DosageUnit), required: true },
|
||||
frequency: { type: String, enum: Object.values(DosageFrequency), required: true },
|
||||
customFrequencyPerDay: { type: Number },
|
||||
timeOfDay: { type: String, enum: Object.values(TimeOfDay) },
|
||||
instructions: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const regimenSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
userId: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
isActive: { type: Boolean, default: true },
|
||||
medications: { type: [regimenMedicationSchema], required: true },
|
||||
createdBy: { type: String, required: true },
|
||||
isDeleted: { type: Boolean, default: false },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
regimenSchema.index({ householdId: 1, userId: 1, isActive: 1 });
|
||||
regimenSchema.index({ householdId: 1, 'medications.medicineId': 1 });
|
||||
|
||||
export const RegimenModel = mongoose.model('Regimen', regimenSchema);
|
||||
export type RegimenDocument = mongoose.InferSchemaType<typeof regimenSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue