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

View file

@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import {
CabinetEventType,
CabinetEventSourceType,
} from './cabinet-event.enums.js';
describe(CabinetEventType.name, () => {
it('has exactly 6 values', () => {
expect(Object.values(CabinetEventType)).toHaveLength(6);
});
it.each([
['PURCHASED', 'purchased'],
['CONSUMED', 'consumed'],
['ADJUSTED', 'adjusted'],
['DISCARDED', 'discarded'],
['RESTORED', 'restored'],
['DELETED', 'deleted'],
])('%s = %s', (key, value) => {
expect(CabinetEventType[key as keyof typeof CabinetEventType]).toBe(value);
});
});
describe(CabinetEventSourceType.name, () => {
it('has exactly 4 values', () => {
expect(Object.values(CabinetEventSourceType)).toHaveLength(4);
});
it.each([
['MANUAL', 'manual'],
['ORGANIZER_FILL', 'organizer_fill'],
['ORGANIZER_UNDO', 'organizer_undo'],
['REFILL_LIST', 'refill_list'],
])('%s = %s', (key, value) => {
expect(
CabinetEventSourceType[key as keyof typeof CabinetEventSourceType],
).toBe(value);
});
});

View file

@ -0,0 +1,15 @@
export enum CabinetEventType {
PURCHASED = 'purchased',
CONSUMED = 'consumed',
ADJUSTED = 'adjusted',
DISCARDED = 'discarded',
RESTORED = 'restored',
DELETED = 'deleted',
}
export enum CabinetEventSourceType {
MANUAL = 'manual',
ORGANIZER_FILL = 'organizer_fill',
ORGANIZER_UNDO = 'organizer_undo',
REFILL_LIST = 'refill_list',
}

View file

@ -1,3 +1,5 @@
export * from './roles.enums.js';
export * from './medicine.enums.js';
export * from './cabinet.enums.js';
export * from './cabinet-event.enums.js';
export * from './regimen.enums.js';

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import {
DosageFrequency,
TimeOfDay,
OrganizerFillStatus,
} from './regimen.enums.js';
describe(DosageFrequency.name, () => {
it('has exactly 7 values', () => {
expect(Object.values(DosageFrequency)).toHaveLength(7);
});
it.each([
['DAILY', 'daily'],
['TWICE_DAILY', 'twice_daily'],
['THREE_TIMES_DAILY', 'three_times_daily'],
['WEEKLY', 'weekly'],
['EVERY_OTHER_DAY', 'every_other_day'],
['AS_NEEDED', 'as_needed'],
['CUSTOM', 'custom'],
])('%s = %s', (key, value) => {
expect(DosageFrequency[key as keyof typeof DosageFrequency]).toBe(value);
});
});
describe(TimeOfDay.name, () => {
it('has exactly 4 values', () => {
expect(Object.values(TimeOfDay)).toHaveLength(4);
});
it.each([
['MORNING', 'morning'],
['AFTERNOON', 'afternoon'],
['EVENING', 'evening'],
['BEDTIME', 'bedtime'],
])('%s = %s', (key, value) => {
expect(TimeOfDay[key as keyof typeof TimeOfDay]).toBe(value);
});
});
describe(OrganizerFillStatus.name, () => {
it('has exactly 3 values', () => {
expect(Object.values(OrganizerFillStatus)).toHaveLength(3);
});
it.each([
['COMPLETED', 'completed'],
['PARTIAL', 'partial'],
['REVERSED', 'reversed'],
])('%s = %s', (key, value) => {
expect(
OrganizerFillStatus[key as keyof typeof OrganizerFillStatus],
).toBe(value);
});
});

View file

@ -0,0 +1,22 @@
export enum DosageFrequency {
DAILY = 'daily',
TWICE_DAILY = 'twice_daily',
THREE_TIMES_DAILY = 'three_times_daily',
WEEKLY = 'weekly',
EVERY_OTHER_DAY = 'every_other_day',
AS_NEEDED = 'as_needed',
CUSTOM = 'custom',
}
export enum TimeOfDay {
MORNING = 'morning',
AFTERNOON = 'afternoon',
EVENING = 'evening',
BEDTIME = 'bedtime',
}
export enum OrganizerFillStatus {
COMPLETED = 'completed',
PARTIAL = 'partial',
REVERSED = 'reversed',
}

View file

@ -6,3 +6,6 @@ export * from './enums/index.js';
// Validation schemas
export * from './validation/index.js';
// Utils
export * from './utils/index.js';

View file

@ -0,0 +1,13 @@
export interface BurnRate {
medicineId: string;
medicineName: string;
dailyConsumption: number;
totalInCabinet: number;
daysUntilEmpty: number | null;
earliestExpiry: Date | null;
avgUnitPrice: number | null;
projectedDailyCost: number | null;
projectedMonthlyCost: number | null;
projectedYearlyCost: number | null;
currency: string | null;
}

View file

@ -0,0 +1,48 @@
import type {
CabinetEventType,
CabinetEventSourceType,
} from '../enums/cabinet-event.enums.js';
export interface CabinetEvent {
id: string;
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;
createdAt: Date;
}
export interface SpendingSummary {
totalSpent: number;
currency: string | null;
byMedicine: SpendingByMedicine[];
byPeriod: SpendingByPeriod[];
}
export interface SpendingByMedicine {
medicineId: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
avgUnitPrice: number;
purchaseCount: number;
}
export interface SpendingByPeriod {
period: string;
totalSpent: number;
}

View file

@ -22,6 +22,12 @@ export interface CabinetItem {
unit: DosageUnit;
expirationDate?: Date;
status: CabinetItemStatus;
purchaseDate?: Date;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
notes?: string;
createdBy: string;
createdAt: Date;

View file

@ -3,3 +3,7 @@ export * from './household.js';
export * from './common.js';
export * from './medicine.js';
export * from './cabinet.js';
export * from './cabinet-event.js';
export * from './regimen.js';
export * from './organizer-fill.js';
export * from './burn-rate.js';

View file

@ -0,0 +1,31 @@
import type { OrganizerFillStatus } from '../enums/regimen.enums.js';
export interface OrganizerFill {
id: string;
householdId: string;
userId: string;
regimenId: string;
regimenName: string;
numberOfDays: number;
fillDate: Date;
items: OrganizerFillItem[];
status: OrganizerFillStatus;
notes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface OrganizerFillItem {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityTaken: number;
wasShort: boolean;
shortage: number;
deductions: OrganizerDeduction[];
}
export interface OrganizerDeduction {
cabinetItemId: string;
quantityTaken: number;
}

View file

@ -0,0 +1,31 @@
import type {
DosageFrequency,
TimeOfDay,
} from '../enums/regimen.enums.js';
import type { DosageUnit, MedicineForm, StrengthUnit } from '../enums/medicine.enums.js';
export interface Regimen {
id: string;
householdId: string;
userId: string;
name: string;
isActive: boolean;
medications: RegimenMedication[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RegimenMedication {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm;
dosage: number;
dosageUnit: DosageUnit;
frequency: DosageFrequency;
customFrequencyPerDay?: number;
timeOfDay?: TimeOfDay;
instructions?: string;
}

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { DosageFrequency } from '../enums/regimen.enums.js';
import { getFrequencyMultiplier, calculateQuantityNeeded } from './frequency.js';
describe('getFrequencyMultiplier', () => {
it.each([
[DosageFrequency.DAILY, undefined, 1],
[DosageFrequency.TWICE_DAILY, undefined, 2],
[DosageFrequency.THREE_TIMES_DAILY, undefined, 3],
[DosageFrequency.WEEKLY, undefined, 1 / 7],
[DosageFrequency.EVERY_OTHER_DAY, undefined, 1 / 2],
[DosageFrequency.AS_NEEDED, undefined, 0],
[DosageFrequency.CUSTOM, 4, 4],
[DosageFrequency.CUSTOM, undefined, 0],
])('%s (customPerDay=%s) = %s', (frequency, custom, expected) => {
expect(getFrequencyMultiplier(frequency, custom)).toBe(expected);
});
});
describe('calculateQuantityNeeded', () => {
it('daily: 2 pills * 7 days = 14', () => {
expect(calculateQuantityNeeded(2, DosageFrequency.DAILY, 7)).toBe(14);
});
it('twice_daily: 1 pill * 2 * 7 days = 14', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.TWICE_DAILY, 7)).toBe(14);
});
it('three_times_daily: 1 pill * 3 * 7 days = 21', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.THREE_TIMES_DAILY, 7)).toBe(21);
});
it('weekly: 1 pill * ceil(7/7) = 1', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.WEEKLY, 7)).toBe(1);
});
it('weekly: 2 pills * ceil(10/7) = 4', () => {
expect(calculateQuantityNeeded(2, DosageFrequency.WEEKLY, 10)).toBe(4);
});
it('every_other_day: 1 pill * ceil(7/2) = 4', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.EVERY_OTHER_DAY, 7)).toBe(4);
});
it('every_other_day: 1 pill * ceil(6/2) = 3', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.EVERY_OTHER_DAY, 6)).toBe(3);
});
it('as_needed: always 0', () => {
expect(calculateQuantityNeeded(5, DosageFrequency.AS_NEEDED, 30)).toBe(0);
});
it('custom: 1 pill * 4/day * 7 days = 28', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.CUSTOM, 7, 4)).toBe(28);
});
it('custom without customFrequencyPerDay: 0', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.CUSTOM, 7)).toBe(0);
});
it('handles fractional dosage: 0.5 pill * 7 days = 3.5', () => {
expect(calculateQuantityNeeded(0.5, DosageFrequency.DAILY, 7)).toBe(3.5);
});
it('single day daily: 1 pill', () => {
expect(calculateQuantityNeeded(1, DosageFrequency.DAILY, 1)).toBe(1);
});
});

