Implement regimens

This commit is contained in:
Aerilyn Weber 2026-03-28 18:25:49 +09:00
parent 1f66fab30f
commit 9f416903ef
66 changed files with 9130 additions and 189 deletions

View file

@ -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) => {

View file

@ -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 });
});
});
});

View file

@ -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;
}
}

View file

@ -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);
});
});
});

View 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'],
},
);

View file

@ -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']);
});
});
});

View file

@ -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);
}
}

View file

@ -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);
});
});
});

View file

@ -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();
}
}

View file

@ -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);
});
});
});

View file

@ -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',

View file

@ -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',
);
});
});
});

View file

@ -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;
}
}

View 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();
});
});
});

View 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();
}
}

View 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');
});
});
});

View 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'],
},
);

View 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);
});
});
});

View 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();
}
}
}

View 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();
});
});
});

View 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();
}
}

View 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');
});
});
});

View 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'],
},
);

View 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();
});
});
});

View 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;
}
}

View 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;
};

View file

@ -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),

View 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;
};

View 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;
};