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