View file

@ -0,0 +1,63 @@
import { DosageFrequency } from '../enums/regimen.enums.js';
/**
* Returns the daily frequency multiplier for a given dosage frequency.
* For example, TWICE_DAILY returns 2, WEEKLY returns 1/7.
* AS_NEEDED returns 0 (excluded from calculations).
*/
export function getFrequencyMultiplier(
frequency: DosageFrequency,
customPerDay?: number,
): number {
switch (frequency) {
case DosageFrequency.DAILY:
return 1;
case DosageFrequency.TWICE_DAILY:
return 2;
case DosageFrequency.THREE_TIMES_DAILY:
return 3;
case DosageFrequency.WEEKLY:
return 1 / 7;
case DosageFrequency.EVERY_OTHER_DAY:
return 1 / 2;
case DosageFrequency.AS_NEEDED:
return 0;
case DosageFrequency.CUSTOM:
return customPerDay ?? 0;
}
}
/**
* Calculate total quantity needed for N days based on dosage and frequency.
*
* daily: dosage * numberOfDays
* twice_daily: dosage * 2 * numberOfDays
* three_times_daily: dosage * 3 * numberOfDays
* weekly: dosage * ceil(numberOfDays / 7)
* every_other_day: dosage * ceil(numberOfDays / 2)
* as_needed: 0 (excluded from organizer fills)
* custom: dosage * customFrequencyPerDay * numberOfDays
*/
export function calculateQuantityNeeded(
dosage: number,
frequency: DosageFrequency,
numberOfDays: number,
customFrequencyPerDay?: number,
): number {
switch (frequency) {
case DosageFrequency.DAILY:
return dosage * numberOfDays;
case DosageFrequency.TWICE_DAILY:
return dosage * 2 * numberOfDays;
case DosageFrequency.THREE_TIMES_DAILY:
return dosage * 3 * numberOfDays;
case DosageFrequency.WEEKLY:
return dosage * Math.ceil(numberOfDays / 7);
case DosageFrequency.EVERY_OTHER_DAY:
return dosage * Math.ceil(numberOfDays / 2);
case DosageFrequency.AS_NEEDED:
return 0;
case DosageFrequency.CUSTOM:
return dosage * (customFrequencyPerDay ?? 0) * numberOfDays;
}
}

View file

@ -0,0 +1 @@
export * from './frequency.js';

View file

@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { CabinetEventType } from '../enums/cabinet-event.enums.js';
import {
CabinetEventQuerySchema,
SpendingSummaryQuerySchema,
DiscardCabinetItemSchema,
} from './cabinet-event.schemas.js';
describe('CabinetEventQuerySchema', () => {
it('applies default limit', () => {
const result = CabinetEventQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts all filters', () => {
const result = CabinetEventQuerySchema.safeParse({
medicineId: 'med-1',
eventType: CabinetEventType.PURCHASED,
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-12-31T00:00:00.000Z',
cursor: 'abc',
limit: 50,
});
expect(result.success).toBe(true);
});
it('rejects invalid eventType', () => {
const result = CabinetEventQuerySchema.safeParse({ eventType: 'invalid' });
expect(result.success).toBe(false);
});
it('rejects limit over 100', () => {
const result = CabinetEventQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});
describe('SpendingSummaryQuerySchema', () => {
it('defaults period to month', () => {
const result = SpendingSummaryQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.period).toBe('month');
}
});
it('accepts quarter period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'quarter' });
expect(result.success).toBe(true);
});
it('accepts year period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'year' });
expect(result.success).toBe(true);
});
it('rejects invalid period', () => {
const result = SpendingSummaryQuerySchema.safeParse({ period: 'week' });
expect(result.success).toBe(false);
});
it('accepts optional medicineId and date range', () => {
const result = SpendingSummaryQuerySchema.safeParse({
medicineId: 'med-1',
startDate: '2026-01-01T00:00:00.000Z',
endDate: '2026-06-30T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
});
describe('DiscardCabinetItemSchema', () => {
it('accepts valid input', () => {
const result = DiscardCabinetItemSchema.safeParse({
reason: 'expired',
notes: 'Found during cleanup',
});
expect(result.success).toBe(true);
});
it('accepts reason only', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: 'damaged' });
expect(result.success).toBe(true);
});
it('rejects empty reason', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: '' });
expect(result.success).toBe(false);
});
it('rejects missing reason', () => {
const result = DiscardCabinetItemSchema.safeParse({});
expect(result.success).toBe(false);
});
it('trims reason', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: ' expired ' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe('expired');
}
});
it('rejects reason over 500 chars', () => {
const result = DiscardCabinetItemSchema.safeParse({ reason: 'x'.repeat(501) });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,87 @@
import { z } from 'zod/v4';
import { CabinetEventType } from '../enums/cabinet-event.enums.js';
// --- Query ---
export const CabinetEventQuerySchema = z.object({
medicineId: z.string().optional(),
eventType: z.nativeEnum(CabinetEventType).optional(),
startDate: z.iso.datetime().optional(),
endDate: z.iso.datetime().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const SpendingSummaryQuerySchema = z.object({
period: z.enum(['month', 'quarter', 'year']).default('month'),
medicineId: z.string().optional(),
startDate: z.iso.datetime().optional(),
endDate: z.iso.datetime().optional(),
});
// --- Discard ---
export const DiscardCabinetItemSchema = z.object({
reason: z.string().min(1).max(500).trim(),
notes: z.string().max(1000).trim().optional(),
});
// --- Response ---
export const CabinetEventResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
cabinetItemId: z.string(),
medicineId: z.string(),
medicineName: z.string(),
eventType: z.string(),
quantity: z.number(),
quantityBefore: z.number(),
quantityAfter: z.number(),
unitPrice: z.number().optional(),
totalPrice: z.number().optional(),
currency: z.string().optional(),
storeId: z.string().optional(),
storeName: z.string().optional(),
sourceType: z.string(),
sourceId: z.string().optional(),
reason: z.string().optional(),
notes: z.string().optional(),
createdAt: z.string(),
});
export const CabinetEventListResponseSchema = z.object({
data: z.array(CabinetEventResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const SpendingByMedicineSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
totalSpent: z.number(),
totalQuantity: z.number(),
avgUnitPrice: z.number(),
purchaseCount: z.number(),
});
export const SpendingByPeriodSchema = z.object({
period: z.string(),
totalSpent: z.number(),
});
export const SpendingSummaryResponseSchema = z.object({
totalSpent: z.number(),
currency: z.string().nullable(),
byMedicine: z.array(SpendingByMedicineSchema),
byPeriod: z.array(SpendingByPeriodSchema),
});
// Type exports
export type CabinetEventQueryInput = z.infer<typeof CabinetEventQuerySchema>;
export type SpendingSummaryQueryInput = z.infer<typeof SpendingSummaryQuerySchema>;
export type DiscardCabinetItemInput = z.infer<typeof DiscardCabinetItemSchema>;

View file

@ -10,6 +10,12 @@ export const CreateCabinetItemSchema = z.object({
quantity: z.number().nonnegative(),
unit: z.nativeEnum(DosageUnit),
expirationDate: z.iso.datetime().optional(),
purchaseDate: z.iso.datetime().optional(),
unitPrice: z.number().nonnegative().optional(),
totalPrice: z.number().nonnegative().optional(),
currency: z.string().max(10).trim().optional(),
storeId: z.string().min(1).optional(),
storeName: z.string().max(200).trim().optional(),
notes: z.string().max(1000).trim().optional(),
});
@ -50,6 +56,12 @@ export const CabinetItemResponseSchema = z.object({
unit: z.string(),
expirationDate: z.string().optional(),
status: z.string(),
purchaseDate: z.string().optional(),
unitPrice: z.number().optional(),
totalPrice: z.number().optional(),
currency: z.string().optional(),
storeId: z.string().optional(),
storeName: z.string().optional(),
notes: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),

View file

@ -2,3 +2,6 @@ export * from './user.schemas.js';
export * from './household.schemas.js';
export * from './medicine.schemas.js';
export * from './cabinet.schemas.js';
export * from './cabinet-event.schemas.js';
export * from './regimen.schemas.js';
export * from './organizer.schemas.js';

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { OrganizerFillStatus } from '../enums/regimen.enums.js';
import {
OrganizerPreviewSchema,
OrganizerFillSchema,
OrganizerFillQuerySchema,
} from './organizer.schemas.js';
describe('OrganizerPreviewSchema', () => {
it('accepts valid input', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 7,
});
expect(result.success).toBe(true);
});
it('defaults numberOfDays to 7', () => {
const result = OrganizerPreviewSchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.numberOfDays).toBe(7);
}
});
it('rejects missing regimenId', () => {
const result = OrganizerPreviewSchema.safeParse({ numberOfDays: 7 });
expect(result.success).toBe(false);
});
it('rejects numberOfDays over 90', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 91,
});
expect(result.success).toBe(false);
});
it('rejects numberOfDays of 0', () => {
const result = OrganizerPreviewSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 0,
});
expect(result.success).toBe(false);
});
});
describe('OrganizerFillSchema', () => {
it('accepts valid input', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: true,
});
expect(result.success).toBe(true);
});
it('defaults allowPartial to false', () => {
const result = OrganizerFillSchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.allowPartial).toBe(false);
}
});
it('accepts optional notes', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
notes: 'Weekly fill',
});
expect(result.success).toBe(true);
});
it('rejects notes over 1000 chars', () => {
const result = OrganizerFillSchema.safeParse({
regimenId: 'reg-1',
notes: 'x'.repeat(1001),
});
expect(result.success).toBe(false);
});
});
describe('OrganizerFillQuerySchema', () => {
it('applies default limit', () => {
const result = OrganizerFillQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('accepts status filter', () => {
const result = OrganizerFillQuerySchema.safeParse({
status: OrganizerFillStatus.COMPLETED,
});
expect(result.success).toBe(true);
});
it('accepts regimenId filter', () => {
const result = OrganizerFillQuerySchema.safeParse({ regimenId: 'reg-1' });
expect(result.success).toBe(true);
});
it('rejects invalid status', () => {
const result = OrganizerFillQuerySchema.safeParse({ status: 'invalid' });
expect(result.success).toBe(false);
});
it('rejects limit over 100', () => {
const result = OrganizerFillQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,93 @@
import { z } from 'zod/v4';
import { OrganizerFillStatus } from '../enums/regimen.enums.js';
// --- Request ---
export const OrganizerPreviewSchema = z.object({
regimenId: z.string().min(1),
numberOfDays: z.number().int().min(1).max(90).default(7),
});
export const OrganizerFillSchema = z.object({
regimenId: z.string().min(1),
numberOfDays: z.number().int().min(1).max(90).default(7),
allowPartial: z.boolean().default(false),
notes: z.string().max(1000).trim().optional(),
});
export const OrganizerFillQuerySchema = z.object({
regimenId: z.string().optional(),
status: z.nativeEnum(OrganizerFillStatus).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// --- Response ---
export const OrganizerDeductionResponseSchema = z.object({
cabinetItemId: z.string(),
quantityTaken: z.number(),
});
export const OrganizerPreviewItemSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
quantityNeeded: z.number(),
quantityAvailable: z.number(),
isShort: z.boolean(),
shortage: z.number(),
cabinetBreakdown: z.array(
z.object({
cabinetItemId: z.string(),
expirationDate: z.string().nullable(),
quantityToTake: z.number(),
}),
),
});
export const OrganizerPreviewResponseSchema = z.object({
regimenName: z.string(),
numberOfDays: z.number(),
items: z.array(OrganizerPreviewItemSchema),
canFillCompletely: z.boolean(),
hasShortages: z.boolean(),
});
export const OrganizerFillItemResponseSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
quantityNeeded: z.number(),
quantityTaken: z.number(),
wasShort: z.boolean(),
shortage: z.number(),
deductions: z.array(OrganizerDeductionResponseSchema),
});
export const OrganizerFillResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
regimenId: z.string(),
regimenName: z.string(),
numberOfDays: z.number(),
fillDate: z.string(),
items: z.array(OrganizerFillItemResponseSchema),
status: z.string(),
notes: z.string().optional(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const OrganizerFillListResponseSchema = z.object({
data: z.array(OrganizerFillResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
// Type exports
export type OrganizerPreviewInput = z.infer<typeof OrganizerPreviewSchema>;
export type OrganizerFillInput = z.infer<typeof OrganizerFillSchema>;
export type OrganizerFillQueryInput = z.infer<typeof OrganizerFillQuerySchema>;

View file

@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { DosageUnit } from '../enums/medicine.enums.js';
import { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
import {
CreateRegimenSchema,
UpdateRegimenSchema,
RegimenQuerySchema,
RegimenMedicationInputSchema,
} from './regimen.schemas.js';
const validMedication = {
medicineId: 'med-1',
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
};
describe('RegimenMedicationInputSchema', () => {
it('accepts valid medication', () => {
const result = RegimenMedicationInputSchema.safeParse(validMedication);
expect(result.success).toBe(true);
});
it('accepts full medication with optional fields', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
timeOfDay: TimeOfDay.MORNING,
instructions: 'Take with food',
});
expect(result.success).toBe(true);
});
it('accepts custom frequency with customFrequencyPerDay', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
frequency: DosageFrequency.CUSTOM,
customFrequencyPerDay: 4,
});
expect(result.success).toBe(true);
});
it('rejects custom frequency without customFrequencyPerDay', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
frequency: DosageFrequency.CUSTOM,
});
expect(result.success).toBe(false);
});
it('rejects zero dosage', () => {
const result = RegimenMedicationInputSchema.safeParse({
...validMedication,
dosage: 0,
});
expect(result.success).toBe(false);
});
it('rejects missing medicineId', () => {
const { medicineId: _, ...rest } = validMedication;
const result = RegimenMedicationInputSchema.safeParse(rest);
expect(result.success).toBe(false);
});
});
describe('CreateRegimenSchema', () => {
const validRegimen = {
name: 'Daily medications',
medications: [validMedication],
};
it('accepts valid input', () => {
const result = CreateRegimenSchema.safeParse(validRegimen);
expect(result.success).toBe(true);
});
it('defaults isActive to true', () => {
const result = CreateRegimenSchema.safeParse(validRegimen);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(true);
}
});
it('accepts isActive override', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, isActive: false });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(false);
}
});
it('rejects empty name', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, name: '' });
expect(result.success).toBe(false);
});
it('rejects empty medications array', () => {
const result = CreateRegimenSchema.safeParse({ ...validRegimen, medications: [] });
expect(result.success).toBe(false);
});
it('accepts multiple medications', () => {
const result = CreateRegimenSchema.safeParse({
...validRegimen,
medications: [
validMedication,
{ ...validMedication, medicineId: 'med-2', dosage: 2 },
],
});
expect(result.success).toBe(true);
});
});
describe('UpdateRegimenSchema', () => {
it('accepts partial update', () => {
const result = UpdateRegimenSchema.safeParse({ name: 'Updated name' });
expect(result.success).toBe(true);
});
it('accepts empty object', () => {
const result = UpdateRegimenSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts isActive toggle', () => {
const result = UpdateRegimenSchema.safeParse({ isActive: false });
expect(result.success).toBe(true);
});
it('rejects empty medications array when provided', () => {
const result = UpdateRegimenSchema.safeParse({ medications: [] });
expect(result.success).toBe(false);
});
});
describe('RegimenQuerySchema', () => {
it('applies default limit', () => {
const result = RegimenQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
}
});
it('parses isActive string to boolean', () => {
const result = RegimenQuerySchema.safeParse({ isActive: 'true' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.isActive).toBe(true);
}
});
it('rejects limit over 100', () => {
const result = RegimenQuerySchema.safeParse({ limit: 101 });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,105 @@
import { z } from 'zod/v4';
import { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
import { DosageUnit } from '../enums/medicine.enums.js';
// --- RegimenMedication (embedded) ---
export const RegimenMedicationInputSchema = z
.object({
medicineId: z.string().min(1),
dosage: z.number().positive(),
dosageUnit: z.nativeEnum(DosageUnit),
frequency: z.nativeEnum(DosageFrequency),
customFrequencyPerDay: z.number().int().positive().optional(),
timeOfDay: z.nativeEnum(TimeOfDay).optional(),
instructions: z.string().max(500).trim().optional(),
})
.refine(
(data) =>
data.frequency !== DosageFrequency.CUSTOM ||
data.customFrequencyPerDay !== undefined,
{ message: 'customFrequencyPerDay is required when frequency is custom' },
);
// --- Regimen ---
export const CreateRegimenSchema = z.object({
name: z.string().min(1).max(200).trim(),
isActive: z.boolean().default(true),
medications: z.array(RegimenMedicationInputSchema).min(1),
});
export const UpdateRegimenSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
isActive: z.boolean().optional(),
medications: z.array(RegimenMedicationInputSchema).min(1).optional(),
});
export const RegimenQuerySchema = z.object({
isActive: z
.string()
.transform((v) => v === 'true')
.optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// --- Response ---
export const RegimenMedicationResponseSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
medicineStrength: z.number(),
medicineStrengthUnit: z.string(),
medicineForm: z.string(),
dosage: z.number(),
dosageUnit: z.string(),
frequency: z.string(),
customFrequencyPerDay: z.number().optional(),
timeOfDay: z.string().optional(),
instructions: z.string().optional(),
});
export const RegimenResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
userId: z.string(),
name: z.string(),
isActive: z.boolean(),
medications: z.array(RegimenMedicationResponseSchema),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const RegimenListResponseSchema = z.object({
data: z.array(RegimenResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export const BurnRateItemSchema = z.object({
medicineId: z.string(),
medicineName: z.string(),
dailyConsumption: z.number(),
totalInCabinet: z.number(),
daysUntilEmpty: z.number().nullable(),
earliestExpiry: z.string().nullable(),
avgUnitPrice: z.number().nullable(),
projectedDailyCost: z.number().nullable(),
projectedMonthlyCost: z.number().nullable(),
projectedYearlyCost: z.number().nullable(),
currency: z.string().nullable(),
});
export const BurnRateResponseSchema = z.object({
data: z.array(BurnRateItemSchema),
});
// Type exports
export type CreateRegimenInput = z.infer<typeof CreateRegimenSchema>;
export type UpdateRegimenInput = z.infer<typeof UpdateRegimenSchema>;
export type RegimenQueryInput = z.infer<typeof RegimenQuerySchema>;

View file

@ -0,0 +1,420 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events';
import { listMedicines } from '@/services/medicines';
import { CabinetEventType } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
CabinetEventResponseSchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
type MedicineOption = {
_id: string;
name: string;
};
const EVENT_TYPE_LABELS: Record<string, string> = {
purchased: 'Purchased',
consumed: 'Consumed',
adjusted: 'Adjusted',
discarded: 'Discarded',
restored: 'Restored',
deleted: 'Deleted',
};
const EVENT_TYPE_COLORS: Record<string, string> = {
purchased: 'bg-green-100 text-green-700',
consumed: 'bg-blue-100 text-blue-700',
adjusted: 'bg-yellow-100 text-yellow-700',
discarded: 'bg-red-100 text-red-700',
restored: 'bg-purple-100 text-purple-700',
deleted: 'bg-gray-100 text-gray-600',
};
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
function formatQuantityChange(event: CabinetEvent): string {
const sign = event.quantity > 0 ? '+' : '';
return `${sign}${event.quantity}`;
}
function QuantityBadge({ quantity }: { quantity: number }) {
const isPositive = quantity > 0;
return (
<span
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
>
{isPositive ? '+' : ''}
{quantity}
</span>
);
}
// --- Spending summary ---
function SpendingSummaryView({
householdId,
medicines,
}: {
householdId: string;
medicines: MedicineOption[];
}) {
const [summary, setSummary] = useState<SpendingSummary | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState<'month' | 'quarter' | 'year'>('month');
const [medicineId, setMedicineId] = useState('');
const [error, setError] = useState('');
const fetchSummary = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getSpendingSummary(householdId, {
period,
medicineId: medicineId || undefined,
});
setSummary(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load spending summary');
} finally {
setLoading(false);
}
}, [householdId, period, medicineId]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-lg font-semibold">Spending Summary</h2>
<div className="flex items-center gap-2">
<select
value={period}
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
<select
value={medicineId}
onChange={(e) => setMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All medicines</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-6 w-32 rounded bg-gray-200" />
<div className="h-20 rounded bg-gray-200" />
</div>
) : summary && summary.totalSpent > 0 ? (
<div className="space-y-5">
<div className="text-2xl font-bold text-gray-900">
{summary.currency ? `${summary.currency} ` : ''}
{summary.totalSpent.toFixed(2)}
<span className="text-sm font-normal text-gray-500 ml-2">total spent</span>
</div>
{summary.byMedicine.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">By medicine</h3>
<div className="space-y-2">
{summary.byMedicine.map((item) => (
<div
key={item.medicineId}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<span className="text-sm font-medium text-gray-900">
{item.medicineName}
</span>
<span className="ml-2 text-xs text-gray-500">
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} &bull;{' '}
avg {summary.currency ? `${summary.currency} ` : ''}
{item.avgUnitPrice.toFixed(2)}/unit
</span>
</div>
<span className="text-sm font-semibold text-gray-800">
{summary.currency ? `${summary.currency} ` : ''}
{item.totalSpent.toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
{summary.byPeriod.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2">By period</h3>
<div className="space-y-2">
{summary.byPeriod.map((item) => (
<div
key={item.period}
className="flex items-center justify-between rounded-lg border p-3"
>
<span className="text-sm text-gray-700">{item.period}</span>
<span className="text-sm font-semibold text-gray-800">
{summary.currency ? `${summary.currency} ` : ''}
{item.totalSpent.toFixed(2)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500 py-4 text-center">
No purchase data found for this period.
</p>
)}
</div>
);
}
// --- Event timeline ---
function EventTimeline({
householdId,
medicines,
}: {
householdId: string;
medicines: MedicineOption[];
}) {
const [events, setEvents] = useState<CabinetEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterEventType, setFilterEventType] = useState('');
const [filterMedicineId, setFilterMedicineId] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const fetchEvents = useCallback(
async (append = false) => {
if (!append) setLoading(true);
setError('');
try {
const result = await listCabinetEvents(householdId, {
eventType: filterEventType || undefined,
medicineId: filterMedicineId || undefined,
startDate: startDate ? new Date(startDate).toISOString() : undefined,
endDate: endDate ? new Date(endDate).toISOString() : undefined,
cursor: append ? (cursor ?? undefined) : undefined,
limit: 20,
});
setEvents((prev) => (append ? [...prev, ...result.data] : result.data));
setCursor(result.pagination.cursor);
setHasMore(result.pagination.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load events');
} finally {
setLoading(false);
}
},
[householdId, filterEventType, filterMedicineId, startDate, endDate, cursor],
);
// Refetch from scratch when filters change
useEffect(() => {
setCursor(null);
setEvents([]);
fetchEvents(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select
value={filterEventType}
onChange={(e) => setFilterEventType(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All event types</option>
{Object.values(CabinetEventType).map((t) => (
<option key={t} value={t}>
{EVENT_TYPE_LABELS[t] ?? t}
</option>
))}
</select>
<select
value={filterMedicineId}
onChange={(e) => setFilterMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All medicines</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name}
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
title="Start date"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
title="End date"
/>
{(filterEventType || filterMedicineId || startDate || endDate) && (
<button
onClick={() => {
setFilterEventType('');
setFilterMedicineId('');
setStartDate('');
setEndDate('');
}}
className="text-sm text-gray-500 underline"
>
Clear filters
</button>
)}
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse h-14 rounded-lg bg-gray-200" />
))}
</div>
) : events.length === 0 ? (
<p className="text-sm text-center text-gray-500 py-6">
No events found for the selected filters.
</p>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-200" />
<div className="space-y-4 pl-10">
{events.map((event) => (
<div key={event._id} className="relative">
{/* Dot */}
<div
className={`absolute -left-6 top-2 h-3 w-3 rounded-full border-2 border-white ${
event.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
}`}
/>
<div className="rounded-lg border bg-gray-50 p-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
>
{EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
</span>
<span className="text-sm font-medium text-gray-900">
{event.medicineName}
</span>
<QuantityBadge quantity={event.quantity} />
<span className="text-xs text-gray-500">
{event.quantityBefore} &rarr; {event.quantityAfter}
</span>
</div>
<span className="text-xs text-gray-400 shrink-0">
{formatDateTime(event.createdAt)}
</span>
</div>
{(event.reason || event.notes || event.storeName || event.totalPrice) && (
<div className="mt-1 flex flex-wrap gap-3 text-xs text-gray-500">
{event.reason && <span>Reason: {event.reason}</span>}
{event.storeName && <span>Store: {event.storeName}</span>}
{event.totalPrice && (
<span>
{event.currency ? `${event.currency} ` : ''}
{event.totalPrice.toFixed(2)}
</span>
)}
{event.notes && <span>{event.notes}</span>}
</div>
)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-4 text-center">
<button
onClick={() => fetchEvents(true)}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Load more
</button>
</div>
)}
</div>
)}
</div>
);
}
// --- Main component ---
export function ActivityTab({ householdId }: { householdId: string }) {
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
useEffect(() => {
listMedicines(householdId, { limit: 100 })
.then((r) =>
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
)
.catch(() => {});
}, [householdId]);
return (
<div className="space-y-6">
<SpendingSummaryView householdId={householdId} medicines={medicines} />
<EventTimeline householdId={householdId} medicines={medicines} />
</div>
);
}

View file

@ -0,0 +1,455 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listFills,
previewFill,
executeFill,
undoFill,
} from '@/services/organizer';
import { listRegimens } from '@/services/regimens';
import { OrganizerFillStatus } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
OrganizerFillResponseSchema,
OrganizerPreviewResponseSchema,
RegimenResponseSchema,
} from '@meshitrack/shared';
type OrganizerFill = z.infer<typeof OrganizerFillResponseSchema>;
type OrganizerPreview = z.infer<typeof OrganizerPreviewResponseSchema>;
type Regimen = z.infer<typeof RegimenResponseSchema>;
const STATUS_LABELS: Record<string, string> = {
completed: 'Completed',
partial: 'Partial',
reversed: 'Reversed',
};
const STATUS_COLORS: Record<string, string> = {
completed: 'bg-green-100 text-green-700',
partial: 'bg-yellow-100 text-yellow-700',
reversed: 'bg-gray-100 text-gray-500',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
// --- Preview result display ---
function PreviewResult({
preview,
onConfirm,
onCancel,
submitting,
allowPartial,
onTogglePartial,
}: {
preview: OrganizerPreview;
onConfirm: () => void;
onCancel: () => void;
submitting: boolean;
allowPartial: boolean;
onTogglePartial: (v: boolean) => void;
}) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
Preview: {preview.regimenName} &mdash; {preview.numberOfDays} day
{preview.numberOfDays !== 1 ? 's' : ''}
</h3>
{preview.hasShortages ? (
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
Shortages detected
</span>
) : (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
Ready to fill
</span>
)}
</div>
<div className="space-y-2">
{preview.items.map((item) => (
<div
key={item.medicineId}
className={`rounded-lg border p-3 ${item.isShort ? 'border-yellow-300 bg-yellow-50' : 'border-gray-200'}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">
Need: <strong>{item.quantityNeeded}</strong>
</span>
<span className="text-gray-500">
Available: <strong>{item.quantityAvailable}</strong>
</span>
{item.isShort && (
<span className="text-yellow-700 font-semibold">
Short: {item.shortage}
</span>
)}
</div>
</div>
{item.cabinetBreakdown.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{item.cabinetBreakdown.map((b, i) => (
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
{b.quantityToTake} units
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
</span>
))}
</div>
)}
</div>
))}
</div>
{preview.hasShortages && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allowPartial"
checked={allowPartial}
onChange={(e) => onTogglePartial(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="allowPartial" className="text-sm text-gray-700">
Allow partial fill (fill what is available)
</label>
</div>
)}
<div className="flex gap-3">
<button
onClick={onConfirm}
disabled={submitting || (preview.hasShortages && !allowPartial)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Filling...' : 'Confirm fill'}
</button>
<button
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Back
</button>
</div>
</div>
);
}
// --- Fill wizard ---
function FillWizard({
householdId,
regimens,
onFilled,
}: {
householdId: string;
regimens: Regimen[];
onFilled: () => void;
}) {
const [regimenId, setRegimenId] = useState('');
const [numberOfDays, setNumberOfDays] = useState(7);
const [notes, setNotes] = useState('');
const [allowPartial, setAllowPartial] = useState(false);
const [preview, setPreview] = useState<OrganizerPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [filling, setFilling] = useState(false);
const [error, setError] = useState('');
const activeRegimens = regimens.filter((r) => r.isActive);
async function handlePreview(e: React.FormEvent) {
e.preventDefault();
setError('');
setPreviewing(true);
try {
const result = await previewFill(householdId, { regimenId, numberOfDays });
setPreview(result);
setAllowPartial(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate preview');
} finally {
setPreviewing(false);
}
}
async function handleFill() {
setError('');
setFilling(true);
try {
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
setPreview(null);
setRegimenId('');
setNumberOfDays(7);
setNotes('');
setAllowPartial(false);
onFilled();
} catch (err) {
setError(err instanceof Error ? err.message : 'Fill failed');
setPreview(null);
} finally {
setFilling(false);
}
}
if (preview) {
return (
<>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<PreviewResult
preview={preview}
onConfirm={handleFill}
onCancel={() => setPreview(null)}
submitting={filling}
allowPartial={allowPartial}
onTogglePartial={setAllowPartial}
/>
</>
);
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{activeRegimens.length === 0 ? (
<p className="text-sm text-gray-500">
No active regimens found. Create and activate a regimen before filling.
</p>
) : (
<form onSubmit={handlePreview} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
<select
required
value={regimenId}
onChange={(e) => setRegimenId(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Select regimen...</option>
{activeRegimens.map((r) => (
<option key={r._id} value={r._id}>
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
<input
type="number"
required
min={1}
max={90}
value={numberOfDays}
onChange={(e) => setNumberOfDays(Number(e.target.value))}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this fill"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
</div>
<button
type="submit"
disabled={previewing}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{previewing ? 'Calculating...' : 'Preview fill'}
</button>
</form>
)}
</div>
);
}
// --- Fill history list ---
function FillHistory({
householdId,
refreshKey,
}: {
householdId: string;
refreshKey: number;
}) {
const [fills, setFills] = useState<OrganizerFill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const fetchFills = useCallback(async () => {
setLoading(true);
try {
const result = await listFills(householdId, {
status: filterStatus || undefined,
limit: 50,
});
setFills(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load fill history');
} finally {
setLoading(false);
}
}, [householdId, filterStatus]);
useEffect(() => {
fetchFills();
}, [fetchFills, refreshKey]);
async function handleUndo(fillId: string) {
if (!confirm('Reverse this fill? Cabinet quantities will be restored.')) return;
try {
const updated = await undoFill(householdId, fillId);
setFills((prev) => prev.map((f) => (f._id === updated._id ? updated : f)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to undo fill');
}
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Fill History</h2>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">All statuses</option>
{Object.values(OrganizerFillStatus).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</option>
))}
</select>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse h-16 rounded-lg bg-gray-200" />
))}
</div>
) : fills.length === 0 ? (
<p className="text-sm text-center text-gray-500 py-4">No fills recorded yet.</p>
) : (
<div className="space-y-3">
{fills.map((fill) => (
<div key={fill._id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{fill.regimenName}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
>
{STATUS_LABELS[fill.status] ?? fill.status}
</span>
</div>
<p className="text-sm text-gray-500">
{fill.numberOfDays} day{fill.numberOfDays !== 1 ? 's' : ''} &bull;{' '}
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} &bull;{' '}
{formatDate(fill.fillDate)}
</p>
{fill.notes && (
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
)}
<div className="flex flex-wrap gap-1 mt-2">
{fill.items.map((item, i) => (
<span
key={i}
className={`rounded-full px-2 py-0.5 text-xs ${
item.wasShort
? 'bg-yellow-50 text-yellow-700'
: 'bg-blue-50 text-blue-700'
}`}
>
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
{item.wasShort ? ' (short)' : ''}
</span>
))}
</div>
</div>
{fill.status !== OrganizerFillStatus.REVERSED && (
<button
onClick={() => handleUndo(fill._id)}
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
>
Undo
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// --- Main component ---
export function OrganizerTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [regimensLoading, setRegimensLoading] = useState(true);
const [fillRefreshKey, setFillRefreshKey] = useState(0);
useEffect(() => {
listRegimens(householdId, { limit: 100 })
.then((r) => setRegimens(r.data))
.catch(() => {})
.finally(() => setRegimensLoading(false));
}, [householdId]);
function handleFilled() {
setFillRefreshKey((k) => k + 1);
}
return (
<div className="space-y-6">
{regimensLoading ? (
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
) : (
<FillWizard
householdId={householdId}
regimens={regimens}
onFilled={handleFilled}
/>
)}
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
</div>
);
}

View file

@ -0,0 +1,722 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listRegimens,
createRegimen,
updateRegimen,
deleteRegimen,
getBurnRates,
} from '@/services/regimens';
import { listMedicines } from '@/services/medicines';
import {
DosageFrequency,
TimeOfDay,
DosageUnit,
MedicineForm,
allowedUnitsForForm,
defaultUnitForForm,
} from '@meshitrack/shared';
import type { CreateRegimenInput } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
RegimenResponseSchema,
RegimenMedicationInputSchema,
BurnRateItemSchema,
} from '@meshitrack/shared';
type Regimen = z.infer<typeof RegimenResponseSchema>;
type MedicationInput = z.infer<typeof RegimenMedicationInputSchema>;
type BurnRateItem = z.infer<typeof BurnRateItemSchema>;
type MedicineOption = {
_id: string;
name: string;
strength: number;
strengthUnit: string;
form: MedicineForm;
};
const FREQUENCY_LABELS: Record<string, string> = {
daily: 'Once daily',
twice_daily: 'Twice daily',
three_times_daily: 'Three times daily',
weekly: 'Weekly',
every_other_day: 'Every other day',
as_needed: 'As needed',
custom: 'Custom',
};
const TIME_LABELS: Record<string, string> = {
morning: 'Morning',
afternoon: 'Afternoon',
evening: 'Evening',
bedtime: 'Bedtime',
};
const FORM_LABELS: Record<string, string> = {
tablet: 'Tablet',
capsule: 'Capsule',
liquid: 'Liquid',
injection: 'Injection',
other: 'Other',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
// --- Medication sub-form row ---
function MedicationRow({
medication,
index,
medicines,
onChange,
onRemove,
}: {
medication: MedicationInput;
index: number;
medicines: MedicineOption[];
onChange: (index: number, updated: MedicationInput) => void;
onRemove: (index: number) => void;
}) {
const selectedMed = medicines.find((m) => m._id === medication.medicineId);
const allowedUnits = selectedMed
? allowedUnitsForForm(selectedMed.form)
: Object.values(DosageUnit);
function handleMedicineChange(medicineId: string) {
const med = medicines.find((m) => m._id === medicineId);
const newUnit = med ? defaultUnitForForm(med.form) : DosageUnit.TABLET;
onChange(index, { ...medication, medicineId, dosageUnit: newUnit });
}
return (
<div className="rounded-lg border bg-gray-50 p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-gray-600">Medication {index + 1}</span>
<button
type="button"
onClick={() => onRemove(index)}
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Remove medication"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
<select
required
value={medication.medicineId}
onChange={(e) => handleMedicineChange(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Select medicine...</option>
{medicines.map((m) => (
<option key={m._id} value={m._id}>
{m.name} {m.strength} {m.strengthUnit} ({FORM_LABELS[m.form] ?? m.form})
</option>
))}
</select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
<input
type="number"
required
min={0.01}
step="any"
value={medication.dosage || ''}
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
<select
value={medication.dosageUnit}
onChange={(e) =>
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{allowedUnits.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
<select
value={medication.frequency}
onChange={(e) =>
onChange(index, {
...medication,
frequency: e.target.value as DosageFrequency,
customFrequencyPerDay:
e.target.value === DosageFrequency.CUSTOM
? (medication.customFrequencyPerDay ?? 1)
: undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{Object.values(DosageFrequency).map((f) => (
<option key={f} value={f}>
{FREQUENCY_LABELS[f] ?? f}
</option>
))}
</select>
</div>
{medication.frequency === DosageFrequency.CUSTOM && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
<input
type="number"
required
min={1}
step={1}
value={medication.customFrequencyPerDay ?? ''}
onChange={(e) =>
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Time of day (optional)
</label>
<select
value={medication.timeOfDay ?? ''}
onChange={(e) =>
onChange(index, {
...medication,
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="">Any time</option>
{Object.values(TimeOfDay).map((t) => (
<option key={t} value={t}>
{TIME_LABELS[t] ?? t}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instructions (optional)
</label>
<input
type="text"
maxLength={500}
value={medication.instructions ?? ''}
onChange={(e) =>
onChange(index, { ...medication, instructions: e.target.value || undefined })
}
placeholder="e.g. Take with food"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
</div>
</div>
);
}
// --- Regimen create / edit form ---
function emptyMedication(): MedicationInput {
return {
medicineId: '',
dosage: 1,
dosageUnit: DosageUnit.TABLET,
frequency: DosageFrequency.DAILY,
};
}
function RegimenForm({
householdId,
medicines,
initial,
onSaved,
onCancel,
}: {
householdId: string;
medicines: MedicineOption[];
initial?: Regimen;
onSaved: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [isActive, setIsActive] = useState(initial?.isActive ?? true);
const [medications, setMedications] = useState<MedicationInput[]>(
initial?.medications.map((m) => ({
medicineId: m.medicineId,
dosage: m.dosage,
dosageUnit: m.dosageUnit as DosageUnit,
frequency: m.frequency as DosageFrequency,
customFrequencyPerDay: m.customFrequencyPerDay,
timeOfDay: m.timeOfDay as TimeOfDay | undefined,
instructions: m.instructions,
})) ?? [emptyMedication()],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
function updateMedication(index: number, updated: MedicationInput) {
setMedications((prev) => prev.map((m, i) => (i === index ? updated : m)));
}
function removeMedication(index: number) {
setMedications((prev) => prev.filter((_, i) => i !== index));
}
function addMedication() {
setMedications((prev) => [...prev, emptyMedication()]);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (medications.length === 0) {
setError('At least one medication is required.');
return;
}
setError('');
setSubmitting(true);
try {
const payload: CreateRegimenInput = { name: name.trim(), isActive, medications };
if (initial) {
await updateRegimen(householdId, initial._id, payload);
} else {
await createRegimen(householdId, payload);
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save regimen');
} finally {
setSubmitting(false);
}
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Morning routine"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Active
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
<button
type="button"
onClick={addMedication}
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
>
+ Add medication
</button>
</div>
{medications.length === 0 ? (
<p className="text-sm text-gray-500 italic">No medications added yet.</p>
) : (
<div className="space-y-3">
{medications.map((med, i) => (
<MedicationRow
key={i}
index={i}
medication={med}
medicines={medicines}
onChange={updateMedication}
onRemove={removeMedication}
/>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}
// --- Burn rate table ---
function BurnRateTable({ burnRates }: { burnRates: BurnRateItem[] }) {
if (burnRates.length === 0) {
return (
<p className="text-sm text-gray-500">
No active regimens with cabinet stock to calculate burn rates.
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="pb-2 font-medium">Medicine</th>
<th className="pb-2 font-medium text-right">Daily use</th>
<th className="pb-2 font-medium text-right">In cabinet</th>
<th className="pb-2 font-medium text-right">Days left</th>
<th className="pb-2 font-medium text-right">Monthly cost</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{burnRates.map((item) => {
const daysLeft = item.daysUntilEmpty;
const daysColor =
daysLeft === null
? 'text-gray-400'
: daysLeft <= 7
? 'text-red-600 font-semibold'
: daysLeft <= 30
? 'text-yellow-600'
: 'text-green-600';
return (
<tr key={item.medicineId} className="py-2">
<td className="py-2 font-medium text-gray-900">{item.medicineName}</td>
<td className="py-2 text-right text-gray-600">
{item.dailyConsumption.toFixed(2)}
</td>
<td className="py-2 text-right text-gray-600">{item.totalInCabinet}</td>
<td className={`py-2 text-right ${daysColor}`}>
{daysLeft !== null ? daysLeft : '-'}
</td>
<td className="py-2 text-right text-gray-600">
{item.projectedMonthlyCost !== null
? `${item.currency ?? ''} ${item.projectedMonthlyCost.toFixed(2)}`.trim()
: '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// --- Main component ---
export function RegimensTab({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [medicines, setMedicines] = useState<MedicineOption[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingRegimen, setEditingRegimen] = useState<Regimen | null>(null);
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
const [burnRates, setBurnRates] = useState<BurnRateItem[]>([]);
const [showBurnRate, setShowBurnRate] = useState(false);
const [burnRateLoading, setBurnRateLoading] = useState(false);
const fetchRegimens = useCallback(async () => {
setLoading(true);
try {
const query =
filterActive === 'active'
? { isActive: true }
: filterActive === 'inactive'
? { isActive: false }
: {};
const result = await listRegimens(householdId, { ...query, limit: 50 });
setRegimens(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load regimens');
} finally {
setLoading(false);
}
}, [householdId, filterActive]);
useEffect(() => {
fetchRegimens();
}, [fetchRegimens]);
// Medicines are needed for the form
useEffect(() => {
listMedicines(householdId, { limit: 100 })
.then((r) => setMedicines(r.data as MedicineOption[]))
.catch(() => {});
}, [householdId]);
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete regimen "${name}"?`)) return;
try {
await deleteRegimen(householdId, id);
setRegimens((prev) => prev.filter((r) => r._id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete');
}
}
async function handleToggleActive(regimen: Regimen) {
try {
const updated = await updateRegimen(householdId, regimen._id, {
isActive: !regimen.isActive,
});
setRegimens((prev) => prev.map((r) => (r._id === regimen._id ? updated : r)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update');
}
}
async function handleShowBurnRate() {
setShowBurnRate((prev) => !prev);
if (!showBurnRate && burnRates.length === 0) {
setBurnRateLoading(true);
try {
const result = await getBurnRates(householdId);
setBurnRates(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load burn rates');
} finally {
setBurnRateLoading(false);
}
}
}
const isFormOpen = showForm || editingRegimen !== null;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<select
value={filterActive}
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
<option value="all">All regimens</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button
onClick={handleShowBurnRate}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
</button>
</div>
<button
onClick={() => {
setEditingRegimen(null);
setShowForm(!showForm);
}}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
{showForm ? 'Cancel' : 'New Regimen'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{showBurnRate && (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Burn Rate &amp; Spending Projections</h2>
{burnRateLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-6 w-full rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
) : (
<BurnRateTable burnRates={burnRates} />
)}
</div>
)}
{showForm && !editingRegimen && (
<RegimenForm
householdId={householdId}
medicines={medicines}
onSaved={() => {
setShowForm(false);
fetchRegimens();
setBurnRates([]);
}}
onCancel={() => setShowForm(false)}
/>
)}
{editingRegimen && (
<RegimenForm
householdId={householdId}
medicines={medicines}
initial={editingRegimen}
onSaved={() => {
setEditingRegimen(null);
fetchRegimens();
setBurnRates([]);
}}
onCancel={() => setEditingRegimen(null)}
/>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl border bg-white p-4 h-24" />
))}
</div>
) : regimens.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
{filterActive !== 'all'
? `No ${filterActive} regimens found.`
: isFormOpen
? null
: 'No regimens yet. Create your first medication schedule above.'}
</div>
) : (
<div className="space-y-3">
{regimens.map((regimen) => (
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
regimen.isActive
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{regimen.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-sm text-gray-500 mb-2">
{regimen.medications.length} medication
{regimen.medications.length !== 1 ? 's' : ''}
</p>
<div className="flex flex-wrap gap-1">
{regimen.medications.map((med, i) => (
<span
key={i}
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
>
{med.medicineName} {med.dosage} {med.dosageUnit} (
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
</span>
))}
</div>
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleToggleActive(regimen)}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
title={regimen.isActive ? 'Deactivate' : 'Activate'}
>
{regimen.isActive ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={() => {
setShowForm(false);
setEditingRegimen(regimen);
}}
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
title="Edit"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => handleDelete(regimen._id, regimen.name)}
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { ActivityTab } from '../ActivityTab';
export default function ActivityPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before viewing cabinet activity.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
</div>
<ActivityTab householdId={householdId} />
</div>
);
}

View file

@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { OrganizerTab } from '../OrganizerTab';
export default function OrganizerPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before using the pill organizer.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Pill Organizer</h1>
</div>
<OrganizerTab householdId={householdId} />
</div>
);
}

View file

@ -41,6 +41,21 @@ export default function MedicinesPage() {
description="Track your medicine inventory, quantities and expiry dates"
href="/medicines/cabinet"
/>
<SectionCard
title="Regimens"
description="Define daily medication schedules and track dosage frequency"
href="/medicines/regimens"
/>
<SectionCard
title="Organizer"
description="Fill your pill organizer and track cabinet usage"
href="/medicines/organizer"
/>
<SectionCard
title="Activity"
description="View cabinet event history and spending summaries"
href="/medicines/activity"
/>
</div>
</div>
);
@ -73,6 +88,9 @@ function PageSkeleton() {
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
</div>
);

View file

@ -0,0 +1,52 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { RegimensTab } from '../RegimensTab';
export default function RegimensPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
</div>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing regimens.
</p>
</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Regimens</h1>
</div>
<RegimensTab householdId={householdId} />
</div>
);
}

View file

@ -0,0 +1,60 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
CabinetEventListResponseSchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
type CabinetEventListResponse = z.infer<typeof CabinetEventListResponseSchema>;
type SpendingSummaryResponse = z.infer<typeof SpendingSummaryResponseSchema>;
export async function listCabinetEvents(
householdId: string,
query?: {
medicineId?: string;
eventType?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
},
): Promise<CabinetEventListResponse> {
const params = new URLSearchParams();
if (query?.medicineId) params.set('medicineId', query.medicineId);
if (query?.eventType) params.set('eventType', query.eventType);
if (query?.startDate) params.set('startDate', query.startDate);
if (query?.endDate) params.set('endDate', query.endDate);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetEventListResponse>(
`/households/${householdId}/cabinet-events${qs ? `?${qs}` : ''}`,
);
}
export async function getEventsByItem(
householdId: string,
cabinetItemId: string,
query?: { cursor?: string; limit?: number },
): Promise<CabinetEventListResponse> {
const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<CabinetEventListResponse>(
`/households/${householdId}/cabinet-events/by-item/${cabinetItemId}${qs ? `?${qs}` : ''}`,
);
}
export async function getSpendingSummary(
householdId: string,
query?: { period?: string; medicineId?: string },
): Promise<SpendingSummaryResponse> {
const params = new URLSearchParams();
if (query?.period) params.set('period', query.period);
if (query?.medicineId) params.set('medicineId', query.medicineId);
const qs = params.toString();
return apiClient.get<SpendingSummaryResponse>(
`/households/${householdId}/cabinet-events/spending-summary${qs ? `?${qs}` : ''}`,
);
}

View file

@ -0,0 +1,55 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
OrganizerFillListResponseSchema,
OrganizerFillResponseSchema,
OrganizerPreviewResponseSchema,
OrganizerFillSchema,
OrganizerPreviewSchema,
} from '@meshitrack/shared';
type OrganizerFillListResponse = z.infer<typeof OrganizerFillListResponseSchema>;
type OrganizerFillResponse = z.infer<typeof OrganizerFillResponseSchema>;
type OrganizerPreviewResponse = z.infer<typeof OrganizerPreviewResponseSchema>;
type OrganizerFillInput = z.infer<typeof OrganizerFillSchema>;
type OrganizerPreviewInput = z.infer<typeof OrganizerPreviewSchema>;
export async function listFills(
householdId: string,
query?: { regimenId?: string; status?: string; cursor?: string; limit?: number },
): Promise<OrganizerFillListResponse> {
const params = new URLSearchParams();
if (query?.regimenId) params.set('regimenId', query.regimenId);
if (query?.status) params.set('status', query.status);
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<OrganizerFillListResponse>(
`/households/${householdId}/organizer/fills${qs ? `?${qs}` : ''}`,
);
}
export async function getFill(householdId: string, id: string): Promise<OrganizerFillResponse> {
return apiClient.get<OrganizerFillResponse>(`/households/${householdId}/organizer/fills/${id}`);
}
export async function previewFill(
householdId: string,
data: OrganizerPreviewInput,
): Promise<OrganizerPreviewResponse> {
return apiClient.post<OrganizerPreviewResponse>(`/households/${householdId}/organizer/preview`, data);
}
export async function executeFill(
householdId: string,
data: OrganizerFillInput,
): Promise<OrganizerFillResponse> {
return apiClient.post<OrganizerFillResponse>(`/households/${householdId}/organizer/fill`, data);
}
export async function undoFill(householdId: string, fillId: string): Promise<OrganizerFillResponse> {
return apiClient.post<OrganizerFillResponse>(
`/households/${householdId}/organizer/fills/${fillId}/undo`,
{},
);
}

View file

@ -0,0 +1,51 @@
import { apiClient } from './api-client';
import type { z } from 'zod/v4';
import type {
RegimenResponseSchema,
RegimenListResponseSchema,
BurnRateResponseSchema,
CreateRegimenSchema,
UpdateRegimenSchema,
} from '@meshitrack/shared';
type RegimenResponse = z.infer<typeof RegimenResponseSchema>;
type RegimenListResponse = z.infer<typeof RegimenListResponseSchema>;
type BurnRateResponse = z.infer<typeof BurnRateResponseSchema>;
type CreateRegimenInput = z.infer<typeof CreateRegimenSchema>;
type UpdateRegimenInput = z.infer<typeof UpdateRegimenSchema>;
export async function listRegimens(
householdId: string,
query?: { isActive?: boolean; cursor?: string; limit?: number },
): Promise<RegimenListResponse> {
const params = new URLSearchParams();
if (query?.isActive !== undefined) params.set('isActive', String(query.isActive));
if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString();
return apiClient.get<RegimenListResponse>(`/households/${householdId}/regimens${qs ? `?${qs}` : ''}`);
}
export async function getRegimen(householdId: string, id: string): Promise<RegimenResponse> {
return apiClient.get<RegimenResponse>(`/households/${householdId}/regimens/${id}`);
}
export async function getBurnRates(householdId: string): Promise<BurnRateResponse> {
return apiClient.get<BurnRateResponse>(`/households/${householdId}/regimens/burn-rate`);
}
export async function createRegimen(householdId: string, data: CreateRegimenInput): Promise<RegimenResponse> {
return apiClient.post<RegimenResponse>(`/households/${householdId}/regimens`, data);
}
export async function updateRegimen(
householdId: string,
id: string,
data: UpdateRegimenInput,
): Promise<RegimenResponse> {
return apiClient.patch<RegimenResponse>(`/households/${householdId}/regimens/${id}`, data);
}
export async function deleteRegimen(householdId: string, id: string): Promise<void> {
return apiClient.delete<void>(`/households/${householdId}/regimens/${id}`);
}