Implement medicine library and cabinet
This commit is contained in:
parent
db79af06f7
commit
1f66fab30f
72 changed files with 7642 additions and 319 deletions
16
packages/shared/src/enums/cabinet.enums.test.ts
Normal file
16
packages/shared/src/enums/cabinet.enums.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { CabinetItemStatus } from './cabinet.enums.js';
|
||||
|
||||
describe(CabinetItemStatus.name, () => {
|
||||
it('has exactly 3 values', () => {
|
||||
expect(Object.values(CabinetItemStatus)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ACTIVE', 'active'],
|
||||
['DEPLETED', 'depleted'],
|
||||
['EXPIRED', 'expired'],
|
||||
])('%s = %s', (key, value) => {
|
||||
expect(CabinetItemStatus[key as keyof typeof CabinetItemStatus]).toBe(value);
|
||||
});
|
||||
});
|
||||
5
packages/shared/src/enums/cabinet.enums.ts
Normal file
5
packages/shared/src/enums/cabinet.enums.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum CabinetItemStatus {
|
||||
ACTIVE = 'active',
|
||||
DEPLETED = 'depleted',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
export * from './roles.enums.js';
|
||||
export * from './medicine.enums.js';
|
||||
export * from './cabinet.enums.js';
|
||||
|
|
|
|||
115
packages/shared/src/enums/medicine.enums.test.ts
Normal file
115
packages/shared/src/enums/medicine.enums.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
MedicineCategory,
|
||||
MedicineProductSource,
|
||||
DosageUnit,
|
||||
ConcentrationUnit,
|
||||
allowedUnitsForForm,
|
||||
defaultUnitForForm,
|
||||
} from './medicine.enums.js';
|
||||
|
||||
describe(MedicineForm.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(MedicineForm.TABLET).toBe('tablet');
|
||||
expect(MedicineForm.CAPSULE).toBe('capsule');
|
||||
expect(MedicineForm.LIQUID).toBe('liquid');
|
||||
expect(MedicineForm.INJECTION).toBe('injection');
|
||||
expect(MedicineForm.OTHER).toBe('other');
|
||||
});
|
||||
|
||||
it('has exactly 5 forms', () => {
|
||||
expect(Object.values(MedicineForm)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe(StrengthUnit.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(StrengthUnit.MG).toBe('mg');
|
||||
expect(StrengthUnit.IU).toBe('IU');
|
||||
expect(StrengthUnit.PERCENT).toBe('%');
|
||||
});
|
||||
|
||||
it('has exactly 7 units', () => {
|
||||
expect(Object.values(StrengthUnit)).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe(MedicineCategory.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(MedicineCategory.PRESCRIPTION).toBe('prescription');
|
||||
expect(MedicineCategory.OTC).toBe('otc');
|
||||
expect(MedicineCategory.SUPPLEMENT).toBe('supplement');
|
||||
});
|
||||
|
||||
it('has exactly 4 categories', () => {
|
||||
expect(Object.values(MedicineCategory)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe(MedicineProductSource.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(MedicineProductSource.MANUAL).toBe('manual');
|
||||
expect(MedicineProductSource.IMPORT).toBe('import');
|
||||
});
|
||||
|
||||
it('has exactly 2 sources', () => {
|
||||
expect(Object.values(MedicineProductSource)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe(DosageUnit.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(DosageUnit.TABLET).toBe('tablet');
|
||||
expect(DosageUnit.CAPSULE).toBe('capsule');
|
||||
expect(DosageUnit.ML).toBe('ml');
|
||||
expect(DosageUnit.VIAL).toBe('vial');
|
||||
expect(DosageUnit.DOSE).toBe('dose');
|
||||
});
|
||||
|
||||
it('has exactly 5 units', () => {
|
||||
expect(Object.values(DosageUnit)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe(ConcentrationUnit.name, () => {
|
||||
it('has expected values', () => {
|
||||
expect(ConcentrationUnit.MG_PER_ML).toBe('mg/mL');
|
||||
expect(ConcentrationUnit.MCG_PER_ML).toBe('mcg/mL');
|
||||
expect(ConcentrationUnit.UNITS_PER_ML).toBe('units/mL');
|
||||
});
|
||||
|
||||
it('has exactly 3 units', () => {
|
||||
expect(Object.values(ConcentrationUnit)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedUnitsForForm', () => {
|
||||
it('returns tablet for tablet form', () => {
|
||||
expect(allowedUnitsForForm(MedicineForm.TABLET)).toEqual([DosageUnit.TABLET]);
|
||||
});
|
||||
|
||||
it('returns capsule for capsule form', () => {
|
||||
expect(allowedUnitsForForm(MedicineForm.CAPSULE)).toEqual([DosageUnit.CAPSULE]);
|
||||
});
|
||||
|
||||
it('returns ml for liquid form', () => {
|
||||
expect(allowedUnitsForForm(MedicineForm.LIQUID)).toEqual([DosageUnit.ML]);
|
||||
});
|
||||
|
||||
it('returns vial and ml for injection form', () => {
|
||||
expect(allowedUnitsForForm(MedicineForm.INJECTION)).toEqual([DosageUnit.VIAL, DosageUnit.ML]);
|
||||
});
|
||||
|
||||
it('returns dose for other form', () => {
|
||||
expect(allowedUnitsForForm(MedicineForm.OTHER)).toEqual([DosageUnit.DOSE]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultUnitForForm', () => {
|
||||
it('returns the first allowed unit', () => {
|
||||
expect(defaultUnitForForm(MedicineForm.TABLET)).toBe(DosageUnit.TABLET);
|
||||
expect(defaultUnitForForm(MedicineForm.INJECTION)).toBe(DosageUnit.VIAL);
|
||||
});
|
||||
});
|
||||
64
packages/shared/src/enums/medicine.enums.ts
Normal file
64
packages/shared/src/enums/medicine.enums.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export enum MedicineForm {
|
||||
TABLET = 'tablet',
|
||||
CAPSULE = 'capsule',
|
||||
LIQUID = 'liquid',
|
||||
INJECTION = 'injection',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum StrengthUnit {
|
||||
MG = 'mg',
|
||||
MCG = 'mcg',
|
||||
G = 'g',
|
||||
ML = 'ml',
|
||||
IU = 'IU',
|
||||
PERCENT = '%',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum MedicineCategory {
|
||||
PRESCRIPTION = 'prescription',
|
||||
OTC = 'otc',
|
||||
SUPPLEMENT = 'supplement',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum MedicineProductSource {
|
||||
MANUAL = 'manual',
|
||||
IMPORT = 'import',
|
||||
}
|
||||
|
||||
export enum DosageUnit {
|
||||
TABLET = 'tablet',
|
||||
CAPSULE = 'capsule',
|
||||
ML = 'ml',
|
||||
VIAL = 'vial',
|
||||
DOSE = 'dose',
|
||||
}
|
||||
|
||||
export enum ConcentrationUnit {
|
||||
MG_PER_ML = 'mg/mL',
|
||||
MCG_PER_ML = 'mcg/mL',
|
||||
UNITS_PER_ML = 'units/mL',
|
||||
}
|
||||
|
||||
/** Returns the allowed DosageUnit values for a given MedicineForm. */
|
||||
export function allowedUnitsForForm(form: MedicineForm): DosageUnit[] {
|
||||
switch (form) {
|
||||
case MedicineForm.TABLET:
|
||||
return [DosageUnit.TABLET];
|
||||
case MedicineForm.CAPSULE:
|
||||
return [DosageUnit.CAPSULE];
|
||||
case MedicineForm.LIQUID:
|
||||
return [DosageUnit.ML];
|
||||
case MedicineForm.INJECTION:
|
||||
return [DosageUnit.VIAL, DosageUnit.ML];
|
||||
case MedicineForm.OTHER:
|
||||
return [DosageUnit.DOSE];
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the default DosageUnit for a given MedicineForm. */
|
||||
export function defaultUnitForForm(form: MedicineForm): DosageUnit {
|
||||
return allowedUnitsForForm(form)[0];
|
||||
}
|
||||
41
packages/shared/src/types/cabinet.ts
Normal file
41
packages/shared/src/types/cabinet.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { CabinetItemStatus } from '../enums/cabinet.enums.js';
|
||||
import type {
|
||||
ConcentrationUnit,
|
||||
DosageUnit,
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
} from '../enums/medicine.enums.js';
|
||||
|
||||
export interface CabinetItem {
|
||||
id: string;
|
||||
householdId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: StrengthUnit;
|
||||
medicineForm: MedicineForm;
|
||||
medicineProductId?: string;
|
||||
medicineProductBrand?: string;
|
||||
concentration?: number;
|
||||
concentrationUnit?: ConcentrationUnit;
|
||||
quantity: number;
|
||||
unit: DosageUnit;
|
||||
expirationDate?: Date;
|
||||
status: CabinetItemStatus;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CabinetSummary {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
earliestExpiry: string | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export * from './user.js';
|
||||
export * from './household.js';
|
||||
export * from './common.js';
|
||||
export * from './medicine.js';
|
||||
export * from './cabinet.js';
|
||||
|
|
|
|||
42
packages/shared/src/types/medicine.ts
Normal file
42
packages/shared/src/types/medicine.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type {
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
MedicineCategory,
|
||||
MedicineProductSource,
|
||||
DosageUnit,
|
||||
ConcentrationUnit,
|
||||
} from '../enums/medicine.enums.js';
|
||||
|
||||
export interface Medicine {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
form: MedicineForm;
|
||||
strength: number;
|
||||
strengthUnit: StrengthUnit;
|
||||
category: MedicineCategory;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface MedicineProduct {
|
||||
id: string;
|
||||
householdId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
brand: string;
|
||||
manufacturer?: string;
|
||||
packageSize: number;
|
||||
packageUnit: DosageUnit;
|
||||
concentration?: number;
|
||||
concentrationUnit?: ConcentrationUnit;
|
||||
imageUrl?: string;
|
||||
notes?: string;
|
||||
source: MedicineProductSource;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
137
packages/shared/src/validation/cabinet.schemas.test.ts
Normal file
137
packages/shared/src/validation/cabinet.schemas.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||
import { CabinetItemStatus } from '../enums/cabinet.enums.js';
|
||||
import {
|
||||
CreateCabinetItemSchema,
|
||||
UpdateCabinetItemSchema,
|
||||
AdjustQuantitySchema,
|
||||
CabinetQuerySchema,
|
||||
} from './cabinet.schemas.js';
|
||||
|
||||
describe('CreateCabinetItemSchema', () => {
|
||||
const validItem = {
|
||||
medicineId: 'med-1',
|
||||
quantity: 30,
|
||||
unit: DosageUnit.TABLET,
|
||||
};
|
||||
|
||||
it('accepts valid minimal input', () => {
|
||||
const result = CreateCabinetItemSchema.safeParse(validItem);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid full input', () => {
|
||||
const result = CreateCabinetItemSchema.safeParse({
|
||||
...validItem,
|
||||
medicineProductId: 'prod-1',
|
||||
expirationDate: '2026-12-31T00:00:00.000Z',
|
||||
notes: 'Main supply',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing medicineId', () => {
|
||||
const result = CreateCabinetItemSchema.safeParse({ quantity: 10, unit: DosageUnit.TABLET });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative quantity', () => {
|
||||
const result = CreateCabinetItemSchema.safeParse({ ...validItem, quantity: -1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts quantity of 0', () => {
|
||||
const result = CreateCabinetItemSchema.safeParse({ ...validItem, quantity: 0 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateCabinetItemSchema', () => {
|
||||
it('accepts partial update', () => {
|
||||
const result = UpdateCabinetItemSchema.safeParse({ quantity: 25 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts status update', () => {
|
||||
const result = UpdateCabinetItemSchema.safeParse({ status: CabinetItemStatus.EXPIRED });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty object', () => {
|
||||
const result = UpdateCabinetItemSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
const result = UpdateCabinetItemSchema.safeParse({ status: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdjustQuantitySchema', () => {
|
||||
it('accepts positive delta', () => {
|
||||
const result = AdjustQuantitySchema.safeParse({ delta: 5 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts negative delta', () => {
|
||||
const result = AdjustQuantitySchema.safeParse({ delta: -3, reason: 'Took a dose' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing delta', () => {
|
||||
const result = AdjustQuantitySchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims reason', () => {
|
||||
const result = AdjustQuantitySchema.safeParse({ delta: 1, reason: ' test ' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.reason).toBe('test');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CabinetQuerySchema', () => {
|
||||
it('applies default limit', () => {
|
||||
const result = CabinetQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts medicineId filter', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ medicineId: 'med-1' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts status filter', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ status: CabinetItemStatus.ACTIVE });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts expiringWithin filter', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ expiringWithin: 30 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces limit from string', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ limit: '50' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(50);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects limit over 100', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ limit: 101 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects expiringWithin over 365', () => {
|
||||
const result = CabinetQuerySchema.safeParse({ expiringWithin: 400 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
88
packages/shared/src/validation/cabinet.schemas.ts
Normal file
88
packages/shared/src/validation/cabinet.schemas.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { CabinetItemStatus } from '../enums/cabinet.enums.js';
|
||||
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||
|
||||
// --- CabinetItem ---
|
||||
|
||||
export const CreateCabinetItemSchema = z.object({
|
||||
medicineId: z.string().min(1),
|
||||
medicineProductId: z.string().min(1).optional(),
|
||||
quantity: z.number().nonnegative(),
|
||||
unit: z.nativeEnum(DosageUnit),
|
||||
expirationDate: z.iso.datetime().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
export const UpdateCabinetItemSchema = z.object({
|
||||
quantity: z.number().nonnegative().optional(),
|
||||
unit: z.nativeEnum(DosageUnit).optional(),
|
||||
expirationDate: z.iso.datetime().optional(),
|
||||
status: z.nativeEnum(CabinetItemStatus).optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
export const AdjustQuantitySchema = z.object({
|
||||
delta: z.number(),
|
||||
reason: z.string().max(500).trim().optional(),
|
||||
});
|
||||
|
||||
export const CabinetQuerySchema = z.object({
|
||||
medicineId: z.string().optional(),
|
||||
status: z.nativeEnum(CabinetItemStatus).optional(),
|
||||
expiringWithin: z.coerce.number().int().min(1).max(365).optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const CabinetItemResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
medicineStrength: z.number(),
|
||||
medicineStrengthUnit: z.string(),
|
||||
medicineForm: z.string(),
|
||||
medicineProductId: z.string().optional(),
|
||||
medicineProductBrand: z.string().optional(),
|
||||
concentration: z.number().optional(),
|
||||
concentrationUnit: z.string().optional(),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
expirationDate: z.string().optional(),
|
||||
status: z.string(),
|
||||
notes: z.string().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const CabinetItemListResponseSchema = z.object({
|
||||
data: z.array(CabinetItemResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
total: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CabinetSummaryItemSchema = z.object({
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
medicineStrength: z.number(),
|
||||
medicineStrengthUnit: z.string(),
|
||||
medicineForm: z.string(),
|
||||
totalQuantity: z.number(),
|
||||
unit: z.string(),
|
||||
earliestExpiry: z.string().nullable(),
|
||||
itemCount: z.number(),
|
||||
});
|
||||
|
||||
export const CabinetSummaryResponseSchema = z.object({
|
||||
data: z.array(CabinetSummaryItemSchema),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type CreateCabinetItemInput = z.infer<typeof CreateCabinetItemSchema>;
|
||||
export type UpdateCabinetItemInput = z.infer<typeof UpdateCabinetItemSchema>;
|
||||
export type AdjustQuantityInput = z.infer<typeof AdjustQuantitySchema>;
|
||||
export type CabinetQueryInput = z.infer<typeof CabinetQuerySchema>;
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './user.schemas.js';
|
||||
export * from './household.schemas.js';
|
||||
export * from './medicine.schemas.js';
|
||||
export * from './cabinet.schemas.js';
|
||||
|
|
|
|||
196
packages/shared/src/validation/medicine.schemas.test.ts
Normal file
196
packages/shared/src/validation/medicine.schemas.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateMedicineSchema,
|
||||
UpdateMedicineSchema,
|
||||
MedicineQuerySchema,
|
||||
CreateMedicineProductSchema,
|
||||
UpdateMedicineProductSchema,
|
||||
} from './medicine.schemas.js';
|
||||
import {
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
MedicineCategory,
|
||||
DosageUnit,
|
||||
MedicineProductSource,
|
||||
} from '../enums/medicine.enums.js';
|
||||
|
||||
describe('CreateMedicineSchema', () => {
|
||||
const validMedicine = {
|
||||
name: 'Metformin',
|
||||
form: MedicineForm.TABLET,
|
||||
strength: 500,
|
||||
strengthUnit: StrengthUnit.MG,
|
||||
category: MedicineCategory.PRESCRIPTION,
|
||||
};
|
||||
|
||||
it('accepts valid medicine', () => {
|
||||
const result = CreateMedicineSchema.safeParse(validMedicine);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts medicine with all optional fields', () => {
|
||||
const result = CreateMedicineSchema.safeParse({
|
||||
...validMedicine,
|
||||
notes: 'Take with food',
|
||||
tags: ['daily', 'morning'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults tags to empty array', () => {
|
||||
const result = CreateMedicineSchema.safeParse(validMedicine);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.tags).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, name: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects zero strength', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, strength: 0 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative strength', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, strength: -1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid form', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, form: 'gummy' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid category', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, category: 'magic' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims name whitespace', () => {
|
||||
const result = CreateMedicineSchema.safeParse({ ...validMedicine, name: ' Metformin ' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('Metformin');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateMedicineSchema', () => {
|
||||
it('accepts empty object (all fields optional)', () => {
|
||||
const result = UpdateMedicineSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts partial update', () => {
|
||||
const result = UpdateMedicineSchema.safeParse({ name: 'Updated Name' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid strength', () => {
|
||||
const result = UpdateMedicineSchema.safeParse({ strength: -5 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedicineQuerySchema', () => {
|
||||
it('accepts empty query with defaults', () => {
|
||||
const result = MedicineQuerySchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts full query', () => {
|
||||
const result = MedicineQuerySchema.safeParse({
|
||||
q: 'metformin',
|
||||
category: MedicineCategory.PRESCRIPTION,
|
||||
form: MedicineForm.TABLET,
|
||||
cursor: 'abc123',
|
||||
limit: 50,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces limit from string', () => {
|
||||
const result = MedicineQuerySchema.safeParse({ limit: '10' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.limit).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects limit above 100', () => {
|
||||
const result = MedicineQuerySchema.safeParse({ limit: 101 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateMedicineProductSchema', () => {
|
||||
const validProduct = {
|
||||
brand: 'CVS Health',
|
||||
packageSize: 90,
|
||||
packageUnit: DosageUnit.TABLET,
|
||||
};
|
||||
|
||||
it('accepts valid product', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse(validProduct);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults source to manual', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse(validProduct);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.source).toBe(MedicineProductSource.MANUAL);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts product with all optional fields', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse({
|
||||
...validProduct,
|
||||
manufacturer: 'CVS Pharmacy',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
notes: 'Generic equivalent',
|
||||
source: MedicineProductSource.IMPORT,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty brand', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse({ ...validProduct, brand: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects zero package size', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse({ ...validProduct, packageSize: 0 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid URL', () => {
|
||||
const result = CreateMedicineProductSchema.safeParse({
|
||||
...validProduct,
|
||||
imageUrl: 'not-a-url',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateMedicineProductSchema', () => {
|
||||
it('accepts empty object', () => {
|
||||
const result = UpdateMedicineProductSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts partial update', () => {
|
||||
const result = UpdateMedicineProductSchema.safeParse({
|
||||
brand: 'Kirkland',
|
||||
packageSize: 60,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
123
packages/shared/src/validation/medicine.schemas.ts
Normal file
123
packages/shared/src/validation/medicine.schemas.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { z } from 'zod/v4';
|
||||
import {
|
||||
MedicineForm,
|
||||
StrengthUnit,
|
||||
MedicineCategory,
|
||||
MedicineProductSource,
|
||||
DosageUnit,
|
||||
ConcentrationUnit,
|
||||
} from '../enums/medicine.enums.js';
|
||||
|
||||
// --- Medicine (generic level) ---
|
||||
|
||||
export const CreateMedicineSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim(),
|
||||
form: z.nativeEnum(MedicineForm),
|
||||
strength: z.number().positive(),
|
||||
strengthUnit: z.nativeEnum(StrengthUnit),
|
||||
category: z.nativeEnum(MedicineCategory),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
tags: z.array(z.string().min(1).max(50).trim()).max(20).default([]),
|
||||
});
|
||||
|
||||
export const UpdateMedicineSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim().optional(),
|
||||
form: z.nativeEnum(MedicineForm).optional(),
|
||||
strength: z.number().positive().optional(),
|
||||
strengthUnit: z.nativeEnum(StrengthUnit).optional(),
|
||||
category: z.nativeEnum(MedicineCategory).optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
tags: z.array(z.string().min(1).max(50).trim()).max(20).optional(),
|
||||
});
|
||||
|
||||
export const MedicineQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
category: z.nativeEnum(MedicineCategory).optional(),
|
||||
form: z.nativeEnum(MedicineForm).optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const MedicineResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
name: z.string(),
|
||||
form: z.string(),
|
||||
strength: z.number(),
|
||||
strengthUnit: z.string(),
|
||||
category: z.string(),
|
||||
notes: z.string().optional(),
|
||||
tags: z.array(z.string()),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const MedicineListResponseSchema = z.object({
|
||||
data: z.array(MedicineResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
total: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// --- MedicineProduct (purchasable level) ---
|
||||
|
||||
export const CreateMedicineProductSchema = z.object({
|
||||
brand: z.string().min(1).max(200).trim(),
|
||||
manufacturer: z.string().max(200).trim().optional(),
|
||||
packageSize: z.number().positive(),
|
||||
packageUnit: z.nativeEnum(DosageUnit),
|
||||
concentration: z.number().positive().optional(),
|
||||
concentrationUnit: z.nativeEnum(ConcentrationUnit).optional(),
|
||||
imageUrl: z.url().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
source: z.nativeEnum(MedicineProductSource).default(MedicineProductSource.MANUAL),
|
||||
});
|
||||
|
||||
export const UpdateMedicineProductSchema = z.object({
|
||||
brand: z.string().min(1).max(200).trim().optional(),
|
||||
manufacturer: z.string().max(200).trim().optional(),
|
||||
packageSize: z.number().positive().optional(),
|
||||
packageUnit: z.nativeEnum(DosageUnit).optional(),
|
||||
concentration: z.number().positive().optional(),
|
||||
concentrationUnit: z.nativeEnum(ConcentrationUnit).optional(),
|
||||
imageUrl: z.url().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
export const MedicineProductResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
brand: z.string(),
|
||||
manufacturer: z.string().optional(),
|
||||
packageSize: z.number(),
|
||||
packageUnit: z.string(),
|
||||
concentration: z.number().optional(),
|
||||
concentrationUnit: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.string(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const MedicineProductListResponseSchema = z.object({
|
||||
data: z.array(MedicineProductResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
total: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type CreateMedicineInput = z.infer<typeof CreateMedicineSchema>;
|
||||
export type UpdateMedicineInput = z.infer<typeof UpdateMedicineSchema>;
|
||||
export type MedicineQueryInput = z.infer<typeof MedicineQuerySchema>;
|
||||
export type CreateMedicineProductInput = z.infer<typeof CreateMedicineProductSchema>;
|
||||
export type UpdateMedicineProductInput = z.infer<typeof UpdateMedicineProductSchema>;
|
||||
|
|
@ -10,5 +10,17 @@ export const CreateUserSchema = z.object({
|
|||
|
||||
export const UpdateUserSchema = CreateUserSchema.partial().omit({ keycloakId: true });
|
||||
|
||||
export const UserResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
keycloakId: z.string(),
|
||||
displayName: z.string(),
|
||||
email: z.string(),
|
||||
householdIds: z.array(z.string()),
|
||||
defaultHouseholdId: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
export type UserResponse = z.infer<typeof UserResponseSchema>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue