Implement regimens

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,48 @@
import type {
CabinetEventType,
CabinetEventSourceType,
} from '../enums/cabinet-event.enums.js';
export interface CabinetEvent {
id: string;
householdId: string;
userId: string;
cabinetItemId: string;
medicineId: string;
medicineName: string;
eventType: CabinetEventType;
quantity: number;
quantityBefore: number;
quantityAfter: number;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
sourceType: CabinetEventSourceType;
sourceId?: string;
reason?: string;
notes?: string;
createdAt: Date;
}
export interface SpendingSummary {
totalSpent: number;
currency: string | null;
byMedicine: SpendingByMedicine[];
byPeriod: SpendingByPeriod[];
}
export interface SpendingByMedicine {
medicineId: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
avgUnitPrice: number;
purchaseCount: number;
}
export interface SpendingByPeriod {
period: string;
totalSpent: number;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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