Implement stores and refills, improve testing
This commit is contained in:
parent
9f416903ef
commit
5536acd67d
137 changed files with 21218 additions and 221 deletions
|
|
@ -3,3 +3,5 @@ export * from './medicine.enums.js';
|
|||
export * from './cabinet.enums.js';
|
||||
export * from './cabinet-event.enums.js';
|
||||
export * from './regimen.enums.js';
|
||||
export * from './refill.enums.js';
|
||||
export * from './purchase.enums.js';
|
||||
|
|
|
|||
15
packages/shared/src/enums/purchase.enums.test.ts
Normal file
15
packages/shared/src/enums/purchase.enums.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { PurchaseStatus } from './purchase.enums.js';
|
||||
|
||||
describe(PurchaseStatus.name, () => {
|
||||
it('has exactly 2 values', () => {
|
||||
expect(Object.values(PurchaseStatus)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ORDERED', 'ordered'],
|
||||
['IN_CABINET', 'in_cabinet'],
|
||||
])('%s = %s', (key, value) => {
|
||||
expect(PurchaseStatus[key as keyof typeof PurchaseStatus]).toBe(value);
|
||||
});
|
||||
});
|
||||
4
packages/shared/src/enums/purchase.enums.ts
Normal file
4
packages/shared/src/enums/purchase.enums.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum PurchaseStatus {
|
||||
ORDERED = 'ordered',
|
||||
IN_CABINET = 'in_cabinet',
|
||||
}
|
||||
17
packages/shared/src/enums/refill.enums.test.ts
Normal file
17
packages/shared/src/enums/refill.enums.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { RefillListStatus } from './refill.enums.js';
|
||||
|
||||
describe(RefillListStatus.name, () => {
|
||||
it('has exactly 4 values', () => {
|
||||
expect(Object.values(RefillListStatus)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ACTIVE', 'active'],
|
||||
['SHOPPING', 'shopping'],
|
||||
['COMPLETED', 'completed'],
|
||||
['ARCHIVED', 'archived'],
|
||||
])('%s = %s', (key, value) => {
|
||||
expect(RefillListStatus[key as keyof typeof RefillListStatus]).toBe(value);
|
||||
});
|
||||
});
|
||||
6
packages/shared/src/enums/refill.enums.ts
Normal file
6
packages/shared/src/enums/refill.enums.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export enum RefillListStatus {
|
||||
ACTIVE = 'active',
|
||||
SHOPPING = 'shopping',
|
||||
COMPLETED = 'completed',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
|
@ -7,3 +7,7 @@ export * from './cabinet-event.js';
|
|||
export * from './regimen.js';
|
||||
export * from './organizer-fill.js';
|
||||
export * from './burn-rate.js';
|
||||
export * from './store.js';
|
||||
export * from './medicine-price.js';
|
||||
export * from './refill.js';
|
||||
export * from './purchase.js';
|
||||
|
|
|
|||
56
packages/shared/src/types/medicine-price.ts
Normal file
56
packages/shared/src/types/medicine-price.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { DosageUnit } from '../enums/medicine.enums.js';
|
||||
|
||||
export interface MedicinePriceRecord {
|
||||
id: string;
|
||||
householdId: string;
|
||||
medicineProductId: string;
|
||||
medicineProductBrand: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
unit: DosageUnit;
|
||||
pricePerUnit: number;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface StoreComparison {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
latestPrice: number;
|
||||
latestPricePerUnit: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
}
|
||||
|
||||
export interface MedicineSpendingAnalytics {
|
||||
spendingOverTime: { period: string; total: number }[];
|
||||
topBySpending: {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}[];
|
||||
spendingByStore: {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}[];
|
||||
priceAlerts: {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeName: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}[];
|
||||
}
|
||||
30
packages/shared/src/types/purchase.ts
Normal file
30
packages/shared/src/types/purchase.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { PurchaseStatus } from '../enums/purchase.enums.js';
|
||||
|
||||
export interface PurchaseItem {
|
||||
id: string;
|
||||
medicineProductId?: string;
|
||||
medicineId?: string;
|
||||
foodProductId?: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
actualPrice?: number;
|
||||
currency?: string;
|
||||
priceRecordId?: string;
|
||||
addedToCabinet: boolean;
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
id: string;
|
||||
householdId: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
status: PurchaseStatus;
|
||||
items: PurchaseItem[];
|
||||
notes?: string;
|
||||
purchasedAt: Date;
|
||||
receivedAt?: Date;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
57
packages/shared/src/types/refill.ts
Normal file
57
packages/shared/src/types/refill.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
|
||||
import type { RefillListStatus } from '../enums/refill.enums.js';
|
||||
|
||||
export interface RefillAlert {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: StrengthUnit;
|
||||
daysUntilEmpty: number;
|
||||
dailyConsumption: number;
|
||||
currentStock: number;
|
||||
pendingOrderStock: number;
|
||||
daysUntilEmptyWithOrders: number | null;
|
||||
suggestedQuantity: number;
|
||||
lastKnownPrice?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
storeId: string;
|
||||
date: Date;
|
||||
};
|
||||
cheapestOption?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
storeId: string;
|
||||
date: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RefillListItem {
|
||||
id: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
unit: DosageUnit;
|
||||
estimatedPrice?: number;
|
||||
actualPrice?: number;
|
||||
checked: boolean;
|
||||
checkedAt?: Date;
|
||||
addedToCabinet: boolean;
|
||||
storeId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface RefillList {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
items: RefillListItem[];
|
||||
status: RefillListStatus;
|
||||
preferredStoreId?: string;
|
||||
totalEstimatedCost?: number;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
17
packages/shared/src/types/store.ts
Normal file
17
packages/shared/src/types/store.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface Store {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
url?: string;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -5,3 +5,7 @@ export * from './cabinet.schemas.js';
|
|||
export * from './cabinet-event.schemas.js';
|
||||
export * from './regimen.schemas.js';
|
||||
export * from './organizer.schemas.js';
|
||||
export * from './store.schemas.js';
|
||||
export * from './medicine-price.schemas.js';
|
||||
export * from './refill.schemas.js';
|
||||
export * from './purchase.schemas.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||
import {
|
||||
CreateMedicinePriceRecordSchema,
|
||||
MedicinePriceHistoryQuerySchema,
|
||||
MedicinePriceAnalyticsQuerySchema,
|
||||
} from './medicine-price.schemas.js';
|
||||
|
||||
const validInput = {
|
||||
medicineProductId: 'prod-1',
|
||||
medicineId: 'med-1',
|
||||
storeId: 'store-1',
|
||||
price: 9.99,
|
||||
currency: 'USD',
|
||||
quantity: 30,
|
||||
unit: DosageUnit.TABLET,
|
||||
};
|
||||
|
||||
describe('CreateMedicinePriceRecordSchema', () => {
|
||||
it('accepts valid input', () => {
|
||||
expect(CreateMedicinePriceRecordSchema.safeParse(validInput).success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults isInsurancePrice to false', () => {
|
||||
const result = CreateMedicinePriceRecordSchema.parse(validInput);
|
||||
expect(result.isInsurancePrice).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional date as ISO datetime', () => {
|
||||
const result = CreateMedicinePriceRecordSchema.safeParse({
|
||||
...validInput,
|
||||
date: '2026-01-15T00:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-positive price', () => {
|
||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-positive quantity', () => {
|
||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid unit', () => {
|
||||
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedicinePriceHistoryQuerySchema', () => {
|
||||
it('applies default limit', () => {
|
||||
const result = MedicinePriceHistoryQuerySchema.parse({});
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('accepts date range filters', () => {
|
||||
const result = MedicinePriceHistoryQuerySchema.parse({
|
||||
startDate: '2026-01-01T00:00:00.000Z',
|
||||
endDate: '2026-03-31T00:00:00.000Z',
|
||||
});
|
||||
expect(result.startDate).toBeTruthy();
|
||||
expect(result.endDate).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MedicinePriceAnalyticsQuerySchema', () => {
|
||||
it('defaults to month period', () => {
|
||||
const result = MedicinePriceAnalyticsQuerySchema.parse({});
|
||||
expect(result.period).toBe('month');
|
||||
});
|
||||
|
||||
it.each(['month', 'quarter', 'year'])('accepts period %s', (period) => {
|
||||
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid period', () => {
|
||||
expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period: 'week' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
98
packages/shared/src/validation/medicine-price.schemas.ts
Normal file
98
packages/shared/src/validation/medicine-price.schemas.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||
|
||||
export const CreateMedicinePriceRecordSchema = z.object({
|
||||
medicineProductId: z.string().min(1),
|
||||
medicineId: z.string().min(1),
|
||||
storeId: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
currency: z.string().min(1).max(10),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(DosageUnit),
|
||||
date: z.iso.datetime().optional(),
|
||||
isInsurancePrice: z.boolean().default(false),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
});
|
||||
|
||||
export const MedicinePriceHistoryQuerySchema = z.object({
|
||||
storeId: z.string().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 MedicinePriceAnalyticsQuerySchema = z.object({
|
||||
period: z.enum(['month', 'quarter', 'year']).default('month'),
|
||||
});
|
||||
|
||||
export const MedicinePriceRecordResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
medicineProductId: z.string(),
|
||||
medicineProductBrand: z.string(),
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
pricePerUnit: z.number(),
|
||||
date: z.string(),
|
||||
isInsurancePrice: z.boolean(),
|
||||
notes: z.string().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const MedicinePriceHistoryResponseSchema = z.object({
|
||||
data: z.array(MedicinePriceRecordResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StoreComparisonItemSchema = z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
latestPrice: z.number(),
|
||||
latestPricePerUnit: z.number(),
|
||||
currency: z.string(),
|
||||
date: z.string(),
|
||||
isInsurancePrice: z.boolean(),
|
||||
});
|
||||
|
||||
export const StoreComparisonResponseSchema = z.object({
|
||||
data: z.array(StoreComparisonItemSchema),
|
||||
});
|
||||
|
||||
export const MedicineSpendingAnalyticsResponseSchema = z.object({
|
||||
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
|
||||
topBySpending: z.array(z.object({
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
totalSpent: z.number(),
|
||||
avgPricePerUnit: z.number(),
|
||||
})),
|
||||
spendingByStore: z.array(z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
totalSpent: z.number(),
|
||||
purchaseCount: z.number(),
|
||||
})),
|
||||
priceAlerts: z.array(z.object({
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
storeName: z.string(),
|
||||
previousPrice: z.number(),
|
||||
currentPrice: z.number(),
|
||||
changePercent: z.number(),
|
||||
})),
|
||||
});
|
||||
|
||||
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;
|
||||
export type MedicinePriceHistoryQueryInput = z.infer<typeof MedicinePriceHistoryQuerySchema>;
|
||||
export type MedicinePriceAnalyticsQueryInput = z.infer<typeof MedicinePriceAnalyticsQuerySchema>;
|
||||
120
packages/shared/src/validation/purchase.schemas.test.ts
Normal file
120
packages/shared/src/validation/purchase.schemas.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreatePurchaseItemSchema,
|
||||
CreatePurchaseSchema,
|
||||
UpdatePurchaseSchema,
|
||||
PurchaseQuerySchema,
|
||||
} from './purchase.schemas.js';
|
||||
|
||||
describe('CreatePurchaseItemSchema', () => {
|
||||
it('accepts minimal valid item', () => {
|
||||
const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts item with all fields', () => {
|
||||
const result = CreatePurchaseItemSchema.safeParse({
|
||||
medicineProductId: 'mp-1',
|
||||
name: 'Tylenol 30ct',
|
||||
quantity: 30,
|
||||
unit: 'tablet',
|
||||
actualPrice: 9.99,
|
||||
currency: 'USD',
|
||||
priceRecordId: 'pr-1',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-positive quantity', () => {
|
||||
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty unit', () => {
|
||||
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims name whitespace', () => {
|
||||
const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' });
|
||||
expect(result.name).toBe('Tylenol');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreatePurchaseSchema', () => {
|
||||
const validItem = { name: 'Tylenol', quantity: 30, unit: 'tablet' };
|
||||
|
||||
it('defaults status to in_cabinet', () => {
|
||||
const result = CreatePurchaseSchema.parse({ storeId: 'st-1', items: [validItem] });
|
||||
expect(result.status).toBe('in_cabinet');
|
||||
});
|
||||
|
||||
it('accepts ordered status', () => {
|
||||
const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty storeId', () => {
|
||||
expect(CreatePurchaseSchema.safeParse({ storeId: '', items: [validItem] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty items array', () => {
|
||||
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional purchasedAt datetime', () => {
|
||||
const result = CreatePurchaseSchema.safeParse({
|
||||
storeId: 'st-1',
|
||||
items: [validItem],
|
||||
purchasedAt: '2026-01-15T00:00:00.000Z',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid purchasedAt', () => {
|
||||
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdatePurchaseSchema', () => {
|
||||
it('accepts empty object', () => {
|
||||
expect(UpdatePurchaseSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts notes only', () => {
|
||||
expect(UpdatePurchaseSchema.safeParse({ notes: 'updated' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('trims notes whitespace', () => {
|
||||
const result = UpdatePurchaseSchema.parse({ notes: ' hello ' });
|
||||
expect(result.notes).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PurchaseQuerySchema', () => {
|
||||
it('defaults limit to 20', () => {
|
||||
const result = PurchaseQuerySchema.parse({});
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('accepts status filter', () => {
|
||||
expect(PurchaseQuerySchema.safeParse({ status: 'ordered' }).success).toBe(true);
|
||||
expect(PurchaseQuerySchema.safeParse({ status: 'in_cabinet' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
expect(PurchaseQuerySchema.safeParse({ status: 'unknown' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('coerces string limit', () => {
|
||||
const result = PurchaseQuerySchema.parse({ limit: '50' });
|
||||
expect(result.limit).toBe(50);
|
||||
});
|
||||
});
|
||||
74
packages/shared/src/validation/purchase.schemas.ts
Normal file
74
packages/shared/src/validation/purchase.schemas.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from 'zod/v4';
|
||||
|
||||
export const CreatePurchaseItemSchema = z.object({
|
||||
medicineProductId: z.string().optional(),
|
||||
name: z.string().min(1).max(200).trim(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.string().min(1),
|
||||
actualPrice: z.number().positive().optional(),
|
||||
currency: z.string().min(1).max(10).optional(),
|
||||
priceRecordId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreatePurchaseSchema = z.object({
|
||||
storeId: z.string().min(1),
|
||||
status: z.enum(['ordered', 'in_cabinet']).default('in_cabinet'),
|
||||
items: z.array(CreatePurchaseItemSchema).min(1),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
purchasedAt: z.iso.datetime().optional(),
|
||||
});
|
||||
|
||||
export const UpdatePurchaseSchema = z.object({
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
items: z.array(CreatePurchaseItemSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
export const PurchaseItemResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
medicineProductId: z.string().optional(),
|
||||
medicineId: z.string().optional(),
|
||||
foodProductId: z.string().optional(),
|
||||
name: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
actualPrice: z.number().optional(),
|
||||
currency: z.string().optional(),
|
||||
priceRecordId: z.string().optional(),
|
||||
addedToCabinet: z.boolean(),
|
||||
});
|
||||
|
||||
export const PurchaseResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
status: z.enum(['ordered', 'in_cabinet']),
|
||||
items: z.array(PurchaseItemResponseSchema),
|
||||
notes: z.string().optional(),
|
||||
purchasedAt: z.string(),
|
||||
receivedAt: z.string().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const PurchaseListResponseSchema = z.object({
|
||||
data: z.array(PurchaseResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
total: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PurchaseQuerySchema = z.object({
|
||||
status: z.enum(['ordered', 'in_cabinet']).optional(),
|
||||
storeId: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type CreatePurchaseItemInput = z.infer<typeof CreatePurchaseItemSchema>;
|
||||
export type CreatePurchaseInput = z.infer<typeof CreatePurchaseSchema>;
|
||||
export type UpdatePurchaseInput = z.infer<typeof UpdatePurchaseSchema>;
|
||||
export type PurchaseQueryInput = z.infer<typeof PurchaseQuerySchema>;
|
||||
112
packages/shared/src/validation/refill.schemas.test.ts
Normal file
112
packages/shared/src/validation/refill.schemas.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DosageUnit } from '../enums/medicine.enums.js';
|
||||
import { RefillListStatus } from '../enums/refill.enums.js';
|
||||
import {
|
||||
CreateRefillListSchema,
|
||||
UpdateRefillListSchema,
|
||||
UpdateRefillListItemSchema,
|
||||
RefillListQuerySchema,
|
||||
RefillAlertQuerySchema,
|
||||
} from './refill.schemas.js';
|
||||
|
||||
describe('CreateRefillListSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const result = CreateRefillListSchema.parse({ name: 'Weekly Refills' });
|
||||
expect(result.fromAlerts).toBe(false);
|
||||
expect(result.thresholdDays).toBe(7);
|
||||
});
|
||||
|
||||
it('accepts fromAlerts with threshold', () => {
|
||||
const result = CreateRefillListSchema.parse({
|
||||
name: 'Auto List',
|
||||
fromAlerts: true,
|
||||
thresholdDays: 14,
|
||||
});
|
||||
expect(result.fromAlerts).toBe(true);
|
||||
expect(result.thresholdDays).toBe(14);
|
||||
});
|
||||
|
||||
it('accepts items array', () => {
|
||||
const result = CreateRefillListSchema.safeParse({
|
||||
name: 'Manual List',
|
||||
items: [
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
medicineName: 'Metformin',
|
||||
quantity: 30,
|
||||
unit: DosageUnit.TABLET,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(CreateRefillListSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects item with non-positive quantity', () => {
|
||||
expect(
|
||||
CreateRefillListSchema.safeParse({
|
||||
name: 'List',
|
||||
items: [{ medicineId: 'med-1', medicineName: 'X', quantity: 0, unit: DosageUnit.TABLET }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('trims name whitespace', () => {
|
||||
const result = CreateRefillListSchema.parse({ name: ' My List ' });
|
||||
expect(result.name).toBe('My List');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateRefillListSchema', () => {
|
||||
it('accepts empty object', () => {
|
||||
expect(UpdateRefillListSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts status update', () => {
|
||||
const result = UpdateRefillListSchema.parse({ status: RefillListStatus.SHOPPING });
|
||||
expect(result.status).toBe(RefillListStatus.SHOPPING);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
expect(UpdateRefillListSchema.safeParse({ status: 'invalid' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateRefillListItemSchema', () => {
|
||||
it('accepts checked=true', () => {
|
||||
expect(UpdateRefillListItemSchema.safeParse({ checked: true }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts actualPrice', () => {
|
||||
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 });
|
||||
expect(result.actualPrice).toBe(12.50);
|
||||
});
|
||||
|
||||
it('rejects negative actualPrice', () => {
|
||||
expect(UpdateRefillListItemSchema.safeParse({ actualPrice: -1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefillListQuerySchema', () => {
|
||||
it('applies default limit', () => {
|
||||
expect(RefillListQuerySchema.parse({}).limit).toBe(20);
|
||||
});
|
||||
|
||||
it('accepts status filter', () => {
|
||||
const result = RefillListQuerySchema.parse({ status: RefillListStatus.ACTIVE });
|
||||
expect(result.status).toBe(RefillListStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefillAlertQuerySchema', () => {
|
||||
it('defaults thresholdDays to 7', () => {
|
||||
expect(RefillAlertQuerySchema.parse({}).thresholdDays).toBe(7);
|
||||
});
|
||||
|
||||
it('coerces thresholdDays from string', () => {
|
||||
expect(RefillAlertQuerySchema.parse({ thresholdDays: '14' }).thresholdDays).toBe(14);
|
||||
});
|
||||
});
|
||||
115
packages/shared/src/validation/refill.schemas.ts
Normal file
115
packages/shared/src/validation/refill.schemas.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js';
|
||||
import { RefillListStatus } from '../enums/refill.enums.js';
|
||||
|
||||
export const CreateRefillListItemSchema = z.object({
|
||||
medicineId: z.string().min(1),
|
||||
medicineName: z.string().min(1),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(DosageUnit),
|
||||
estimatedPrice: z.number().nonnegative().optional(),
|
||||
storeId: z.string().optional(),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
});
|
||||
|
||||
export const CreateRefillListSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
fromAlerts: z.boolean().default(false),
|
||||
thresholdDays: z.number().int().min(1).max(365).default(7),
|
||||
items: z.array(CreateRefillListItemSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateRefillListSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim().optional(),
|
||||
status: z.nativeEnum(RefillListStatus).optional(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateRefillListItemSchema = z.object({
|
||||
checked: z.boolean().optional(),
|
||||
actualPrice: z.number().nonnegative().optional(),
|
||||
storeId: z.string().optional(),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
});
|
||||
|
||||
export const RefillListQuerySchema = z.object({
|
||||
status: z.nativeEnum(RefillListStatus).optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const RefillAlertQuerySchema = z.object({
|
||||
thresholdDays: z.coerce.number().int().min(1).max(365).default(7),
|
||||
userId: z.string().optional(),
|
||||
});
|
||||
|
||||
const PriceOptionSchema = z.object({
|
||||
price: z.number(),
|
||||
pricePerUnit: z.number(),
|
||||
storeName: z.string(),
|
||||
storeId: z.string(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const RefillAlertResponseSchema = z.object({
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
medicineStrength: z.number(),
|
||||
medicineStrengthUnit: z.nativeEnum(StrengthUnit),
|
||||
daysUntilEmpty: z.number(),
|
||||
dailyConsumption: z.number(),
|
||||
currentStock: z.number(),
|
||||
pendingOrderStock: z.number().optional(),
|
||||
daysUntilEmptyWithOrders: z.number().nullable().optional(),
|
||||
suggestedQuantity: z.number(),
|
||||
lastKnownPrice: PriceOptionSchema.optional(),
|
||||
cheapestOption: PriceOptionSchema.optional(),
|
||||
});
|
||||
|
||||
export const RefillListItemResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
medicineId: z.string(),
|
||||
medicineName: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: z.string(),
|
||||
estimatedPrice: z.number().optional(),
|
||||
actualPrice: z.number().optional(),
|
||||
checked: z.boolean(),
|
||||
checkedAt: z.string().optional(),
|
||||
addedToCabinet: z.boolean(),
|
||||
storeId: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RefillListResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
name: z.string(),
|
||||
items: z.array(RefillListItemResponseSchema),
|
||||
status: z.nativeEnum(RefillListStatus),
|
||||
preferredStoreId: z.string().optional(),
|
||||
totalEstimatedCost: z.number().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const RefillListListResponseSchema = z.object({
|
||||
data: z.array(RefillListResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AddToCabinetResponseSchema = z.object({
|
||||
addedCount: z.number(),
|
||||
priceRecordsCreated: z.number(),
|
||||
});
|
||||
|
||||
export type CreateRefillListInput = z.infer<typeof CreateRefillListSchema>;
|
||||
export type UpdateRefillListInput = z.infer<typeof UpdateRefillListSchema>;
|
||||
export type UpdateRefillListItemInput = z.infer<typeof UpdateRefillListItemSchema>;
|
||||
export type RefillListQueryInput = z.infer<typeof RefillListQuerySchema>;
|
||||
export type RefillAlertQueryInput = z.infer<typeof RefillAlertQuerySchema>;
|
||||
81
packages/shared/src/validation/store.schemas.test.ts
Normal file
81
packages/shared/src/validation/store.schemas.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateStoreSchema, UpdateStoreSchema, StoreQuerySchema } from './store.schemas.js';
|
||||
|
||||
describe('CreateStoreSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const result = CreateStoreSchema.safeParse({ name: 'Walgreens' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.tags).toEqual([]);
|
||||
expect(result.data.isActive).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts full valid input', () => {
|
||||
const result = CreateStoreSchema.safeParse({
|
||||
name: 'CVS Pharmacy',
|
||||
address: '123 Main St',
|
||||
location: { lat: 40.7128, lng: -74.006 },
|
||||
url: 'https://cvs.com',
|
||||
notes: 'Open 24h',
|
||||
tags: ['pharmacy', 'retail'],
|
||||
isActive: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(CreateStoreSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid location lat', () => {
|
||||
expect(
|
||||
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid location lng', () => {
|
||||
expect(
|
||||
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('trims name whitespace', () => {
|
||||
const result = CreateStoreSchema.parse({ name: ' Rite Aid ' });
|
||||
expect(result.name).toBe('Rite Aid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateStoreSchema', () => {
|
||||
it('accepts empty object', () => {
|
||||
expect(UpdateStoreSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts partial update', () => {
|
||||
const result = UpdateStoreSchema.safeParse({ isActive: false });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts tags update', () => {
|
||||
const result = UpdateStoreSchema.safeParse({ tags: ['grocery'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoreQuerySchema', () => {
|
||||
it('applies default limit', () => {
|
||||
const result = StoreQuerySchema.parse({});
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('coerces limit from string', () => {
|
||||
const result = StoreQuerySchema.parse({ limit: '5' });
|
||||
expect(result.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('accepts tags and search filters', () => {
|
||||
const result = StoreQuerySchema.parse({ tags: 'pharmacy', search: 'walgreens' });
|
||||
expect(result.tags).toBe('pharmacy');
|
||||
expect(result.search).toBe('walgreens');
|
||||
});
|
||||
});
|
||||
60
packages/shared/src/validation/store.schemas.ts
Normal file
60
packages/shared/src/validation/store.schemas.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { z } from 'zod/v4';
|
||||
|
||||
const locationSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
});
|
||||
|
||||
export const CreateStoreSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim(),
|
||||
address: z.string().max(500).trim().optional(),
|
||||
location: locationSchema.optional(),
|
||||
url: z.url().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
tags: z.array(z.string().min(1).max(50).trim()).default([]),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const UpdateStoreSchema = z.object({
|
||||
name: z.string().min(1).max(200).trim().optional(),
|
||||
address: z.string().max(500).trim().optional(),
|
||||
location: locationSchema.optional(),
|
||||
url: z.url().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
tags: z.array(z.string().min(1).max(50).trim()).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const StoreQuerySchema = z.object({
|
||||
tags: z.string().optional(),
|
||||
search: z.string().max(200).optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const StoreResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
name: z.string(),
|
||||
address: z.string().optional(),
|
||||
location: locationSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
tags: z.array(z.string()),
|
||||
isActive: z.boolean(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const StoreListResponseSchema = z.object({
|
||||
data: z.array(StoreResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
|
||||
export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
|
||||
export type StoreQueryInput = z.infer<typeof StoreQuerySchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue