Add additional lint rules

This commit is contained in:
Aerilyn Weber 2026-05-19 16:15:15 +09:00
parent 02d782c3da
commit 420b18eb78
67 changed files with 3686 additions and 1415 deletions

View file

@ -26,13 +26,79 @@ export default tseslint.config(
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'public-static-field',
'protected-static-field',
'private-static-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'constructor',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
],
},
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
},
],
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z]',
match: false,
},
},
{
selector: 'objectLiteralProperty',
format: null,
},
{
selector: 'objectLiteralMethod',
format: null,
},
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
},
},
{
// Relax some rules in test files
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-member-accessibility': 'off',
},
},

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type */
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';

View file

@ -1,4 +1,5 @@
import { CabinetEventModel } from '../../schemas/cabinet-event.schema.js';
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
import type {
CabinetEventType,
CabinetEventSourceType,
@ -28,17 +29,24 @@ export interface CreateCabinetEventData {
}
export class CabinetEventsRepository {
public async create(data: CreateCabinetEventData) {
public async create(data: CreateCabinetEventData): Promise<CabinetEventDocument> {
const event = new CabinetEventModel(data);
const saved = await event.save();
return saved.toObject();
return saved.toObject() as CabinetEventDocument;
}
public async createMany(events: CreateCabinetEventData[]) {
return CabinetEventModel.insertMany(events);
public async createMany(events: CreateCabinetEventData[]): Promise<CabinetEventDocument[]> {
const docs = await CabinetEventModel.insertMany(events);
return docs as unknown as CabinetEventDocument[];
}
public async findByHousehold(householdId: string, query: CabinetEventQueryInput) {
public async findByHousehold(
householdId: string,
query: CabinetEventQueryInput,
): Promise<{
data: CabinetEventDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId };
if (query.medicineId) filter['medicineId'] = query.medicineId;
@ -67,14 +75,20 @@ export class CabinetEventsRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as CabinetEventDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findByCabinetItem(
householdId: string,
cabinetItemId: string,
query: { cursor?: string; limit: number },
) {
): Promise<{
data: CabinetEventDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId, cabinetItemId };
if (query.cursor) {
@ -94,10 +108,31 @@ export class CabinetEventsRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as CabinetEventDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
public async getSpendingSummary(
householdId: string,
query: SpendingSummaryQueryInput,
): Promise<{
totalSpent: number;
currency: string | null;
byMedicine: Array<{
medicineId: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
avgUnitPrice: number;
purchaseCount: number;
}>;
byPeriod: Array<{
period: string;
totalSpent: number;
}>;
}> {
const match: Record<string, unknown> = {
householdId,
eventType: 'purchased',
@ -114,7 +149,22 @@ export class CabinetEventsRepository {
const dateFormat =
query.period === 'year' ? '%Y' : query.period === 'quarter' ? '%Y-Q%q' : '%Y-%m';
const [byMedicine, byPeriod] = await Promise.all([
interface MedicineSpendingSummaryGroup {
_id: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
purchaseCount: number;
currency: string | null;
avgUnitPrice: number;
}
interface PeriodSpendingSummaryGroup {
_id: string;
totalSpent: number;
}
const [byMedicineRaw, byPeriodRaw] = await Promise.all([
CabinetEventModel.aggregate([
{ $match: match },
{
@ -152,36 +202,47 @@ export class CabinetEventsRepository {
]).exec(),
]);
const byMedicine = byMedicineRaw as unknown as MedicineSpendingSummaryGroup[];
const byPeriod = byPeriodRaw as unknown as PeriodSpendingSummaryGroup[];
const totalSpent = byMedicine.reduce(
(sum: number, m: Record<string, unknown>) => sum + (m.totalSpent as number),
(sum: number, m: MedicineSpendingSummaryGroup) => sum + m.totalSpent,
0,
);
const currency =
byMedicine.length > 0 ? ((byMedicine[0].currency as string | null) ?? null) : null;
const currency = byMedicine.length > 0 ? (byMedicine[0].currency ?? null) : null;
return {
totalSpent,
currency,
byMedicine: byMedicine.map((m: Record<string, unknown>) => ({
medicineId: m._id as string,
medicineName: m.medicineName as string,
totalSpent: m.totalSpent as number,
totalQuantity: m.totalQuantity as number,
avgUnitPrice: m.avgUnitPrice as number,
purchaseCount: m.purchaseCount as number,
byMedicine: byMedicine.map((m: MedicineSpendingSummaryGroup) => ({
medicineId: m._id,
medicineName: m.medicineName,
totalSpent: m.totalSpent,
totalQuantity: m.totalQuantity,
avgUnitPrice: m.avgUnitPrice,
purchaseCount: m.purchaseCount,
})),
byPeriod: byPeriod.map((p: Record<string, unknown>) => ({
period: p._id as string,
totalSpent: p.totalSpent as number,
byPeriod: byPeriod.map((p: PeriodSpendingSummaryGroup) => ({
period: p._id,
totalSpent: p.totalSpent,
})),
};
}
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
public async getAvgUnitPriceByMedicine(
householdId: string,
medicineIds: string[],
): Promise<Map<string, { avgUnitPrice: number; currency: string | null }>> {
if (medicineIds.length === 0)
return new Map<string, { avgUnitPrice: number; currency: string | null }>();
const results = await CabinetEventModel.aggregate([
interface AvgUnitPriceGroup {
_id: string;
avgUnitPrice: number;
currency: string | null;
}
const resultsRaw = await CabinetEventModel.aggregate([
{
$match: {
householdId,
@ -211,11 +272,12 @@ export class CabinetEventsRepository {
},
]).exec();
const results = resultsRaw as unknown as AvgUnitPriceGroup[];
const map = new Map<string, { avgUnitPrice: number; currency: string | null }>();
for (const r of results) {
map.set(r._id as string, {
avgUnitPrice: r.avgUnitPrice as number,
currency: (r.currency as string | null) ?? null,
map.set(r._id, {
avgUnitPrice: r.avgUnitPrice,
currency: r.currency ?? null,
});
}
return map;

View file

@ -7,12 +7,15 @@ import {
CabinetEventListResponseSchema,
SpendingSummaryQuerySchema,
SpendingSummaryResponseSchema,
type CabinetEventQueryInput,
type SpendingSummaryQueryInput,
} from '@meshitrack/shared';
import { CabinetEventsRepository } from './cabinet-events.repository.js';
import { CabinetEventsService } from './cabinet-events.service.js';
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
type AnyCabinetEventDoc = {
_id: string | { toString: () => string };
interface SerializedCabinetEventResponse {
_id: string;
householdId: string;
userId: string;
cabinetItemId: string;
@ -22,31 +25,38 @@ type AnyCabinetEventDoc = {
quantity: number;
quantityBefore: number;
quantityAfter: number;
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
sourceType: string;
sourceId?: string | null;
reason?: string | null;
notes?: string | null;
createdAt: string | Date | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
sourceId?: string;
reason?: string;
notes?: string;
createdAt: string;
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toCabinetEventResponse(doc: CabinetEventDocument): SerializedCabinetEventResponse {
const docAny = doc as unknown as {
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
sourceId?: string | null;
reason?: string | null;
notes?: string | null;
createdAt?: { toISOString?: () => string } | string;
};
function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
return {
_id: toStr(doc._id),
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const response: SerializedCabinetEventResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
userId: doc.userId,
cabinetItemId: doc.cabinetItemId,
@ -56,17 +66,20 @@ function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
quantity: doc.quantity,
quantityBefore: doc.quantityBefore,
quantityAfter: doc.quantityAfter,
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
...(doc.currency ? { currency: doc.currency } : {}),
...(doc.storeId ? { storeId: doc.storeId } : {}),
...(doc.storeName ? { storeName: doc.storeName } : {}),
sourceType: doc.sourceType,
...(doc.sourceId ? { sourceId: doc.sourceId } : {}),
...(doc.reason ? { reason: doc.reason } : {}),
...(doc.notes ? { notes: doc.notes } : {}),
createdAt: toIso(doc.createdAt),
createdAt: createdAtStr,
};
if (docAny.unitPrice != null) response.unitPrice = docAny.unitPrice;
if (docAny.totalPrice != null) response.totalPrice = docAny.totalPrice;
if (docAny.currency) response.currency = docAny.currency;
if (docAny.storeId) response.storeId = docAny.storeId;
if (docAny.storeName) response.storeName = docAny.storeName;
if (docAny.sourceId) response.sourceId = docAny.sourceId;
if (docAny.reason) response.reason = docAny.reason;
if (docAny.notes) response.notes = docAny.notes;
return response;
}
declare module '@fastify/awilix' {
@ -97,7 +110,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const result = await service.listEvents(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as CabinetEventQueryInput;
const result = await service.listEvents(params.householdId, query);
return reply.send({
data: result.data.map(toCabinetEventResponse),
pagination: result.pagination,
@ -119,10 +134,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const params = request.params as { householdId: string; cabinetItemId: string };
const query = request.query as { cursor?: string; limit: number };
const result = await service.getEventsByItem(
request.params.householdId,
request.params.cabinetItemId,
request.query,
params.householdId,
params.cabinetItemId,
query,
);
return reply.send({
data: result.data.map(toCabinetEventResponse),
@ -142,7 +159,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const summary = await service.getSpendingSummary(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as SpendingSummaryQueryInput;
const summary = await service.getSpendingSummary(params.householdId, query);
return reply.send(summary);
},
});

View file

@ -2,6 +2,7 @@ import type {
CabinetEventsRepository,
CreateCabinetEventData,
} from './cabinet-events.repository.js';
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
interface Deps {
@ -15,16 +16,22 @@ export class CabinetEventsService {
this.cabinetEventsRepository = cabinetEventsRepository;
}
public async logEvent(data: CreateCabinetEventData) {
public async logEvent(data: CreateCabinetEventData): Promise<CabinetEventDocument> {
return this.cabinetEventsRepository.create(data);
}
public async logEvents(events: CreateCabinetEventData[]) {
public async logEvents(events: CreateCabinetEventData[]): Promise<CabinetEventDocument[]> {
if (events.length === 0) return [];
return this.cabinetEventsRepository.createMany(events);
}
public async listEvents(householdId: string, query: CabinetEventQueryInput) {
public async listEvents(
householdId: string,
query: CabinetEventQueryInput,
): Promise<{
data: CabinetEventDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.cabinetEventsRepository.findByHousehold(householdId, query);
}
@ -32,15 +39,39 @@ export class CabinetEventsService {
householdId: string,
cabinetItemId: string,
query: { cursor?: string; limit: number },
) {
): Promise<{
data: CabinetEventDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.cabinetEventsRepository.findByCabinetItem(householdId, cabinetItemId, query);
}
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
public async getSpendingSummary(
householdId: string,
query: SpendingSummaryQueryInput,
): Promise<{
totalSpent: number;
currency: string | null;
byMedicine: Array<{
medicineId: string;
medicineName: string;
totalSpent: number;
totalQuantity: number;
avgUnitPrice: number;
purchaseCount: number;
}>;
byPeriod: Array<{
period: string;
totalSpent: number;
}>;
}> {
return this.cabinetEventsRepository.getSpendingSummary(householdId, query);
}
public async getAvgUnitPrices(householdId: string, medicineIds: string[]) {
public async getAvgUnitPrices(
householdId: string,
medicineIds: string[],
): Promise<Map<string, { avgUnitPrice: number; currency: string | null }>> {
return this.cabinetEventsRepository.getAvgUnitPriceByMedicine(householdId, medicineIds);
}
}

View file

@ -1,4 +1,5 @@
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
import type {
CabinetItemStatus,
CreateCabinetItemInput,
@ -14,7 +15,13 @@ interface FindByHouseholdQuery {
}
export class CabinetRepository {
public async findByHousehold(householdId: string, query: FindByHouseholdQuery) {
public async findByHousehold(
householdId: string,
query: FindByHouseholdQuery,
): Promise<{
data: CabinetItemDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId, isDeleted: false };
if (query.medicineId) filter['medicineId'] = query.medicineId;
@ -44,15 +51,21 @@ export class CabinetRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as CabinetItemDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string) {
return CabinetItemModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
public async findById(id: string, householdId: string): Promise<CabinetItemDocument | null> {
const doc = await CabinetItemModel.findOne({ _id: id, householdId, isDeleted: false })
.lean()
.exec();
return doc as unknown as CabinetItemDocument | null;
}
public async getAggregateSummary(householdId: string) {
return CabinetItemModel.aggregate([
public async getAggregateSummary(householdId: string): Promise<Array<Record<string, unknown>>> {
const results = await CabinetItemModel.aggregate([
{ $match: { householdId, isDeleted: false, status: 'active' } },
{
$group: {
@ -69,6 +82,7 @@ export class CabinetRepository {
},
{ $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } },
]).exec();
return results as unknown as Array<Record<string, unknown>>;
}
public async create(
@ -83,21 +97,30 @@ export class CabinetRepository {
},
householdId: string,
createdBy: string,
) {
): Promise<CabinetItemDocument> {
const item = new CabinetItemModel({ ...data, householdId, createdBy });
const saved = await item.save();
return saved.toObject();
return saved.toObject() as CabinetItemDocument;
}
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) {
return CabinetItemModel.findOneAndUpdate(
public async update(
id: string,
householdId: string,
data: UpdateCabinetItemInput,
): Promise<CabinetItemDocument | null> {
const doc = await CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as CabinetItemDocument | null;
}
public async adjustQuantity(id: string, householdId: string, delta: number) {
public async adjustQuantity(
id: string,
householdId: string,
delta: number,
): Promise<CabinetItemDocument | null> {
const item = await CabinetItemModel.findOne({
_id: id,
householdId,
@ -112,18 +135,22 @@ export class CabinetRepository {
const newStatus =
newQuantity === 0 ? 'depleted' : item.status === 'depleted' ? 'active' : item.status;
return CabinetItemModel.findOneAndUpdate(
const doc = await CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { quantity: newQuantity, status: newStatus } },
{ new: true, lean: true },
).exec();
return doc as unknown as CabinetItemDocument | null;
}
public async findExpiringSoon(householdId: string, withinDays: number) {
public async findExpiringSoon(
householdId: string,
withinDays: number,
): Promise<CabinetItemDocument[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
return CabinetItemModel.find({
const docs = await CabinetItemModel.find({
householdId,
isDeleted: false,
status: 'active',
@ -132,30 +159,36 @@ export class CabinetRepository {
.sort({ expirationDate: 1 })
.lean()
.exec();
return docs as unknown as CabinetItemDocument[];
}
public async countByMedicineId(medicineId: string): Promise<number> {
return CabinetItemModel.countDocuments({ medicineId, isDeleted: false }).exec();
}
public async softDelete(id: string, householdId: string) {
return CabinetItemModel.findOneAndUpdate(
public async softDelete(id: string, householdId: string): Promise<CabinetItemDocument | null> {
const doc = await CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
return doc as unknown as CabinetItemDocument | null;
}
public async discard(id: string, householdId: string) {
return CabinetItemModel.findOneAndUpdate(
public async discard(id: string, householdId: string): Promise<CabinetItemDocument | null> {
const doc = await CabinetItemModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { quantity: 0, status: 'depleted', isDeleted: true } },
{ new: true, lean: true },
).exec();
return doc as unknown as CabinetItemDocument | null;
}
public async findActiveByMedicineForFEFO(householdId: string, medicineId: string) {
return CabinetItemModel.find({
public async findActiveByMedicineForFEFO(
householdId: string,
medicineId: string,
): Promise<CabinetItemDocument[]> {
const docs = await CabinetItemModel.find({
householdId,
medicineId,
isDeleted: false,
@ -165,5 +198,6 @@ export class CabinetRepository {
.sort({ expirationDate: 1, _id: 1 })
.lean()
.exec();
return docs as unknown as CabinetItemDocument[];
}
}

View file

@ -11,83 +11,106 @@ import {
CabinetItemListResponseSchema,
CabinetSummaryResponseSchema,
DiscardCabinetItemSchema,
type CreateCabinetItemInput,
type UpdateCabinetItemInput,
type CabinetQueryInput,
} from '@meshitrack/shared';
import { CabinetRepository } from './cabinet.repository.js';
import { CabinetService } from './cabinet.service.js';
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
type AnyCabinetDoc = {
_id: string | { toString: () => string };
interface SerializedCabinetItemResponse {
_id: string;
householdId: string;
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
medicineProductId?: string | null;
medicineProductBrand?: string | null;
concentration?: number | null;
concentrationUnit?: string | null;
medicineProductId?: string;
medicineProductBrand?: string;
concentration?: number;
concentrationUnit?: string;
quantity: number;
unit: string;
expirationDate?: Date | string | null;
expirationDate?: string;
status: string;
purchaseDate?: Date | string | null;
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
notes?: string | null;
purchaseDate?: string;
unitPrice?: number;
totalPrice?: number;
currency?: string;
storeId?: string;
storeName?: string;
notes?: string;
createdBy: string;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
createdAt: string;
updatedAt: string;
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toCabinetItemResponse(doc: CabinetItemDocument): SerializedCabinetItemResponse {
const docAny = doc as unknown as {
medicineProductId?: string | null;
medicineProductBrand?: string | null;
concentration?: number | null;
concentrationUnit?: string | null;
expirationDate?: Date | string | null;
purchaseDate?: Date | string | null;
unitPrice?: number | null;
totalPrice?: number | null;
currency?: string | null;
storeId?: string | null;
storeName?: string | null;
notes?: string | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
function toOptIso(v: Date | string | null | undefined): string | undefined {
/* v8 ignore next */
if (!v) return undefined;
if (typeof v === 'string') return v;
return v.toISOString();
}
const getIsoStr = (d: Date | string | null | undefined): string | undefined => {
if (!d) return undefined;
if (typeof d === 'string') return d;
return d.toISOString();
};
function toCabinetItemResponse(doc: AnyCabinetDoc): z.infer<typeof CabinetItemResponseSchema> {
return {
_id: toStr(doc._id),
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
const response: SerializedCabinetItemResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
medicineStrength: doc.medicineStrength,
medicineStrengthUnit: doc.medicineStrengthUnit,
medicineForm: doc.medicineForm,
...(doc.medicineProductId ? { medicineProductId: doc.medicineProductId } : {}),
...(doc.medicineProductBrand ? { medicineProductBrand: doc.medicineProductBrand } : {}),
...(doc.concentration != null ? { concentration: doc.concentration } : {}),
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
quantity: doc.quantity,
unit: doc.unit,
...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}),
status: doc.status,
...(doc.purchaseDate ? { purchaseDate: toOptIso(doc.purchaseDate) } : {}),
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
...(doc.currency ? { currency: doc.currency } : {}),
...(doc.storeId ? { storeId: doc.storeId } : {}),
...(doc.storeName ? { storeName: doc.storeName } : {}),
...(doc.notes ? { notes: doc.notes } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
if (docAny.medicineProductId) response.medicineProductId = docAny.medicineProductId;
if (docAny.medicineProductBrand) response.medicineProductBrand = docAny.medicineProductBrand;
if (docAny.concentration != null) response.concentration = docAny.concentration;
if (docAny.concentrationUnit) response.concentrationUnit = docAny.concentrationUnit;
if (docAny.expirationDate) response.expirationDate = getIsoStr(docAny.expirationDate);
if (docAny.purchaseDate) response.purchaseDate = getIsoStr(docAny.purchaseDate);
if (docAny.unitPrice != null) response.unitPrice = docAny.unitPrice;
if (docAny.totalPrice != null) response.totalPrice = docAny.totalPrice;
if (docAny.currency) response.currency = docAny.currency;
if (docAny.storeId) response.storeId = docAny.storeId;
if (docAny.storeName) response.storeName = docAny.storeName;
if (docAny.notes) response.notes = docAny.notes;
return response;
}
declare module '@fastify/awilix' {
@ -118,7 +141,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const result = await service.list(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as CabinetQueryInput;
const result = await service.list(params.householdId, query);
return reply.send({
data: result.data.map(toCabinetItemResponse),
pagination: result.pagination,
@ -136,7 +161,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const data = await service.getSummary(request.params.householdId);
const params = request.params as { householdId: string };
const data = await service.getSummary(params.householdId);
return reply.send({ data });
},
});
@ -156,7 +182,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const items = await service.getExpiringSoon(request.params.householdId, request.query.days);
const params = request.params as { householdId: string };
const query = request.query as { days: number };
const items = await service.getExpiringSoon(params.householdId, query.days);
return reply.send({ data: items.map(toCabinetItemResponse) });
},
});
@ -171,7 +199,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const item = await service.getById(params.id, params.householdId);
return reply.send(toCabinetItemResponse(item));
},
});
@ -187,11 +216,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const item = await service.addItem(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateCabinetItemInput;
const item = await service.addItem(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toCabinetItemResponse(item));
},
});
@ -207,10 +234,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateCabinetItemInput;
const item = await service.update(
request.params.id,
request.params.householdId,
request.body,
params.id,
params.householdId,
body,
request.user.keycloakId,
);
return reply.send(toCabinetItemResponse(item));
@ -228,12 +257,14 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const params = request.params as { householdId: string; id: string };
const body = request.body as { delta: number; reason?: string };
const item = await service.adjustQuantity(
request.params.id,
request.params.householdId,
request.body.delta,
params.id,
params.householdId,
body.delta,
request.user.keycloakId,
request.body.reason,
body.reason,
);
return reply.send(toCabinetItemResponse(item));
},
@ -249,11 +280,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string; id: string };
await service.delete(params.id, params.householdId, request.user.keycloakId);
return reply.status(204).send();
},
});
@ -269,12 +297,14 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
const params = request.params as { householdId: string; id: string };
const body = request.body as { reason: string; notes?: string };
const item = await service.discard(
request.params.id,
request.params.householdId,
params.id,
params.householdId,
request.user.keycloakId,
request.body.reason,
request.body.notes,
body.reason,
body.notes,
);
return reply.send(toCabinetItemResponse(item));
},

View file

@ -2,6 +2,7 @@ import type { CabinetRepository } from './cabinet.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
import type {
CreateCabinetItemInput,
@ -17,6 +18,18 @@ interface Deps {
cabinetEventsService: CabinetEventsService;
}
interface CabinetSummaryItem {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: string | null;
itemCount: number;
}
export class CabinetService {
private readonly cabinetRepository: CabinetRepository;
private readonly medicinesRepository: MedicinesRepository;
@ -35,11 +48,17 @@ export class CabinetService {
this.cabinetEventsService = cabinetEventsService;
}
public async list(householdId: string, query: CabinetQueryInput) {
public async list(
householdId: string,
query: CabinetQueryInput,
): Promise<{
data: CabinetItemDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.cabinetRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<CabinetItemDocument> {
const item = await this.cabinetRepository.findById(id, householdId);
if (!item) {
throw new NotFoundError('Cabinet item not found');
@ -47,7 +66,7 @@ export class CabinetService {
return item;
}
public async getSummary(householdId: string) {
public async getSummary(householdId: string): Promise<CabinetSummaryItem[]> {
const results = await this.cabinetRepository.getAggregateSummary(householdId);
return results.map((r: Record<string, unknown>) => ({
medicineId: r._id as string,
@ -62,7 +81,11 @@ export class CabinetService {
}));
}
public async addItem(data: CreateCabinetItemInput, householdId: string, createdBy: string) {
public async addItem(
data: CreateCabinetItemInput,
householdId: string,
createdBy: string,
): Promise<CabinetItemDocument> {
const medicine = await this.medicinesRepository.findById(data.medicineId, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
@ -125,7 +148,7 @@ export class CabinetService {
householdId: string,
data: UpdateCabinetItemInput,
userId: string,
) {
): Promise<CabinetItemDocument> {
const existing = await this.getById(id, householdId);
const updated = await this.cabinetRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Cabinet item not found');
@ -154,7 +177,7 @@ export class CabinetService {
delta: number,
userId: string,
reason?: string,
) {
): Promise<CabinetItemDocument> {
if (delta === 0) {
throw new BadRequestError('Delta must be non-zero');
}
@ -180,11 +203,18 @@ export class CabinetService {
return updated;
}
public async getExpiringSoon(householdId: string, withinDays: number) {
public async getExpiringSoon(
householdId: string,
withinDays: number,
): Promise<CabinetItemDocument[]> {
return this.cabinetRepository.findExpiringSoon(householdId, withinDays);
}
public async delete(id: string, householdId: string, userId: string) {
public async delete(
id: string,
householdId: string,
userId: string,
): Promise<CabinetItemDocument> {
const existing = await this.getById(id, householdId);
const deleted = await this.cabinetRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Cabinet item not found');
@ -211,7 +241,7 @@ export class CabinetService {
userId: string,
reason: string,
notes?: string,
) {
): Promise<CabinetItemDocument> {
const existing = await this.getById(id, householdId);
if (existing.quantity === 0) {
throw new BadRequestError('Cannot discard an item with zero quantity');

View file

@ -1,15 +1,18 @@
import type mongoose from 'mongoose';
import { HouseholdModel } from '../../schemas/household.schema.js';
import type { HouseholdDocument } from '../../schemas/household.schema.js';
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
import { HouseholdRole } from '@meshitrack/shared';
export class HouseholdsRepository {
public async findById(id: string) {
return HouseholdModel.findById(id).lean().exec();
public async findById(id: string): Promise<HouseholdDocument | null> {
const doc = await HouseholdModel.findById(id).lean().exec();
return doc as unknown as HouseholdDocument | null;
}
public async findByInviteCode(inviteCode: string) {
return HouseholdModel.findOne({ inviteCode }).lean().exec();
public async findByInviteCode(inviteCode: string): Promise<HouseholdDocument | null> {
const doc = await HouseholdModel.findOne({ inviteCode }).lean().exec();
return doc as unknown as HouseholdDocument | null;
}
public async create(
@ -17,7 +20,7 @@ export class HouseholdsRepository {
ownerUserId: string,
inviteCode: string,
session?: mongoose.ClientSession,
) {
): Promise<HouseholdDocument> {
const household = new HouseholdModel({
...data,
ownerUserId,
@ -25,11 +28,16 @@ export class HouseholdsRepository {
members: [{ userId: ownerUserId, role: HouseholdRole.OWNER, joinedAt: new Date() }],
});
const saved = await household.save({ session });
return saved.toObject();
return saved.toObject() as HouseholdDocument;
}
public async update(id: string, data: UpdateHouseholdInput) {
return HouseholdModel.findByIdAndUpdate(id, { $set: data }, { new: true, lean: true }).exec();
public async update(id: string, data: UpdateHouseholdInput): Promise<HouseholdDocument | null> {
const doc = await HouseholdModel.findByIdAndUpdate(
id,
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as HouseholdDocument | null;
}
public async addMember(
@ -37,19 +45,21 @@ export class HouseholdsRepository {
userId: string,
role: HouseholdRole,
session?: mongoose.ClientSession,
) {
return HouseholdModel.findByIdAndUpdate(
): Promise<HouseholdDocument | null> {
const doc = await HouseholdModel.findByIdAndUpdate(
id,
{ $push: { members: { userId, role, joinedAt: new Date() } } },
{ new: true, lean: true, session },
).exec();
return doc as unknown as HouseholdDocument | null;
}
public async updateInviteCode(id: string, inviteCode: string) {
return HouseholdModel.findByIdAndUpdate(
public async updateInviteCode(id: string, inviteCode: string): Promise<HouseholdDocument | null> {
const doc = await HouseholdModel.findByIdAndUpdate(
id,
{ $set: { inviteCode } },
{ new: true, lean: true },
).exec();
return doc as unknown as HouseholdDocument | null;
}
}

View file

@ -7,53 +7,76 @@ import {
UpdateHouseholdSchema,
JoinHouseholdSchema,
HouseholdResponseSchema,
type CreateHouseholdInput,
type UpdateHouseholdInput,
} from '@meshitrack/shared';
type AnyHouseholdDoc = {
_id: string | { toString: () => string };
name: string;
ownerUserId: string;
members: ReadonlyArray<{
userId: string;
role: string;
joinedAt: string | { toISOString: () => string };
}>;
inviteCode: string;
settings?: { timezone?: string; currency?: string; language?: string } | null;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
}
function toIso(v: string | { toISOString: () => string }): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toHouseholdResponse(doc: AnyHouseholdDoc): z.infer<typeof HouseholdResponseSchema> {
return {
_id: toStr(doc._id),
name: doc.name,
ownerUserId: doc.ownerUserId,
members: doc.members.map((m) => ({
userId: m.userId,
role: m.role,
joinedAt: toIso(m.joinedAt),
})),
inviteCode: doc.inviteCode,
settings: {
timezone: doc.settings?.timezone ?? 'UTC',
currency: doc.settings?.currency ?? 'USD',
language: doc.settings?.language ?? 'en',
},
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
};
}
import { HouseholdsRepository } from './households.repository.js';
import { HouseholdsService } from './households.service.js';
import type { HouseholdDocument } from '../../schemas/household.schema.js';
interface SerializedHouseholdResponse {
_id: string;
name: string;
ownerUserId: string;
members: Array<{
userId: string;
role: string;
joinedAt: string;
}>;
inviteCode: string;
settings: {
timezone: string;
currency: string;
language: string;
};
createdAt: string;
updatedAt: string;
}
function toHouseholdResponse(doc: HouseholdDocument): SerializedHouseholdResponse {
const docAny = doc as unknown as {
settings?: { timezone?: string; currency?: string; language?: string } | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
const members = doc.members.map((m) => {
const joinedAtStr =
typeof m.joinedAt === 'object' && typeof m.joinedAt?.toISOString === 'function'
? m.joinedAt.toISOString()
: String(m.joinedAt || '');
return {
userId: m.userId,
role: m.role,
joinedAt: joinedAtStr,
};
});
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
return {
_id: doc._id.toString(),
name: doc.name,
ownerUserId: doc.ownerUserId,
members,
inviteCode: doc.inviteCode,
settings: {
timezone: docAny.settings?.timezone ?? 'UTC',
currency: docAny.settings?.currency ?? 'USD',
language: docAny.settings?.language ?? 'en',
},
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
}
declare module '@fastify/awilix' {
interface Cradle {
@ -83,7 +106,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('householdsService');
const household = await service.create(request.body, request.user.keycloakId);
const body = request.body as CreateHouseholdInput;
const household = await service.create(body, request.user.keycloakId);
return reply.status(201).send(toHouseholdResponse(household));
},
});
@ -98,7 +122,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('householdsService');
const household = await service.getById(request.params.householdId);
const params = request.params as { householdId: string };
const household = await service.getById(params.householdId);
return reply.send(toHouseholdResponse(household));
},
});
@ -114,11 +139,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('householdsService');
const household = await service.update(
request.params.householdId,
request.body,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as UpdateHouseholdInput;
const household = await service.update(params.householdId, body, request.user.keycloakId);
return reply.send(toHouseholdResponse(household));
},
});
@ -133,8 +156,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('householdsService');
const params = request.params as { householdId: string };
const household = await service.generateInviteCode(
request.params.householdId,
params.householdId,
request.user.keycloakId,
);
return reply.send(toHouseholdResponse(household));
@ -152,7 +176,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('householdsService');
const household = await service.join(request.body.inviteCode, request.user.keycloakId);
const body = request.body as { inviteCode: string };
const household = await service.join(body.inviteCode, request.user.keycloakId);
return reply.send(toHouseholdResponse(household));
},
});

View file

@ -2,6 +2,7 @@ import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import type { HouseholdsRepository } from './households.repository.js';
import type { UsersRepository } from '../users/users.repository.js';
import type { HouseholdDocument } from '../../schemas/household.schema.js';
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
import { HouseholdRole } from '@meshitrack/shared';
import { NotFoundError, ForbiddenError, ConflictError } from '../../common/errors.js';
@ -20,7 +21,10 @@ export class HouseholdsService {
this.usersRepository = usersRepository;
}
public async create(data: CreateHouseholdInput, ownerKeycloakId: string) {
public async create(
data: CreateHouseholdInput,
ownerKeycloakId: string,
): Promise<HouseholdDocument> {
const inviteCode = uuidv4().slice(0, 8).toUpperCase();
const session = await mongoose.startSession();
try {
@ -52,11 +56,11 @@ export class HouseholdsService {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
await session.endSession();
}
}
public async getById(id: string) {
public async getById(id: string): Promise<HouseholdDocument> {
const household = await this.householdsRepository.findById(id);
if (!household) {
throw new NotFoundError('Household not found');
@ -64,7 +68,11 @@ export class HouseholdsService {
return household;
}
public async update(id: string, data: UpdateHouseholdInput, requestingUserId: string) {
public async update(
id: string,
data: UpdateHouseholdInput,
requestingUserId: string,
): Promise<HouseholdDocument> {
const household = await this.getById(id);
const member = household.members.find((m) => m.userId === requestingUserId);
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
@ -75,7 +83,10 @@ export class HouseholdsService {
return updated;
}
public async generateInviteCode(id: string, requestingUserId: string) {
public async generateInviteCode(
id: string,
requestingUserId: string,
): Promise<HouseholdDocument> {
const household = await this.getById(id);
const member = household.members.find((m) => m.userId === requestingUserId);
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
@ -87,7 +98,7 @@ export class HouseholdsService {
return updated;
}
public async join(inviteCode: string, userId: string) {
public async join(inviteCode: string, userId: string): Promise<HouseholdDocument> {
const household = await this.householdsRepository.findByInviteCode(inviteCode);
if (!household) {
throw new NotFoundError('Invalid invite code');
@ -129,7 +140,7 @@ export class HouseholdsService {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
await session.endSession();
}
}
}

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { NutritionInfo } from '@meshitrack/shared';
export interface NutritionExtractionResult {

View file

@ -9,10 +9,6 @@ import type {
} from './llm-provider.interface.js';
export class NoOpLlmProvider implements ILlmProvider {
private warn(method: string): void {
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
}
public async extractNutrition(_input: {
text?: string;
image?: Buffer;
@ -45,4 +41,8 @@ export class NoOpLlmProvider implements ILlmProvider {
this.warn('parseNaturalLanguage');
return null;
}
private warn(method: string): void {
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
}
}

View file

@ -1,4 +1,5 @@
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
import type {
MedicinePriceHistoryQueryInput,
MedicinePriceAnalyticsQueryInput,
@ -24,17 +25,20 @@ export interface CreateMedicinePriceData {
}
export class MedicinePricesRepository {
public async create(data: CreateMedicinePriceData) {
public async create(data: CreateMedicinePriceData): Promise<MedicinePriceDocument> {
const record = new MedicinePriceModel(data);
const saved = await record.save();
return saved.toObject();
return saved.toObject() as MedicinePriceDocument;
}
public async findByMedicine(
householdId: string,
medicineId: string,
query: MedicinePriceHistoryQueryInput,
) {
): Promise<{
data: MedicinePriceDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId, medicineId };
if (query.storeId) filter['storeId'] = query.storeId;
@ -63,12 +67,37 @@ export class MedicinePricesRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as MedicinePriceDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async compareStores(householdId: string, medicineId: string) {
// Get the most recent price per store for this medicine
const results = await MedicinePriceModel.aggregate([
public async compareStores(
householdId: string,
medicineId: string,
): Promise<
Array<{
storeId: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}>
> {
interface CompareStoresGroup {
_id: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}
const resultsRaw = await MedicinePriceModel.aggregate([
{ $match: { householdId, medicineId } },
{ $sort: { storeId: 1, date: -1 } },
{
@ -85,28 +114,88 @@ export class MedicinePricesRepository {
{ $sort: { latestPricePerUnit: 1 } },
]).exec();
const results = resultsRaw as unknown as CompareStoresGroup[];
return results.map((r) => ({
storeId: r._id as string,
storeName: r.storeName as string,
latestPrice: r.latestPrice as number,
latestPricePerUnit: r.latestPricePerUnit as number,
currency: r.currency as string,
date: r.date as Date,
isInsurancePrice: r.isInsurancePrice as boolean,
storeId: r._id,
storeName: r.storeName,
latestPrice: r.latestPrice,
latestPricePerUnit: r.latestPricePerUnit,
currency: r.currency,
date: r.date,
isInsurancePrice: r.isInsurancePrice,
}));
}
public async getLatestForMedicine(householdId: string, medicineId: string, storeId?: string) {
public async getLatestForMedicine(
householdId: string,
medicineId: string,
storeId?: string,
): Promise<MedicinePriceDocument | null> {
const filter: Record<string, unknown> = { householdId, medicineId };
if (storeId) filter['storeId'] = storeId;
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
const doc = await MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
return doc as unknown as MedicinePriceDocument | null;
}
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
public async getAnalytics(
householdId: string,
query: MedicinePriceAnalyticsQueryInput,
): Promise<{
spendingOverTime: Array<{ period: string; total: number }>;
topBySpending: Array<{
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}>;
spendingByStore: Array<{
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}>;
priceAlerts: Array<{
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}>;
}> {
const dateFormat =
query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y';
const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([
interface SpendingOverTimeGroup {
period: string;
total: number;
}
interface TopBySpendingGroup {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}
interface SpendingByStoreGroup {
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}
interface PriceAlertGroup {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}
const [spendingOverTimeRaw, topBySpendingRaw, spendingByStoreRaw] = await Promise.all([
MedicinePriceModel.aggregate([
{ $match: { householdId } },
{
@ -158,7 +247,7 @@ export class MedicinePricesRepository {
]);
// Price alerts: medicines where the most recent price is >10% higher than the previous
const priceAlerts = await MedicinePriceModel.aggregate([
const priceAlertsRaw = await MedicinePriceModel.aggregate([
{ $match: { householdId } },
{ $sort: { medicineId: 1, storeId: 1, date: -1 } },
{
@ -201,27 +290,10 @@ export class MedicinePricesRepository {
]).exec();
return {
spendingOverTime: spendingOverTime as { period: string; total: number }[],
topBySpending: topBySpending as {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}[],
spendingByStore: spendingByStore as {
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}[],
priceAlerts: priceAlerts as {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}[],
spendingOverTime: spendingOverTimeRaw as unknown as SpendingOverTimeGroup[],
topBySpending: topBySpendingRaw as unknown as TopBySpendingGroup[],
spendingByStore: spendingByStoreRaw as unknown as SpendingByStoreGroup[],
priceAlerts: priceAlertsRaw as unknown as PriceAlertGroup[],
};
}
}

View file

@ -10,12 +10,16 @@ import {
MedicinePriceHistoryResponseSchema,
StoreComparisonResponseSchema,
MedicineSpendingAnalyticsResponseSchema,
type CreateMedicinePriceRecordInput,
type MedicinePriceHistoryQueryInput,
type MedicinePriceAnalyticsQueryInput,
} from '@meshitrack/shared';
import { MedicinePricesRepository } from './medicine-prices.repository.js';
import { MedicinePricesService } from './medicine-prices.service.js';
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
type AnyPriceDoc = {
_id: string | { toString: () => string };
interface SerializedMedicinePriceRecordResponse {
_id: string;
householdId: string;
medicineProductId: string;
medicineProductBrand: string;
@ -28,22 +32,32 @@ type AnyPriceDoc = {
quantity: number;
unit: string;
pricePerUnit: number;
date: Date | string | { toISOString: () => string };
date: string;
isInsurancePrice: boolean;
notes?: string;
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
};
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
createdAt: string;
}
function toPriceRecordResponse(rawDoc: unknown) {
const doc = rawDoc as AnyPriceDoc;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
function toPriceRecordResponse(doc: MedicinePriceDocument): SerializedMedicinePriceRecordResponse {
const docAny = doc as unknown as {
notes?: string | null;
date?: { toISOString?: () => string } | string | Date;
createdAt?: { toISOString?: () => string } | string | Date;
};
const getIsoStr = (
d: { toISOString?: () => string } | string | Date | undefined | null,
): string => {
if (!d) return '';
if (typeof d === 'string') return d;
if (d instanceof Date) return d.toISOString();
if (typeof d.toISOString === 'function') return d.toISOString();
return String(d);
};
const response: SerializedMedicinePriceRecordResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
medicineProductId: doc.medicineProductId,
medicineProductBrand: doc.medicineProductBrand,
@ -56,12 +70,15 @@ function toPriceRecordResponse(rawDoc: unknown) {
quantity: doc.quantity,
unit: doc.unit,
pricePerUnit: doc.pricePerUnit,
date: toIso(doc.date),
date: getIsoStr(docAny.date),
isInsurancePrice: doc.isInsurancePrice,
...(doc.notes != null ? { notes: doc.notes } : {}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
createdAt: getIsoStr(docAny.createdAt),
};
if (docAny.notes) response.notes = docAny.notes;
return response;
}
declare module '@fastify/awilix' {
@ -91,11 +108,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const record = await service.recordPrice(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateMedicinePriceRecordInput;
const record = await service.recordPrice(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toPriceRecordResponse(record));
},
});
@ -110,11 +125,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const result = await service.getPriceHistory(
request.params.householdId,
request.params.medicineId,
request.query,
);
const params = request.params as { householdId: string; medicineId: string };
const query = request.query as MedicinePriceHistoryQueryInput;
const result = await service.getPriceHistory(params.householdId, params.medicineId, query);
return reply.send({
data: result.data.map(toPriceRecordResponse),
pagination: result.pagination,
@ -131,14 +144,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const results = await service.compareStores(
request.params.householdId,
request.params.medicineId,
);
const params = request.params as { householdId: string; medicineId: string };
const results = await service.compareStores(params.householdId, params.medicineId);
return reply.send({
data: results.map((r) => ({
...r,
date: toIso(r.date),
date: r.date.toISOString(),
})),
});
},
@ -154,7 +165,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinePricesService');
const analytics = await service.getAnalytics(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as MedicinePriceAnalyticsQueryInput;
const analytics = await service.getAnalytics(params.householdId, query);
return reply.send(analytics);
},
});

View file

@ -1,6 +1,7 @@
import type { MedicinePricesRepository } from './medicine-prices.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { StoresRepository } from '../stores/stores.repository.js';
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
import type {
CreateMedicinePriceRecordInput,
MedicinePriceHistoryQueryInput,
@ -33,7 +34,7 @@ export class MedicinePricesService {
data: CreateMedicinePriceRecordInput,
householdId: string,
userId: string,
) {
): Promise<MedicinePriceDocument> {
const product = await this.medicineProductsRepository.findById(
data.medicineProductId,
householdId,
@ -70,11 +71,27 @@ export class MedicinePricesService {
householdId: string,
medicineId: string,
query: MedicinePriceHistoryQueryInput,
) {
): Promise<{
data: MedicinePriceDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query);
}
public async compareStores(householdId: string, medicineId: string) {
public async compareStores(
householdId: string,
medicineId: string,
): Promise<
Array<{
storeId: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}>
> {
return this.medicinePricesRepository.compareStores(householdId, medicineId);
}
@ -91,7 +108,32 @@ export class MedicinePricesService {
return record ? (record.pricePerUnit as number) : null;
}
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
public async getAnalytics(
householdId: string,
query: MedicinePriceAnalyticsQueryInput,
): Promise<{
spendingOverTime: Array<{ period: string; total: number }>;
topBySpending: Array<{
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}>;
spendingByStore: Array<{
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}>;
priceAlerts: Array<{
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}>;
}> {
return this.medicinePricesRepository.getAnalytics(householdId, query);
}
}

View file

@ -1,4 +1,5 @@
import { MedicineProductModel } from '../../schemas/medicine-product.schema.js';
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
interface FindByMedicineQuery {
@ -7,7 +8,14 @@ interface FindByMedicineQuery {
}
export class MedicineProductsRepository {
public async findByMedicine(householdId: string, medicineId: string, query: FindByMedicineQuery) {
public async findByMedicine(
householdId: string,
medicineId: string,
query: FindByMedicineQuery,
): Promise<{
data: MedicineProductDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId, medicineId, isDeleted: false };
if (query.cursor) {
@ -27,11 +35,17 @@ export class MedicineProductsRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as MedicineProductDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string) {
return MedicineProductModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
public async findById(id: string, householdId: string): Promise<MedicineProductDocument | null> {
const doc = await MedicineProductModel.findOne({ _id: id, householdId, isDeleted: false })
.lean()
.exec();
return doc as unknown as MedicineProductDocument | null;
}
public async create(
@ -40,7 +54,7 @@ export class MedicineProductsRepository {
medicineId: string,
medicineName: string,
createdBy: string,
) {
): Promise<MedicineProductDocument> {
const product = new MedicineProductModel({
...data,
householdId,
@ -49,26 +63,35 @@ export class MedicineProductsRepository {
createdBy,
});
const saved = await product.save();
return saved.toObject();
return saved.toObject() as MedicineProductDocument;
}
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
return MedicineProductModel.findOneAndUpdate(
public async update(
id: string,
householdId: string,
data: UpdateMedicineProductInput,
): Promise<MedicineProductDocument | null> {
const doc = await MedicineProductModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as MedicineProductDocument | null;
}
public async countByMedicineId(medicineId: string): Promise<number> {
return MedicineProductModel.countDocuments({ medicineId, isDeleted: false }).exec();
}
public async softDelete(id: string, householdId: string) {
return MedicineProductModel.findOneAndUpdate(
public async softDelete(
id: string,
householdId: string,
): Promise<MedicineProductDocument | null> {
const doc = await MedicineProductModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
return doc as unknown as MedicineProductDocument | null;
}
}

View file

@ -7,56 +7,74 @@ import {
UpdateMedicineProductSchema,
MedicineProductResponseSchema,
MedicineProductListResponseSchema,
type CreateMedicineProductInput,
type UpdateMedicineProductInput,
} from '@meshitrack/shared';
import { MedicineProductsRepository } from './medicine-products.repository.js';
import { MedicineProductsService } from './medicine-products.service.js';
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
type AnyProductDoc = {
_id: string | { toString: () => string };
interface SerializedMedicineProductResponse {
_id: string;
householdId: string;
medicineId: string;
medicineName: string;
brand: string;
manufacturer?: string | null;
manufacturer?: string;
packageSize: number;
packageUnit: string;
concentration?: number | null;
concentrationUnit?: string | null;
imageUrl?: string | null;
notes?: string | null;
concentration?: number;
concentrationUnit?: string;
imageUrl?: string;
notes?: string;
source: string;
createdBy: string;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
createdAt: string;
updatedAt: string;
}
function toIso(v: string | { toISOString: () => string }): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toProductResponse(doc: MedicineProductDocument): SerializedMedicineProductResponse {
const docAny = doc as unknown as {
manufacturer?: string | null;
concentration?: number | null;
concentrationUnit?: string | null;
imageUrl?: string | null;
notes?: string | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
function toProductResponse(doc: AnyProductDoc): z.infer<typeof MedicineProductResponseSchema> {
return {
_id: toStr(doc._id),
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
const response: SerializedMedicineProductResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
medicineId: doc.medicineId,
medicineName: doc.medicineName,
brand: doc.brand,
...(doc.manufacturer ? { manufacturer: doc.manufacturer } : {}),
packageSize: doc.packageSize,
packageUnit: doc.packageUnit,
...(doc.concentration ? { concentration: doc.concentration } : {}),
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
...(doc.notes ? { notes: doc.notes } : {}),
source: doc.source,
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
if (docAny.manufacturer) response.manufacturer = docAny.manufacturer;
if (docAny.concentration != null) response.concentration = docAny.concentration;
if (docAny.concentrationUnit) response.concentrationUnit = docAny.concentrationUnit;
if (docAny.imageUrl) response.imageUrl = docAny.imageUrl;
if (docAny.notes) response.notes = docAny.notes;
return response;
}
declare module '@fastify/awilix' {
@ -93,11 +111,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const result = await service.listByMedicine(
request.params.householdId,
request.params.medicineId,
request.query,
);
const params = request.params as { householdId: string; medicineId: string };
const query = request.query as { cursor?: string; limit: number };
const result = await service.listByMedicine(params.householdId, params.medicineId, query);
return reply.send({
data: result.data.map(toProductResponse),
pagination: result.pagination,
@ -115,7 +131,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const product = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const product = await service.getById(params.id, params.householdId);
return reply.send(toProductResponse(product));
},
});
@ -131,10 +148,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const params = request.params as { householdId: string; medicineId: string };
const body = request.body as CreateMedicineProductInput;
const product = await service.create(
request.body,
request.params.householdId,
request.params.medicineId,
body,
params.householdId,
params.medicineId,
request.user.keycloakId,
);
return reply.status(201).send(toProductResponse(product));
@ -152,11 +171,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
const product = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateMedicineProductInput;
const product = await service.update(params.id, params.householdId, body);
return reply.send(toProductResponse(product));
},
});
@ -171,7 +188,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicineProductsService');
await service.delete(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
await service.delete(params.id, params.householdId);
return reply.status(204).send();
},
});

View file

@ -1,5 +1,6 @@
import type { MedicineProductsRepository } from './medicine-products.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
@ -21,11 +22,14 @@ export class MedicineProductsService {
householdId: string,
medicineId: string,
query: { cursor?: string; limit: number },
) {
): Promise<{
data: MedicineProductDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.medicineProductsRepository.findByMedicine(householdId, medicineId, query);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<MedicineProductDocument> {
const product = await this.medicineProductsRepository.findById(id, householdId);
if (!product) {
throw new NotFoundError('Medicine product not found');
@ -38,7 +42,7 @@ export class MedicineProductsService {
householdId: string,
medicineId: string,
createdBy: string,
) {
): Promise<MedicineProductDocument> {
const medicine = await this.medicinesRepository.findById(medicineId, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
@ -53,14 +57,18 @@ export class MedicineProductsService {
);
}
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
public async update(
id: string,
householdId: string,
data: UpdateMedicineProductInput,
): Promise<MedicineProductDocument> {
await this.getById(id, householdId);
const updated = await this.medicineProductsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Medicine product not found');
return updated;
}
public async delete(id: string, householdId: string) {
public async delete(id: string, householdId: string): Promise<MedicineProductDocument> {
await this.getById(id, householdId);
const deleted = await this.medicineProductsRepository.softDelete(id, householdId);
if (!deleted) throw new NotFoundError('Medicine product not found');

View file

@ -1,4 +1,5 @@
import { MedicineModel } from '../../schemas/medicine.schema.js';
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
import type {
CreateMedicineInput,
UpdateMedicineInput,
@ -6,7 +7,13 @@ import type {
} from '@meshitrack/shared';
export class MedicinesRepository {
public async findByHousehold(householdId: string, query: MedicineQueryInput) {
public async findByHousehold(
householdId: string,
query: MedicineQueryInput,
): Promise<{
data: MedicineDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId, isDeleted: false };
if (query.category) filter['category'] = query.category;
@ -30,11 +37,17 @@ export class MedicinesRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as MedicineDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string) {
return MedicineModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
public async findById(id: string, householdId: string): Promise<MedicineDocument | null> {
const doc = await MedicineModel.findOne({ _id: id, householdId, isDeleted: false })
.lean()
.exec();
return doc as unknown as MedicineDocument | null;
}
public async findDuplicate(
@ -44,7 +57,7 @@ export class MedicinesRepository {
strengthUnit: string,
form: string,
excludeId?: string,
) {
): Promise<MedicineDocument | null> {
const filter: Record<string, unknown> = {
householdId,
name,
@ -54,28 +67,39 @@ export class MedicinesRepository {
isDeleted: false,
};
if (excludeId) filter['_id'] = { $ne: excludeId };
return MedicineModel.findOne(filter).lean().exec();
const doc = await MedicineModel.findOne(filter).lean().exec();
return doc as unknown as MedicineDocument | null;
}
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
public async create(
data: CreateMedicineInput,
householdId: string,
createdBy: string,
): Promise<MedicineDocument> {
const medicine = new MedicineModel({ ...data, householdId, createdBy });
const saved = await medicine.save();
return saved.toObject();
return saved.toObject() as MedicineDocument;
}
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
return MedicineModel.findOneAndUpdate(
public async update(
id: string,
householdId: string,
data: UpdateMedicineInput,
): Promise<MedicineDocument | null> {
const doc = await MedicineModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as MedicineDocument | null;
}
public async softDelete(id: string, householdId: string) {
return MedicineModel.findOneAndUpdate(
public async softDelete(id: string, householdId: string): Promise<MedicineDocument | null> {
const doc = await MedicineModel.findOneAndUpdate(
{ _id: id, householdId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
return doc as unknown as MedicineDocument | null;
}
}

View file

@ -8,48 +8,65 @@ import {
MedicineQuerySchema,
MedicineResponseSchema,
MedicineListResponseSchema,
type CreateMedicineInput,
type UpdateMedicineInput,
type MedicineQueryInput,
} from '@meshitrack/shared';
import { MedicinesRepository } from './medicines.repository.js';
import { MedicinesService } from './medicines.service.js';
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
type AnyMedicineDoc = {
_id: string | { toString: () => string };
interface SerializedMedicineResponse {
_id: string;
householdId: string;
name: string;
form: string;
strength: number;
strengthUnit: string;
category: string;
notes?: string | null;
notes?: string;
tags: string[];
createdBy: string;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
createdAt: string;
updatedAt: string;
}
function toIso(v: string | { toISOString: () => string }): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toMedicineResponse(doc: MedicineDocument): SerializedMedicineResponse {
const docAny = doc as unknown as {
notes?: string | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
function toMedicineResponse(doc: AnyMedicineDoc): z.infer<typeof MedicineResponseSchema> {
return {
_id: toStr(doc._id),
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
const result: SerializedMedicineResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
form: doc.form,
strength: doc.strength,
strengthUnit: doc.strengthUnit,
category: doc.category,
...(doc.notes ? { notes: doc.notes } : {}),
tags: doc.tags,
tags: doc.tags || [],
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
if (docAny.notes) {
result.notes = docAny.notes;
}
return result;
}
declare module '@fastify/awilix' {
@ -80,7 +97,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const result = await service.list(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as MedicineQueryInput;
const result = await service.list(params.householdId, query);
return reply.send({
data: result.data.map(toMedicineResponse),
pagination: result.pagination,
@ -98,7 +117,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const medicine = await service.getById(params.id, params.householdId);
return reply.send(toMedicineResponse(medicine));
},
});
@ -114,11 +134,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateMedicineInput;
const medicine = await service.create(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toMedicineResponse(medicine));
},
});
@ -134,11 +152,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
const medicine = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateMedicineInput;
const medicine = await service.update(params.id, params.householdId, body);
return reply.send(toMedicineResponse(medicine));
},
});
@ -153,7 +169,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('medicinesService');
await service.delete(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
await service.delete(params.id, params.householdId);
return reply.status(204).send();
},
});

View file

@ -1,5 +1,6 @@
import type { MedicinesRepository } from './medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
import type {
CreateMedicineInput,
UpdateMedicineInput,
@ -21,11 +22,17 @@ export class MedicinesService {
this.medicineProductsRepository = medicineProductsRepository;
}
public async list(householdId: string, query: MedicineQueryInput) {
public async list(
householdId: string,
query: MedicineQueryInput,
): Promise<{
data: MedicineDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.medicinesRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<MedicineDocument> {
const medicine = await this.medicinesRepository.findById(id, householdId);
if (!medicine) {
throw new NotFoundError('Medicine not found');
@ -33,7 +40,11 @@ export class MedicinesService {
return medicine;
}
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
public async create(
data: CreateMedicineInput,
householdId: string,
createdBy: string,
): Promise<MedicineDocument> {
const existing = await this.medicinesRepository.findDuplicate(
householdId,
data.name,
@ -47,7 +58,11 @@ export class MedicinesService {
return this.medicinesRepository.create(data, householdId, createdBy);
}
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
public async update(
id: string,
householdId: string,
data: UpdateMedicineInput,
): Promise<MedicineDocument> {
await this.getById(id, householdId);
if (data.name || data.strength || data.strengthUnit || data.form) {
@ -75,7 +90,7 @@ export class MedicinesService {
return updated;
}
public async delete(id: string, householdId: string) {
public async delete(id: string, householdId: string): Promise<MedicineDocument> {
await this.getById(id, householdId);
const productCount = await this.medicineProductsRepository.countByMedicineId(id);

View file

@ -1,21 +1,63 @@
import { NutritionTargetModel } from '../../schemas/nutrition-target.schema.js';
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
import type { UpdateWriteOpResult } from 'mongoose';
export interface CreateNutritionTargetData {
userId: string;
householdId: string;
dailyCalories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG?: number;
sodiumMg?: number;
sugarG?: number;
isActive?: boolean;
}
export interface UpdateNutritionTargetData {
dailyCalories?: number;
proteinG?: number;
carbsG?: number;
fatG?: number;
fiberG?: number;
sodiumMg?: number;
sugarG?: number;
isActive?: boolean;
}
export class NutritionTargetRepository {
public async findByUser(userId: string, householdId: string) {
return NutritionTargetModel.findOne({ userId, householdId, isActive: true }).lean().exec();
public async findByUser(
userId: string,
householdId: string,
): Promise<NutritionTargetDocument | null> {
const doc = await NutritionTargetModel.findOne({ userId, householdId, isActive: true })
.lean()
.exec();
return doc as unknown as NutritionTargetDocument | null;
}
public async findAllByUser(userId: string, householdId: string) {
return NutritionTargetModel.find({ userId, householdId }).sort({ createdAt: -1 }).lean().exec();
public async findAllByUser(
userId: string,
householdId: string,
): Promise<NutritionTargetDocument[]> {
const docs = await NutritionTargetModel.find({ userId, householdId })
.sort({ createdAt: -1 })
.lean()
.exec();
return docs as unknown as NutritionTargetDocument[];
}
public async create(data: Record<string, unknown>) {
public async create(data: CreateNutritionTargetData): Promise<NutritionTargetDocument> {
const doc = new NutritionTargetModel(data);
const saved = await doc.save();
return saved.toObject();
return saved.toObject() as unknown as NutritionTargetDocument;
}
public async deactivateAllForUser(userId: string, householdId: string) {
public async deactivateAllForUser(
userId: string,
householdId: string,
): Promise<UpdateWriteOpResult> {
return NutritionTargetModel.updateMany(
{ userId, householdId, isActive: true },
{ $set: { isActive: false } },
@ -26,12 +68,13 @@ export class NutritionTargetRepository {
id: string,
userId: string,
householdId: string,
data: Record<string, unknown>,
) {
return NutritionTargetModel.findOneAndUpdate(
data: UpdateNutritionTargetData,
): Promise<NutritionTargetDocument | null> {
const doc = await NutritionTargetModel.findOneAndUpdate(
{ _id: id, userId, householdId },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as NutritionTargetDocument | null;
}
}

View file

@ -2,52 +2,65 @@ import fp from 'fastify-plugin';
import { asClass, Lifetime } from 'awilix';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod/v4';
import { NutritionTargetSchema, NutritionTargetResponseSchema } from '@meshitrack/shared';
import {
NutritionTargetSchema,
NutritionTargetResponseSchema,
type SetNutritionTargetInput,
} from '@meshitrack/shared';
import { NutritionTargetRepository } from './nutrition-target.repository.js';
import { NutritionTargetService } from './nutrition-target.service.js';
import { NutritionTargetService, type PresetType } from './nutrition-target.service.js';
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
type AnyTargetDoc = {
_id: string | { toString: () => string };
interface SerializedNutritionTarget {
_id: string;
userId: string;
householdId: string;
dailyCalories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG?: number | null;
sugarG?: number | null;
sodiumMg?: number | null;
fiberG?: number;
sugarG?: number;
sodiumMg?: number;
isActive: boolean;
createdAt: string | Date;
updatedAt: string | Date;
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
createdAt: string;
updatedAt: string;
}
function toIso(v: string | Date): string {
return typeof v === 'string' ? v : v.toISOString();
}
function toNutritionTargetResponse(doc: NutritionTargetDocument): SerializedNutritionTarget {
const docAny = doc as unknown as {
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
fiberG?: number | null;
sugarG?: number | null;
sodiumMg?: number | null;
};
function toNutritionTargetResponse(
doc: AnyTargetDoc,
): z.infer<typeof NutritionTargetResponseSchema> {
return {
_id: toStr(doc._id),
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
if (!d) return '';
if (typeof d === 'string') return d;
if (typeof d.toISOString === 'function') return d.toISOString();
return String(d);
};
const res: SerializedNutritionTarget = {
_id: doc._id.toString(),
userId: doc.userId,
householdId: doc.householdId,
dailyCalories: doc.dailyCalories,
proteinG: doc.proteinG,
carbsG: doc.carbsG,
fatG: doc.fatG,
...(doc.fiberG != null ? { fiberG: doc.fiberG } : {}),
...(doc.sugarG != null ? { sugarG: doc.sugarG } : {}),
...(doc.sodiumMg != null ? { sodiumMg: doc.sodiumMg } : {}),
isActive: doc.isActive,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: getIsoStr(docAny.createdAt),
updatedAt: getIsoStr(docAny.updatedAt),
};
if (docAny.fiberG != null) res.fiberG = docAny.fiberG;
if (docAny.sugarG != null) res.sugarG = docAny.sugarG;
if (docAny.sodiumMg != null) res.sodiumMg = docAny.sodiumMg;
return res;
}
declare module '@fastify/awilix' {
@ -85,13 +98,14 @@ export default fp(
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('nutritionTargetService');
const userId = request.user.keycloakId;
const target = await service.getActiveByUser(userId, request.params.householdId);
const params = request.params as { householdId: string };
const target = await service.getActiveByUser(userId, params.householdId);
if (!target) {
return reply.status(200).send({ message: 'No active targets defined' });
}
return reply.send(toNutritionTargetResponse(target as AnyTargetDoc));
return reply.send(toNutritionTargetResponse(target));
},
});
@ -108,8 +122,9 @@ export default fp(
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('nutritionTargetService');
const userId = request.user.keycloakId;
const targets = await service.getAllByUser(userId, request.params.householdId);
return reply.send(targets.map((t) => toNutritionTargetResponse(t as AnyTargetDoc)));
const params = request.params as { householdId: string };
const targets = await service.getAllByUser(userId, params.householdId);
return reply.send(targets.map(toNutritionTargetResponse));
},
});
@ -125,8 +140,10 @@ export default fp(
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('nutritionTargetService');
const userId = request.user.keycloakId;
const target = await service.setTarget(userId, request.params.householdId, request.body);
return reply.status(201).send(toNutritionTargetResponse(target as AnyTargetDoc));
const params = request.params as { householdId: string };
const body = request.body as SetNutritionTargetInput;
const target = await service.setTarget(userId, params.householdId, body);
return reply.status(201).send(toNutritionTargetResponse(target));
},
});
@ -144,7 +161,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('nutritionTargetService');
const calculated = service.calculatePreset(request.body.calories, request.body.strategy);
const body = request.body as { calories: number; strategy: PresetType };
const calculated = service.calculatePreset(body.calories, body.strategy);
return reply.send(calculated);
},
});

View file

@ -1,5 +1,6 @@
import type { NutritionTargetRepository } from './nutrition-target.repository.js';
import type { SetNutritionTargetInput } from '@meshitrack/shared';
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
interface Deps {
nutritionTargetRepository: NutritionTargetRepository;
@ -14,15 +15,25 @@ export class NutritionTargetService {
this.nutritionTargetRepository = nutritionTargetRepository;
}
public async getActiveByUser(userId: string, householdId: string) {
public async getActiveByUser(
userId: string,
householdId: string,
): Promise<NutritionTargetDocument | null> {
return this.nutritionTargetRepository.findByUser(userId, householdId);
}
public async getAllByUser(userId: string, householdId: string) {
public async getAllByUser(
userId: string,
householdId: string,
): Promise<NutritionTargetDocument[]> {
return this.nutritionTargetRepository.findAllByUser(userId, householdId);
}
public async setTarget(userId: string, householdId: string, input: SetNutritionTargetInput) {
public async setTarget(
userId: string,
householdId: string,
input: SetNutritionTargetInput,
): Promise<NutritionTargetDocument> {
// Maintain invariant: only one target is active per user per household
if (input.isActive !== false) {
await this.nutritionTargetRepository.deactivateAllForUser(userId, householdId);

View file

@ -1,7 +1,8 @@
import { OrganizerFillModel } from '../../schemas/organizer-fill.schema.js';
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
import type { OrganizerFillStatus } from '@meshitrack/shared';
interface OrganizerFillItemData {
export interface OrganizerFillItemData {
medicineId: string;
medicineName: string;
quantityNeeded: number;
@ -11,7 +12,7 @@ interface OrganizerFillItemData {
deductions: { cabinetItemId: string; quantityTaken: number }[];
}
interface CreateOrganizerFillData {
export interface CreateOrganizerFillData {
householdId: string;
userId: string;
regimenId: string;
@ -23,15 +24,27 @@ interface CreateOrganizerFillData {
notes?: string;
}
interface FindByHouseholdQuery {
export interface FindByHouseholdQuery {
regimenId?: string;
status?: OrganizerFillStatus;
cursor?: string;
limit: number;
}
export interface FindByHouseholdResult {
data: OrganizerFillDocument[];
pagination: {
cursor: string | null;
hasMore: boolean;
};
}
export class OrganizerRepository {
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
public async findByHousehold(
householdId: string,
userId: string,
query: FindByHouseholdQuery,
): Promise<FindByHouseholdResult> {
const filter: Record<string, unknown> = { householdId, userId };
if (query.regimenId) filter['regimenId'] = query.regimenId;
@ -50,28 +63,36 @@ export class OrganizerRepository {
.exec();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const rawData = hasMore ? items.slice(0, limit) : items;
const data = rawData as unknown as OrganizerFillDocument[];
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}
public async findById(id: string, householdId: string) {
return OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
public async findById(id: string, householdId: string): Promise<OrganizerFillDocument | null> {
const doc = await OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
return doc as unknown as OrganizerFillDocument | null;
}
public async create(data: CreateOrganizerFillData) {
public async create(data: CreateOrganizerFillData): Promise<OrganizerFillDocument> {
const fill = new OrganizerFillModel(data);
const saved = await fill.save();
return saved.toObject();
return saved.toObject() as unknown as OrganizerFillDocument;
}
public async updateStatus(id: string, householdId: string, status: OrganizerFillStatus) {
return OrganizerFillModel.findOneAndUpdate(
public async updateStatus(
id: string,
householdId: string,
status: OrganizerFillStatus,
): Promise<OrganizerFillDocument | null> {
const doc = await OrganizerFillModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: { status } },
{ new: true, lean: true },
).exec();
return doc as unknown as OrganizerFillDocument | null;
}
}

View file

@ -9,60 +9,78 @@ import {
OrganizerPreviewResponseSchema,
OrganizerFillResponseSchema,
OrganizerFillListResponseSchema,
type OrganizerFillInput,
type OrganizerFillQueryInput,
type OrganizerPreviewInput,
} from '@meshitrack/shared';
import { OrganizerRepository } from './organizer.repository.js';
import { OrganizerService } from './organizer.service.js';
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
type AnyFillDeduction = {
interface SerializedOrganizerDeduction {
cabinetItemId: string;
quantityTaken: number;
};
}
type AnyFillItem = {
interface SerializedOrganizerFillItem {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityTaken: number;
wasShort: boolean;
shortage: number;
deductions: AnyFillDeduction[];
};
deductions: SerializedOrganizerDeduction[];
}
type AnyFillDoc = {
_id: string | { toString: () => string };
interface SerializedOrganizerFillResponse {
_id: string;
householdId: string;
userId: string;
regimenId: string;
regimenName: string;
numberOfDays: number;
fillDate: string | Date | { toISOString: () => string };
items: AnyFillItem[];
status: string;
notes?: string | null;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
fillDate: string;
items: SerializedOrganizerFillItem[];
status: 'completed' | 'partial' | 'reversed';
notes?: string;
createdAt: string;
updatedAt: string;
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toFillResponse(doc: OrganizerFillDocument): SerializedOrganizerFillResponse {
const docAny = doc as unknown as {
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
fillDate?: { toISOString?: () => string } | string;
notes?: string | null;
};
function toFillResponse(doc: AnyFillDoc) {
return {
_id: toStr(doc._id),
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
if (!d) return '';
if (typeof d === 'string') return d;
if (typeof d.toISOString === 'function') return d.toISOString();
return String(d);
};
const itemsAny = doc.items as unknown as Array<{
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityTaken: number;
wasShort: boolean;
shortage: number;
deductions: Array<{ cabinetItemId: string; quantityTaken: number }>;
}>;
const res: SerializedOrganizerFillResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
userId: doc.userId,
regimenId: doc.regimenId,
regimenName: doc.regimenName,
numberOfDays: doc.numberOfDays,
fillDate: toIso(doc.fillDate),
items: doc.items.map((item) => ({
fillDate: getIsoStr(docAny.fillDate),
items: itemsAny.map((item) => ({
medicineId: item.medicineId,
medicineName: item.medicineName,
quantityNeeded: item.quantityNeeded,
@ -74,11 +92,16 @@ function toFillResponse(doc: AnyFillDoc) {
quantityTaken: d.quantityTaken,
})),
})),
status: doc.status,
...(doc.notes ? { notes: doc.notes } : {}),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
status: doc.status as 'completed' | 'partial' | 'reversed',
createdAt: getIsoStr(docAny.createdAt),
updatedAt: getIsoStr(docAny.updatedAt),
};
if (docAny.notes) {
res.notes = docAny.notes;
}
return res;
}
declare module '@fastify/awilix' {
@ -109,11 +132,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const result = await service.listFills(
request.params.householdId,
request.user.keycloakId,
request.query,
);
const params = request.params as { householdId: string };
const query = request.query as OrganizerFillQueryInput;
const result = await service.listFills(params.householdId, request.user.keycloakId, query);
return reply.send({
data: result.data.map(toFillResponse),
pagination: result.pagination,
@ -131,7 +152,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.getFillById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const fill = await service.getFillById(params.id, params.householdId);
return reply.send(toFillResponse(fill));
},
});
@ -147,11 +169,13 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const params = request.params as { householdId: string };
const body = request.body as OrganizerPreviewInput;
const preview = await service.preview(
request.params.householdId,
params.householdId,
request.user.keycloakId,
request.body.regimenId,
request.body.numberOfDays,
body.regimenId,
body.numberOfDays,
);
return reply.send(preview);
},
@ -168,11 +192,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.fill(
request.params.householdId,
request.user.keycloakId,
request.body,
);
const params = request.params as { householdId: string };
const body = request.body as OrganizerFillInput;
const fill = await service.fill(params.householdId, request.user.keycloakId, body);
return reply.status(201).send(toFillResponse(fill));
},
});
@ -187,11 +209,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('organizerService');
const fill = await service.undoFill(
request.params.householdId,
request.params.id,
request.user.keycloakId,
);
const params = request.params as { householdId: string; id: string };
const fill = await service.undoFill(params.householdId, params.id, request.user.keycloakId);
return reply.send(toFillResponse(fill));
},
});

View file

@ -1,5 +1,5 @@
import mongoose from 'mongoose';
import type { OrganizerRepository } from './organizer.repository.js';
import type { OrganizerRepository, FindByHouseholdResult } from './organizer.repository.js';
import type { RegimensService } from '../regimens/regimens.service.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
@ -12,6 +12,7 @@ import {
calculateQuantityNeeded,
} from '@meshitrack/shared';
import type { CreateCabinetEventData } from '../cabinet-events/cabinet-events.repository.js';
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
@ -21,14 +22,14 @@ interface Deps {
cabinetEventsService: CabinetEventsService;
}
interface PreviewDeduction {
export interface PreviewDeduction {
cabinetItemId: string;
expirationDate: string | null;
quantityToTake: number;
quantityBefore: number;
}
interface PreviewItem {
export interface PreviewItem {
medicineId: string;
medicineName: string;
quantityNeeded: number;
@ -38,6 +39,14 @@ interface PreviewItem {
cabinetBreakdown: PreviewDeduction[];
}
export interface PreviewResult {
regimenName: string;
numberOfDays: number;
items: PreviewItem[];
canFillCompletely: boolean;
hasShortages: boolean;
}
export class OrganizerService {
private readonly organizerRepository: OrganizerRepository;
private readonly regimensService: RegimensService;
@ -56,11 +65,15 @@ export class OrganizerService {
this.cabinetEventsService = cabinetEventsService;
}
public async listFills(householdId: string, userId: string, query: OrganizerFillQueryInput) {
public async listFills(
householdId: string,
userId: string,
query: OrganizerFillQueryInput,
): Promise<FindByHouseholdResult> {
return this.organizerRepository.findByHousehold(householdId, userId, query);
}
public async getFillById(id: string, householdId: string) {
public async getFillById(id: string, householdId: string): Promise<OrganizerFillDocument> {
const fill = await this.organizerRepository.findById(id, householdId);
if (!fill) throw new NotFoundError('Organizer fill not found');
return fill;
@ -71,7 +84,7 @@ export class OrganizerService {
userId: string,
regimenId: string,
numberOfDays: number,
) {
): Promise<PreviewResult> {
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
if (!regimen.isActive) {
throw new BadRequestError('Regimen is not active');
@ -136,7 +149,11 @@ export class OrganizerService {
};
}
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
public async fill(
householdId: string,
userId: string,
input: OrganizerFillInput,
): Promise<OrganizerFillDocument> {
const previewResult = await this.preview(
householdId,
userId,
@ -233,11 +250,15 @@ export class OrganizerService {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
await session.endSession();
}
}
public async undoFill(householdId: string, fillId: string, userId: string) {
public async undoFill(
householdId: string,
fillId: string,
userId: string,
): Promise<OrganizerFillDocument> {
const fill = await this.getFillById(fillId, householdId);
if (fill.status === OrganizerFillStatus.REVERSED) {
@ -250,7 +271,13 @@ export class OrganizerService {
try {
session.startTransaction();
for (const item of fill.items) {
const itemsAny = fill.items as unknown as Array<{
medicineId: string;
medicineName: string;
deductions: Array<{ cabinetItemId: string; quantityTaken: number }>;
}>;
for (const item of itemsAny) {
for (const deduction of item.deductions) {
// Get current quantity before restoring
const current = await this.cabinetRepository.findById(
@ -297,7 +324,7 @@ export class OrganizerService {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
await session.endSession();
}
}
}

View file

@ -1,4 +1,5 @@
import { RefillListModel } from '../../schemas/refill-list.schema.js';
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
import type {
RefillListQueryInput,
UpdateRefillListInput,
@ -24,13 +25,19 @@ export interface CreateRefillListData {
}
export class RefillsRepository {
public async create(data: CreateRefillListData) {
public async create(data: CreateRefillListData): Promise<RefillListDocument> {
const list = new RefillListModel(data);
const saved = await list.save();
return saved.toObject();
return saved.toObject() as RefillListDocument;
}
public async findByHousehold(householdId: string, query: RefillListQueryInput) {
public async findByHousehold(
householdId: string,
query: RefillListQueryInput,
): Promise<{
data: RefillListDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
const filter: Record<string, unknown> = { householdId };
if (query.status) filter['status'] = query.status;
@ -52,24 +59,33 @@ export class RefillsRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as RefillListDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string) {
return RefillListModel.findOne({ _id: id, householdId }).lean().exec();
public async findById(id: string, householdId: string): Promise<RefillListDocument | null> {
const doc = await RefillListModel.findOne({ _id: id, householdId }).lean().exec();
return doc as unknown as RefillListDocument | null;
}
public async update(id: string, householdId: string, data: UpdateRefillListInput) {
public async update(
id: string,
householdId: string,
data: UpdateRefillListInput,
): Promise<RefillListDocument | null> {
const updateSet: Record<string, unknown> = {};
if (data.name !== undefined) updateSet['name'] = data.name;
if (data.status !== undefined) updateSet['status'] = data.status;
if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId;
return RefillListModel.findOneAndUpdate(
const doc = await RefillListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
return doc as unknown as RefillListDocument | null;
}
public async updateItem(
@ -77,7 +93,7 @@ export class RefillsRepository {
householdId: string,
itemId: string,
data: UpdateRefillListItemInput & { checkedAt?: Date },
) {
): Promise<RefillListDocument | null> {
const updateSet: Record<string, unknown> = {};
if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked;
if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice;
@ -85,15 +101,20 @@ export class RefillsRepository {
if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes;
if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt;
return RefillListModel.findOneAndUpdate(
const doc = await RefillListModel.findOneAndUpdate(
{ _id: listId, householdId, 'items._id': itemId },
{ $set: updateSet },
{ new: true, lean: true },
).exec();
return doc as unknown as RefillListDocument | null;
}
public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) {
return RefillListModel.findOneAndUpdate(
public async markItemsAddedToCabinet(
listId: string,
householdId: string,
itemIds: string[],
): Promise<RefillListDocument | null> {
const doc = await RefillListModel.findOneAndUpdate(
{ _id: listId, householdId },
{ $set: { 'items.$[elem].addedToCabinet': true } },
{
@ -102,5 +123,6 @@ export class RefillsRepository {
lean: true,
},
).exec();
return doc as unknown as RefillListDocument | null;
}
}

View file

@ -13,17 +13,17 @@ import {
RefillListListResponseSchema,
AddToCabinetResponseSchema,
StoreComparisonItemSchema,
type CreateRefillListInput,
type UpdateRefillListInput,
type UpdateRefillListItemInput,
type RefillListQueryInput,
} from '@meshitrack/shared';
import { RefillsRepository } from './refills.repository.js';
import { RefillsService } from './refills.service.js';
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
function toIso(v: Date | string | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
}
type AnyItem = {
_id: string | { toString: () => string };
interface SerializedRefillListItemResponse {
_id: string;
medicineId: string;
medicineName: string;
quantity: number;
@ -31,57 +31,90 @@ type AnyItem = {
estimatedPrice?: number;
actualPrice?: number;
checked: boolean;
checkedAt?: Date | string;
checkedAt?: string;
addedToCabinet: boolean;
storeId?: string;
notes?: string;
};
}
type AnyRefillList = {
_id: string | { toString: () => string };
interface SerializedRefillListResponse {
_id: string;
householdId: string;
name: string;
items: AnyItem[];
status: string;
items: SerializedRefillListItemResponse[];
status: 'active' | 'completed' | 'cancelled';
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
createdAt: Date | string | { toISOString: () => string };
updatedAt: Date | string | { toISOString: () => string };
};
function toItemResponse(rawItem: unknown) {
const item = rawItem as AnyItem;
return {
_id: typeof item._id === 'string' ? item._id : item._id.toString(),
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit,
...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}),
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
checked: item.checked,
...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}),
addedToCabinet: item.addedToCabinet,
...(item.storeId != null ? { storeId: item.storeId } : {}),
...(item.notes != null ? { notes: item.notes } : {}),
};
createdAt: string;
updatedAt: string;
}
function toListResponse(rawDoc: unknown) {
const doc = rawDoc as AnyRefillList;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
function toListResponse(doc: RefillListDocument): SerializedRefillListResponse {
const docAny = doc as unknown as {
preferredStoreId?: string | null;
totalEstimatedCost?: number | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
if (!d) return '';
if (typeof d === 'string') return d;
if (typeof d.toISOString === 'function') return d.toISOString();
return String(d);
};
const response: SerializedRefillListResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
items: doc.items.map(toItemResponse),
status: doc.status as never,
...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}),
...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}),
items: doc.items.map((item) => {
const itemAny = item as unknown as {
_id: { toString: () => string };
estimatedPrice?: number | null;
actualPrice?: number | null;
checkedAt?: { toISOString?: () => string } | string | Date;
storeId?: string | null;
notes?: string | null;
};
const itemResponse: SerializedRefillListItemResponse = {
_id: itemAny._id.toString(),
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit,
checked: item.checked,
addedToCabinet: item.addedToCabinet,
};
if (itemAny.estimatedPrice != null) itemResponse.estimatedPrice = itemAny.estimatedPrice;
if (itemAny.actualPrice != null) itemResponse.actualPrice = itemAny.actualPrice;
if (itemAny.checkedAt) {
if (typeof itemAny.checkedAt === 'string') {
itemResponse.checkedAt = itemAny.checkedAt;
} else if (itemAny.checkedAt instanceof Date) {
itemResponse.checkedAt = itemAny.checkedAt.toISOString();
} else if (typeof itemAny.checkedAt.toISOString === 'function') {
itemResponse.checkedAt = itemAny.checkedAt.toISOString();
}
}
if (itemAny.storeId) itemResponse.storeId = itemAny.storeId;
if (itemAny.notes) itemResponse.notes = itemAny.notes;
return itemResponse;
}),
status: doc.status as 'active' | 'completed' | 'cancelled',
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: getIsoStr(docAny.createdAt),
updatedAt: getIsoStr(docAny.updatedAt),
};
if (docAny.preferredStoreId) response.preferredStoreId = docAny.preferredStoreId;
if (docAny.totalEstimatedCost != null) response.totalEstimatedCost = docAny.totalEstimatedCost;
return response;
}
declare module '@fastify/awilix' {
@ -111,19 +144,21 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const params = request.params as { householdId: string };
const query = request.query as { userId?: string; thresholdDays?: number };
const alerts = await service.getAlerts(
request.params.householdId,
request.query.userId ?? request.user.keycloakId,
request.query.thresholdDays,
params.householdId,
query.userId ?? request.user.keycloakId,
query.thresholdDays,
);
return reply.send({
data: alerts.map((a) => ({
...a,
lastKnownPrice: a.lastKnownPrice
? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) }
? { ...a.lastKnownPrice, date: a.lastKnownPrice.date.toISOString() }
: undefined,
cheapestOption: a.cheapestOption
? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) }
? { ...a.cheapestOption, date: a.cheapestOption.date.toISOString() }
: undefined,
})),
});
@ -140,11 +175,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.createList(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateRefillListInput;
const list = await service.createList(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toListResponse(list));
},
});
@ -159,7 +192,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const result = await service.list(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as RefillListQueryInput;
const result = await service.list(params.householdId, query);
return reply.send({
data: result.data.map(toListResponse),
pagination: result.pagination,
@ -176,7 +211,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const list = await service.getById(params.id, params.householdId);
return reply.send(toListResponse(list));
},
});
@ -191,11 +227,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.updateList(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateRefillListInput;
const list = await service.updateList(params.id, params.householdId, body);
return reply.send(toListResponse(list));
},
});
@ -210,12 +244,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const list = await service.updateItem(
request.params.id,
request.params.householdId,
request.params.itemId,
request.body,
);
const params = request.params as { householdId: string; id: string; itemId: string };
const body = request.body as UpdateRefillListItemInput;
const list = await service.updateItem(params.id, params.householdId, params.itemId, body);
return reply.send(toListResponse(list));
},
});
@ -229,9 +260,10 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const params = request.params as { householdId: string; id: string };
const result = await service.addToCabinet(
request.params.id,
request.params.householdId,
params.id,
params.householdId,
request.user.keycloakId,
);
return reply.send(result);
@ -256,16 +288,14 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('refillsService');
const comparisons = await service.getStoreComparison(
request.params.id,
request.params.householdId,
);
const params = request.params as { householdId: string; id: string };
const comparisons = await service.getStoreComparison(params.id, params.householdId);
return reply.send({
data: comparisons.map((c) => ({
medicineId: c.medicineId,
storeOptions: c.storeOptions.map((opt) => ({
...opt,
date: toIso(opt.date),
date: opt.date.toISOString(),
})),
})),
});

View file

@ -3,7 +3,8 @@ import type { RegimensService } from '../regimens/regimens.service.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetService } from '../cabinet/cabinet.service.js';
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
import type { PurchasesRepository } from '../purchases/purchases.repository.js';
import type { ShoppingListsRepository } from '../shopping-lists/shopping-lists.repository.js';
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
import type {
CreateRefillListInput,
UpdateRefillListInput,
@ -19,7 +20,46 @@ interface Deps {
cabinetRepository: CabinetRepository;
cabinetService: CabinetService;
medicinePricesRepository: MedicinePricesRepository;
purchasesRepository: PurchasesRepository;
shoppingListsRepository: ShoppingListsRepository;
}
interface RefillAlert {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
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;
};
}
interface CabinetAggregateSummaryGroup {
_id: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: Date | null;
itemCount: number;
}
export class RefillsService {
@ -28,7 +68,7 @@ export class RefillsService {
private readonly cabinetRepository: CabinetRepository;
private readonly cabinetService: CabinetService;
private readonly medicinePricesRepository: MedicinePricesRepository;
private readonly purchasesRepository: PurchasesRepository;
private readonly shoppingListsRepository: ShoppingListsRepository;
public constructor({
refillsRepository,
@ -36,17 +76,21 @@ export class RefillsService {
cabinetRepository,
cabinetService,
medicinePricesRepository,
purchasesRepository,
shoppingListsRepository,
}: Deps) {
this.refillsRepository = refillsRepository;
this.regimensService = regimensService;
this.cabinetRepository = cabinetRepository;
this.cabinetService = cabinetService;
this.medicinePricesRepository = medicinePricesRepository;
this.purchasesRepository = purchasesRepository;
this.shoppingListsRepository = shoppingListsRepository;
}
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
public async getAlerts(
householdId: string,
userId: string,
thresholdDays = 7,
): Promise<RefillAlert[]> {
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
const triggered = burnRates.filter(
@ -56,23 +100,36 @@ export class RefillsService {
if (triggered.length === 0) return [];
// Get strength data from cabinet aggregate
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
const summariesRaw = await this.cabinetRepository.getAggregateSummary(householdId);
const summaries = summariesRaw as unknown as CabinetAggregateSummaryGroup[];
const summaryMap = new Map<
string,
{ medicineStrength: number; medicineStrengthUnit: string }
>();
for (const s of summaries) {
summaryMap.set(s._id as string, {
medicineStrength: s.medicineStrength as number,
medicineStrengthUnit: s.medicineStrengthUnit as string,
summaryMap.set(s._id, {
medicineStrength: s.medicineStrength,
medicineStrengthUnit: s.medicineStrengthUnit,
});
}
// Get pending stock from ordered purchases
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
// Get pending stock from active shopping lists
const activeLists = await this.shoppingListsRepository.findActiveByHousehold(householdId);
const pendingStockMap = new Map<string, number>();
for (const row of pendingStockRows) {
pendingStockMap.set(row.medicineId, row.totalUnits);
for (const list of activeLists) {
if (list.items) {
for (const item of list.items) {
const itemAny = item as unknown as {
productId?: string;
quantity: number;
checked: boolean;
};
if (itemAny.productId && !itemAny.checked) {
const currentQty = pendingStockMap.get(itemAny.productId) ?? 0;
pendingStockMap.set(itemAny.productId, currentQty + itemAny.quantity);
}
}
}
}
const alerts = await Promise.all(
@ -94,7 +151,7 @@ export class RefillsService {
medicineId: br.medicineId,
medicineName: br.medicineName,
medicineStrength: summary?.medicineStrength ?? 0,
medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never,
medicineStrengthUnit: summary?.medicineStrengthUnit ?? 'mg',
daysUntilEmpty: br.daysUntilEmpty as number,
dailyConsumption: br.dailyConsumption,
currentStock: br.totalInCabinet,
@ -103,11 +160,11 @@ export class RefillsService {
suggestedQuantity,
lastKnownPrice: latestRecord
? {
price: latestRecord.price as number,
pricePerUnit: latestRecord.pricePerUnit as number,
storeName: latestRecord.storeName as string,
storeId: latestRecord.storeId as string,
date: latestRecord.date as Date,
price: latestRecord.price,
pricePerUnit: latestRecord.pricePerUnit,
storeName: latestRecord.storeName,
storeId: latestRecord.storeId,
date: latestRecord.date,
}
: undefined,
cheapestOption:
@ -127,7 +184,11 @@ export class RefillsService {
return alerts;
}
public async createList(data: CreateRefillListInput, householdId: string, userId: string) {
public async createList(
data: CreateRefillListInput,
householdId: string,
userId: string,
): Promise<RefillListDocument> {
let items: Array<{
medicineId: string;
medicineName: string;
@ -144,7 +205,7 @@ export class RefillsService {
medicineId: alert.medicineId,
medicineName: alert.medicineName,
quantity: alert.suggestedQuantity,
unit: 'tablet' as string,
unit: 'tablet',
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
}));
@ -153,7 +214,7 @@ export class RefillsService {
medicineId: item.medicineId,
medicineName: item.medicineName,
quantity: item.quantity,
unit: item.unit as string,
unit: item.unit,
estimatedPrice: item.estimatedPrice,
storeId: item.storeId,
notes: item.notes,
@ -176,17 +237,27 @@ export class RefillsService {
});
}
public async list(householdId: string, query: RefillListQueryInput) {
public async list(
householdId: string,
query: RefillListQueryInput,
): Promise<{
data: RefillListDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.refillsRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<RefillListDocument> {
const list = await this.refillsRepository.findById(id, householdId);
if (!list) throw new NotFoundError('Refill list not found');
return list;
}
public async updateList(id: string, householdId: string, data: UpdateRefillListInput) {
public async updateList(
id: string,
householdId: string,
data: UpdateRefillListInput,
): Promise<RefillListDocument> {
await this.getById(id, householdId);
const updated = await this.refillsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Refill list not found');
@ -198,7 +269,7 @@ export class RefillsService {
householdId: string,
itemId: string,
data: UpdateRefillListItemInput,
) {
): Promise<RefillListDocument> {
await this.getById(listId, householdId);
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
@ -216,7 +287,11 @@ export class RefillsService {
return updated;
}
public async addToCabinet(listId: string, householdId: string, userId: string) {
public async addToCabinet(
listId: string,
householdId: string,
userId: string,
): Promise<{ addedCount: number; priceRecordsCreated: number }> {
const list = await this.getById(listId, householdId);
const checkedItems = (
@ -266,7 +341,23 @@ export class RefillsService {
return { addedCount, priceRecordsCreated: 0 };
}
public async getStoreComparison(listId: string, householdId: string) {
public async getStoreComparison(
listId: string,
householdId: string,
): Promise<
Array<{
medicineId: string;
storeOptions: Array<{
storeId: string;
storeName: string;
latestPrice: number;
latestPricePerUnit: number;
currency: string;
date: Date;
isInsurancePrice: boolean;
}>;
}>
> {
const list = await this.getById(listId, householdId);
const medicineIds = [

View file

@ -1,4 +1,5 @@
import { RegimenModel } from '../../schemas/regimen.schema.js';
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
interface FindByHouseholdQuery {
isActive?: boolean;
@ -33,7 +34,11 @@ interface UpdateRegimenData {
}
export class RegimensRepository {
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
public async findByHousehold(
householdId: string,
userId: string,
query: FindByHouseholdQuery,
): Promise<{ data: RegimenDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
const filter: Record<string, unknown> = { householdId, userId, isDeleted: false };
if (query.isActive !== undefined) filter['isActive'] = query.isActive;
@ -55,18 +60,29 @@ export class RegimensRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as RegimenDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string, userId: string) {
return RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false }).lean().exec();
public async findById(
id: string,
householdId: string,
userId: string,
): Promise<RegimenDocument | null> {
const doc = await RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false })
.lean()
.exec();
return doc as unknown as RegimenDocument | null;
}
public async findActiveByUser(householdId: string, userId: string) {
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
public async findActiveByUser(householdId: string, userId: string): Promise<RegimenDocument[]> {
const docs = await RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
.sort({ name: 1, _id: 1 })
.lean()
.exec();
return docs as unknown as RegimenDocument[];
}
public async create(
@ -74,25 +90,36 @@ export class RegimensRepository {
householdId: string,
userId: string,
createdBy: string,
) {
): Promise<RegimenDocument> {
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
const saved = await regimen.save();
return saved.toObject();
return saved.toObject() as RegimenDocument;
}
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenData) {
return RegimenModel.findOneAndUpdate(
public async update(
id: string,
householdId: string,
userId: string,
data: UpdateRegimenData,
): Promise<RegimenDocument | null> {
const doc = await RegimenModel.findOneAndUpdate(
{ _id: id, householdId, userId, isDeleted: false },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as RegimenDocument | null;
}
public async softDelete(id: string, householdId: string, userId: string) {
return RegimenModel.findOneAndUpdate(
public async softDelete(
id: string,
householdId: string,
userId: string,
): Promise<RegimenDocument | null> {
const doc = await RegimenModel.findOneAndUpdate(
{ _id: id, householdId, userId, isDeleted: false },
{ $set: { isDeleted: true } },
{ new: true, lean: true },
).exec();
return doc as unknown as RegimenDocument | null;
}
}

View file

@ -9,11 +9,15 @@ import {
RegimenResponseSchema,
RegimenListResponseSchema,
BurnRateResponseSchema,
type CreateRegimenInput,
type UpdateRegimenInput,
type RegimenQueryInput,
} from '@meshitrack/shared';
import { RegimensRepository } from './regimens.repository.js';
import { RegimensService } from './regimens.service.js';
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
type AnyRegimenMedication = {
interface SerializedRegimenMedication {
medicineId: string;
medicineName: string;
medicineStrength: number;
@ -22,58 +26,74 @@ type AnyRegimenMedication = {
dosage: number;
dosageUnit: string;
frequency: string;
customFrequencyPerDay?: number | null;
timeOfDay?: string | null;
instructions?: string | null;
};
customFrequencyPerDay?: number;
timeOfDay?: string;
instructions?: string;
}
type AnyRegimenDoc = {
_id: string | { toString: () => string };
interface SerializedRegimenResponse {
_id: string;
householdId: string;
userId: string;
name: string;
isActive: boolean;
medications: AnyRegimenMedication[];
medications: SerializedRegimenMedication[];
createdBy: string;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
function toStr(v: string | { toString: () => string }): string {
return typeof v === 'string' ? v : v.toString();
createdAt: string;
updatedAt: string;
}
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
if (v instanceof Date) return v.toISOString();
return v.toISOString();
}
function toRegimenResponse(doc: RegimenDocument): SerializedRegimenResponse {
const docAny = doc as unknown as {
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
if (!d) return '';
if (typeof d === 'string') return d;
if (typeof d.toISOString === 'function') return d.toISOString();
return String(d);
};
function toRegimenResponse(doc: AnyRegimenDoc) {
return {
_id: toStr(doc._id),
_id: doc._id.toString(),
householdId: doc.householdId,
userId: doc.userId,
name: doc.name,
isActive: doc.isActive,
medications: doc.medications.map((med) => ({
medicineId: med.medicineId,
medicineName: med.medicineName,
medicineStrength: med.medicineStrength,
medicineStrengthUnit: med.medicineStrengthUnit,
medicineForm: med.medicineForm,
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
...(med.customFrequencyPerDay != null
? { customFrequencyPerDay: med.customFrequencyPerDay }
: {}),
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
...(med.instructions ? { instructions: med.instructions } : {}),
})),
medications: doc.medications.map((med) => {
const medAny = med as unknown as {
customFrequencyPerDay?: number | null;
timeOfDay?: string | null;
instructions?: string | null;
};
const response: SerializedRegimenMedication = {
medicineId: med.medicineId,
medicineName: med.medicineName,
medicineStrength: med.medicineStrength,
medicineStrengthUnit: med.medicineStrengthUnit,
medicineForm: med.medicineForm,
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
};
if (medAny.customFrequencyPerDay != null) {
response.customFrequencyPerDay = medAny.customFrequencyPerDay;
}
if (medAny.timeOfDay) {
response.timeOfDay = medAny.timeOfDay;
}
if (medAny.instructions) {
response.instructions = medAny.instructions;
}
return response;
}),
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: getIsoStr(docAny.createdAt),
updatedAt: getIsoStr(docAny.updatedAt),
};
}
@ -105,11 +125,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const result = await service.list(
request.params.householdId,
request.user.keycloakId,
request.query,
);
const params = request.params as { householdId: string };
const query = request.query as RegimenQueryInput;
const result = await service.list(params.householdId, request.user.keycloakId, query);
return reply.send({
data: result.data.map(toRegimenResponse),
pagination: result.pagination,
@ -127,10 +145,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const data = await service.calculateBurnRates(
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const data = await service.calculateBurnRates(params.householdId, request.user.keycloakId);
return reply.send({ data });
},
});
@ -145,9 +161,10 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const params = request.params as { householdId: string; id: string };
const regimen = await service.getById(
request.params.id,
request.params.householdId,
params.id,
params.householdId,
request.user.keycloakId,
);
return reply.send(toRegimenResponse(regimen));
@ -165,11 +182,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateRegimenInput;
const regimen = await service.create(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toRegimenResponse(regimen));
},
});
@ -185,11 +200,13 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateRegimenInput;
const regimen = await service.update(
request.params.id,
request.params.householdId,
params.id,
params.householdId,
request.user.keycloakId,
request.body,
body,
);
return reply.send(toRegimenResponse(regimen));
},
@ -205,11 +222,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string; id: string };
await service.delete(params.id, params.householdId, request.user.keycloakId);
return reply.status(204).send();
},
});

View file

@ -2,6 +2,7 @@ import type { RegimensRepository } from './regimens.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
import type { CreateRegimenInput, UpdateRegimenInput, RegimenQueryInput } from '@meshitrack/shared';
import { getFrequencyMultiplier } from '@meshitrack/shared';
import type { DosageFrequency } from '@meshitrack/shared';
@ -14,6 +15,46 @@ interface Deps {
cabinetEventsService: CabinetEventsService;
}
interface RegimenMedicationData {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
dosage: number;
dosageUnit: string;
frequency: string;
customFrequencyPerDay?: number;
timeOfDay?: string;
instructions?: string;
}
interface CabinetAggregateSummaryGroup {
_id: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: string;
medicineForm: string;
totalQuantity: number;
unit: string;
earliestExpiry: Date | null;
itemCount: number;
}
interface BurnRateItem {
medicineId: string;
medicineName: string;
dailyConsumption: number;
totalInCabinet: number;
daysUntilEmpty: number | null;
earliestExpiry: string | null;
avgUnitPrice: number | null;
projectedDailyCost: number | null;
projectedMonthlyCost: number | null;
projectedYearlyCost: number | null;
currency: string | null;
}
export class RegimensService {
private readonly regimensRepository: RegimensRepository;
private readonly medicinesRepository: MedicinesRepository;
@ -32,17 +73,25 @@ export class RegimensService {
this.cabinetEventsService = cabinetEventsService;
}
public async list(householdId: string, userId: string, query: RegimenQueryInput) {
public async list(
householdId: string,
userId: string,
query: RegimenQueryInput,
): Promise<{ data: RegimenDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
return this.regimensRepository.findByHousehold(householdId, userId, query);
}
public async getById(id: string, householdId: string, userId: string) {
public async getById(id: string, householdId: string, userId: string): Promise<RegimenDocument> {
const regimen = await this.regimensRepository.findById(id, householdId, userId);
if (!regimen) throw new NotFoundError('Regimen not found');
return regimen;
}
public async create(data: CreateRegimenInput, householdId: string, userId: string) {
public async create(
data: CreateRegimenInput,
householdId: string,
userId: string,
): Promise<RegimenDocument> {
const medications = await this.denormalizeMedications(data.medications, householdId);
return this.regimensRepository.create(
{ name: data.name, isActive: data.isActive, medications },
@ -52,14 +101,23 @@ export class RegimensService {
);
}
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenInput) {
public async update(
id: string,
householdId: string,
userId: string,
data: UpdateRegimenInput,
): Promise<RegimenDocument> {
await this.getById(id, householdId, userId);
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.isActive !== undefined) updateData['isActive'] = data.isActive;
const updateData: {
name?: string;
isActive?: boolean;
medications?: RegimenMedicationData[];
} = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.medications !== undefined) {
updateData['medications'] = await this.denormalizeMedications(data.medications, householdId);
updateData.medications = await this.denormalizeMedications(data.medications, householdId);
}
const updated = await this.regimensRepository.update(id, householdId, userId, updateData);
@ -67,18 +125,18 @@ export class RegimensService {
return updated;
}
public async delete(id: string, householdId: string, userId: string) {
public async delete(id: string, householdId: string, userId: string): Promise<RegimenDocument> {
await this.getById(id, householdId, userId);
const deleted = await this.regimensRepository.softDelete(id, householdId, userId);
if (!deleted) throw new NotFoundError('Regimen not found');
return deleted;
}
public async getActiveByUser(householdId: string, userId: string) {
public async getActiveByUser(householdId: string, userId: string): Promise<RegimenDocument[]> {
return this.regimensRepository.findActiveByUser(householdId, userId);
}
public async calculateBurnRates(householdId: string, userId: string) {
public async calculateBurnRates(householdId: string, userId: string): Promise<BurnRateItem[]> {
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
// Sum daily consumption per medicine across all active regimens
@ -111,12 +169,13 @@ export class RegimensService {
if (consumptionMap.size === 0) return [];
// Get cabinet summary for all medicines in regimens
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryResultsRaw = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryResults = summaryResultsRaw as unknown as CabinetAggregateSummaryGroup[];
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
for (const s of summaryResults) {
stockMap.set(s._id as string, {
totalQuantity: s.totalQuantity as number,
earliestExpiry: (s.earliestExpiry as Date | null) ?? null,
stockMap.set(s._id, {
totalQuantity: s.totalQuantity,
earliestExpiry: s.earliestExpiry,
});
}
@ -125,7 +184,7 @@ export class RegimensService {
const priceMap = await this.cabinetEventsService.getAvgUnitPrices(householdId, medicineIds);
// Build burn rate array
const burnRates = [];
const burnRates: BurnRateItem[] = [];
for (const [medicineId, consumption] of consumptionMap) {
const stock = stockMap.get(medicineId);
const totalInCabinet = stock?.totalQuantity ?? 0;
@ -174,8 +233,8 @@ export class RegimensService {
private async denormalizeMedications(
medications: CreateRegimenInput['medications'],
householdId: string,
) {
const result = [];
): Promise<RegimenMedicationData[]> {
const result: RegimenMedicationData[] = [];
for (const med of medications) {
const medicine = await this.medicinesRepository.findById(med.medicineId, householdId);
if (!medicine) {

View file

@ -1,13 +1,118 @@
import { ShoppingListModel } from '../../schemas/shopping-list.schema.js';
import type {
UpdateShoppingListInput,
ShoppingItem,
} from '@meshitrack/shared';
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
import type { UpdateShoppingListInput, ShoppingItem } from '@meshitrack/shared';
export class ShoppingListsRepository {
private sortItems(list: any) {
public async create(
data: Omit<ShoppingListDocument, '_id' | 'createdAt' | 'updatedAt'>,
): Promise<ShoppingListDocument> {
const list = new ShoppingListModel(data);
const saved = await list.save();
return this.sortItems(saved.toObject() as ShoppingListDocument);
}
public async list(householdId: string): Promise<ShoppingListDocument[]> {
const lists = await ShoppingListModel.find({ householdId })
.sort({ createdAt: -1 })
.lean()
.exec();
return (lists as unknown as ShoppingListDocument[]).map((l) => this.sortItems(l));
}
public async findById(id: string, householdId: string): Promise<ShoppingListDocument | null> {
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
return this.sortItems(list as unknown as ShoppingListDocument | null);
}
public async findActiveByHousehold(householdId: string): Promise<ShoppingListDocument[]> {
const lists = await ShoppingListModel.find({
householdId,
status: { $in: ['active', 'shopping'] },
})
.sort({ updatedAt: -1 })
.lean()
.exec();
return (lists as unknown as ShoppingListDocument[]).map((l) => this.sortItems(l));
}
public async update(
id: string,
householdId: string,
data: UpdateShoppingListInput,
): Promise<ShoppingListDocument | null> {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: data },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated as unknown as ShoppingListDocument | null);
}
public async delete(id: string, householdId: string): Promise<ShoppingListDocument | null> {
const deleted = await ShoppingListModel.findOneAndDelete({ _id: id, householdId })
.lean()
.exec();
return deleted as unknown as ShoppingListDocument | null;
}
// --- Granular Atomic Subdocument Actions ---
public async addItem(
id: string,
householdId: string,
item: ShoppingItem,
): Promise<ShoppingListDocument | null> {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $push: { items: item } },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated as unknown as ShoppingListDocument | null);
}
public async updateItem(
id: string,
householdId: string,
itemId: string,
updates: Partial<ShoppingItem>,
): Promise<ShoppingListDocument | null> {
const setUpdates: Record<string, unknown> = {};
for (const [key, val] of Object.entries(updates)) {
setUpdates[`items.$.${key}`] = val;
}
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId, 'items.id': itemId },
{ $set: setUpdates },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated as unknown as ShoppingListDocument | null);
}
public async removeItem(
id: string,
householdId: string,
itemId: string,
): Promise<ShoppingListDocument | null> {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $pull: { items: { id: itemId } } },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated as unknown as ShoppingListDocument | null);
}
private sortItems<T extends { items?: ShoppingItem[] } | null>(list: T): T {
if (!list || !list.items) return list;
list.items.sort((a: any, b: any) => {
list.items.sort((a: ShoppingItem, b: ShoppingItem) => {
// Unchecked first
if (a.checked !== b.checked) return a.checked ? 1 : -1;
// Then by category
@ -21,94 +126,4 @@ export class ShoppingListsRepository {
});
return list;
}
public async create(data: any) {
const list = new ShoppingListModel(data);
const saved = await list.save();
return this.sortItems(saved.toObject());
}
public async list(householdId: string) {
const lists = await ShoppingListModel.find({ householdId })
.sort({ createdAt: -1 })
.lean()
.exec();
return lists.map((l) => this.sortItems(l));
}
public async findById(id: string, householdId: string) {
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
return this.sortItems(list);
}
public async findActiveByHousehold(householdId: string) {
const lists = await ShoppingListModel.find({
householdId,
status: { $in: ['active', 'shopping'] },
})
.sort({ updatedAt: -1 })
.lean()
.exec();
return lists.map((l) => this.sortItems(l));
}
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: data },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated);
}
public async delete(id: string, householdId: string) {
return ShoppingListModel.findOneAndDelete({ _id: id, householdId }).lean().exec();
}
// --- Granular Atomic Subdocument Actions ---
public async addItem(id: string, householdId: string, item: ShoppingItem) {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $push: { items: item } },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated);
}
public async updateItem(
id: string,
householdId: string,
itemId: string,
updates: Partial<ShoppingItem>,
) {
const setUpdates: Record<string, unknown> = {};
for (const [key, val] of Object.entries(updates)) {
setUpdates[`items.$.${key}`] = val;
}
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId, 'items.id': itemId },
{ $set: setUpdates },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated);
}
public async removeItem(id: string, householdId: string, itemId: string) {
const updated = await ShoppingListModel.findOneAndUpdate(
{ _id: id, householdId },
{ $pull: { items: { id: itemId } } },
{ new: true },
)
.lean()
.exec();
return this.sortItems(updated);
}
}

View file

@ -10,16 +10,21 @@ import {
UpdateShoppingItemSchema,
ShoppingListResponseSchema,
type ShoppingItem,
type CreateShoppingListInput,
type UpdateShoppingListInput,
type AddShoppingItemInput,
type UpdateShoppingItemInput,
} from '@meshitrack/shared';
import { ShoppingListsRepository } from './shopping-lists.repository.js';
import { ShoppingListsService } from './shopping-lists.service.js';
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
import { StoresRepository } from '../stores/stores.repository.js';
// Memory track for live concurrent websocket clients per active list session
const activeListSockets = new Map<string, Set<WebSocket>>();
/* v8 ignore start */
function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) {
function broadcastToList(listId: string, excludeSocket: WebSocket | null, message: unknown): void {
const set = activeListSockets.get(listId);
if (!set) return;
const payload = JSON.stringify(message);
@ -38,16 +43,49 @@ declare module '@fastify/awilix' {
}
}
function serializeList(doc: any) {
interface SerializedShoppingList {
_id: string;
householdId: string;
name: string;
status: string;
createdBy: string;
createdAt?: string;
updatedAt?: string;
completedAt?: string;
mealPlanId?: string;
totalEstimatedCost?: number;
preferredStoreId?: string;
items: Array<Omit<ShoppingItem, 'checkedAt'> & { checkedAt?: string }>;
}
function serializeList(doc: ShoppingListDocument): SerializedShoppingList {
return {
...doc,
_id: doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
status: doc.status,
createdBy: doc.createdBy,
createdAt: doc.createdAt?.toISOString(),
updatedAt: doc.updatedAt?.toISOString(),
completedAt: doc.completedAt?.toISOString(),
items: doc.items.map((it: any) => ({
...it,
mealPlanId: doc.mealPlanId,
totalEstimatedCost: doc.totalEstimatedCost,
preferredStoreId: doc.preferredStoreId,
items: doc.items.map((it: ShoppingItem) => ({
id: it.id,
productId: it.productId,
customName: it.customName,
quantity: it.quantity,
unit: it.unit,
checked: it.checked,
checkedAt: it.checkedAt?.toISOString(),
checkedBy: it.checkedBy,
estimatedPrice: it.estimatedPrice,
actualPrice: it.actualPrice,
storeId: it.storeId,
notes: it.notes,
category: it.category,
addedToPantry: it.addedToPantry,
})),
};
}
@ -76,7 +114,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const lists = await service.list(request.params.householdId);
const params = request.params as { householdId: string };
const lists = await service.list(params.householdId);
return reply.send(lists.map(serializeList));
},
});
@ -91,11 +130,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const list = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateShoppingListInput;
const list = await service.create(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(serializeList(list));
},
});
@ -109,7 +146,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const list = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const list = await service.getById(params.id, params.householdId);
return reply.send(serializeList(list));
},
});
@ -124,11 +162,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const list = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateShoppingListInput;
const list = await service.update(params.id, params.householdId, body);
return reply.send(serializeList(list));
},
});
@ -141,7 +177,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
await service.delete(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
await service.delete(params.id, params.householdId);
return reply.status(204).send();
},
});
@ -158,14 +195,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const { list, addedItem } = await service.addItem(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as AddShoppingItemInput;
const { list, addedItem } = await service.addItem(params.id, params.householdId, body);
// Emit real-time update notification to existing connected viewers
broadcastToList(request.params.id, null as any, {
broadcastToList(params.id, null, {
type: 'ITEM_ADDED',
item: { ...addedItem, checkedAt: undefined },
});
@ -184,24 +219,24 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const params = request.params as { householdId: string; id: string; itemId: string };
const body = request.body as UpdateShoppingItemInput;
const updatedList = await service.updateItem(
request.params.id,
request.params.householdId,
request.params.itemId,
request.body,
params.id,
params.householdId,
params.itemId,
body,
request.user.keycloakId,
);
// Broadcast the precise item differential state update to sibling websocket listeners
const matchedItem = updatedList.items.find(
(i: ShoppingItem) => i.id === request.params.itemId,
);
const matchedItem = updatedList.items.find((i: ShoppingItem) => i.id === params.itemId);
if (matchedItem) {
broadcastToList(request.params.id, null as any, {
broadcastToList(params.id, null, {
type: 'ITEM_UPDATED',
itemId: request.params.itemId,
itemId: params.itemId,
updates: {
...request.body,
...body,
checkedAt: matchedItem.checkedAt?.toISOString(),
checkedBy: matchedItem.checkedBy,
},
@ -221,15 +256,12 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('shoppingListsService');
const list = await service.removeItem(
request.params.id,
request.params.householdId,
request.params.itemId,
);
const params = request.params as { householdId: string; id: string; itemId: string };
const list = await service.removeItem(params.id, params.householdId, params.itemId);
broadcastToList(request.params.id, null as any, {
broadcastToList(params.id, null, {
type: 'ITEM_REMOVED',
itemId: request.params.itemId,
itemId: params.itemId,
});
return reply.send(serializeList(list));
@ -239,6 +271,7 @@ export default fp(
// 4. Persist Collaborative WebSocket Handshakes
/* v8 ignore start */
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
app.get(
'/api/v1/households/:householdId/shopping-lists/:id/sync',
{ websocket: true },
@ -301,6 +334,7 @@ export default fp(
});
},
);
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
/* v8 ignore stop */
},
{

View file

@ -1,4 +1,5 @@
import type { ShoppingListsRepository } from './shopping-lists.repository.js';
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
import type {
CreateShoppingListInput,
UpdateShoppingListInput,
@ -6,8 +7,7 @@ import type {
UpdateShoppingItemInput,
ShoppingItem,
} from '@meshitrack/shared';
import { ShoppingListSourceType } from '@meshitrack/shared';
import { StorageLocation, type ServingUnit } from '@meshitrack/shared';
import { type ProductCategory } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
import { v4 as uuidv4 } from 'uuid';
@ -18,29 +18,31 @@ interface Deps {
export class ShoppingListsService {
private readonly shoppingListsRepository: ShoppingListsRepository;
public constructor({
shoppingListsRepository,
}: Deps) {
public constructor({ shoppingListsRepository }: Deps) {
this.shoppingListsRepository = shoppingListsRepository;
}
public async list(householdId: string) {
public async list(householdId: string): Promise<ShoppingListDocument[]> {
return this.shoppingListsRepository.list(householdId);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<ShoppingListDocument> {
const list = await this.shoppingListsRepository.findById(id, householdId);
if (!list) throw new NotFoundError('Shopping list not found');
return list;
}
public async create(data: CreateShoppingListInput, householdId: string, userId: string) {
public async create(
data: CreateShoppingListInput,
householdId: string,
userId: string,
): Promise<ShoppingListDocument> {
const hydratedItems: ShoppingItem[] = [];
for (const it of data.items || []) {
const itemId = uuidv4();
let estimatedPrice: number | undefined;
let category: string | undefined = it.category;
const estimatedPrice: number | undefined = undefined;
const category: string | undefined = it.category;
hydratedItems.push({
id: itemId,
@ -52,7 +54,7 @@ export class ShoppingListsService {
addedToPantry: false,
notes: it.notes,
estimatedPrice,
category: category as any,
category: category as ProductCategory,
});
}
@ -65,25 +67,33 @@ export class ShoppingListsService {
});
}
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
public async update(
id: string,
householdId: string,
data: UpdateShoppingListInput,
): Promise<ShoppingListDocument> {
await this.getById(id, householdId);
const updated = await this.shoppingListsRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Shopping list not found');
return updated;
}
public async delete(id: string, householdId: string) {
public async delete(id: string, householdId: string): Promise<ShoppingListDocument | null> {
await this.getById(id, householdId);
return this.shoppingListsRepository.delete(id, householdId);
}
// --- Live Item Actions ---
public async addItem(id: string, householdId: string, data: AddShoppingItemInput) {
public async addItem(
id: string,
householdId: string,
data: AddShoppingItemInput,
): Promise<{ list: ShoppingListDocument; addedItem: ShoppingItem }> {
await this.getById(id, householdId);
let estimatedPrice: number | undefined;
let category: string | undefined = data.category;
const estimatedPrice: number | undefined = undefined;
const category: string | undefined = data.category;
const newItem: ShoppingItem = {
id: uuidv4(),
@ -95,7 +105,7 @@ export class ShoppingListsService {
addedToPantry: false,
notes: data.notes,
estimatedPrice,
category: category as any,
category: category as ProductCategory,
};
const updated = await this.shoppingListsRepository.addItem(id, householdId, newItem);
@ -109,7 +119,7 @@ export class ShoppingListsService {
itemId: string,
data: UpdateShoppingItemInput,
userId: string,
) {
): Promise<ShoppingListDocument> {
const updates: Partial<ShoppingItem> = { ...data };
if (data.checked !== undefined) {
@ -122,10 +132,13 @@ export class ShoppingListsService {
return updated;
}
public async removeItem(id: string, householdId: string, itemId: string) {
public async removeItem(
id: string,
householdId: string,
itemId: string,
): Promise<ShoppingListDocument> {
const updated = await this.shoppingListsRepository.removeItem(id, householdId, itemId);
if (!updated) throw new NotFoundError('Shopping list not found');
return updated;
}
}

View file

@ -1,8 +1,12 @@
import { StoreModel } from '../../schemas/store.schema.js';
import type { StoreDocument } from '../../schemas/store.schema.js';
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
export class StoresRepository {
public async findByHousehold(householdId: string, query: StoreQueryInput) {
public async findByHousehold(
householdId: string,
query: StoreQueryInput,
): Promise<{ data: StoreDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
const filter: Record<string, unknown> = { householdId };
if (query.tags) {
@ -34,32 +38,46 @@ export class StoresRepository {
const cursor =
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
return {
data: data as unknown as StoreDocument[],
pagination: { cursor: hasMore ? cursor : null, hasMore },
};
}
public async findById(id: string, householdId: string) {
return StoreModel.findOne({ _id: id, householdId }).lean().exec();
public async findById(id: string, householdId: string): Promise<StoreDocument | null> {
const doc = await StoreModel.findOne({ _id: id, householdId }).lean().exec();
return doc as unknown as StoreDocument | null;
}
public async create(data: CreateStoreInput, householdId: string, createdBy: string) {
public async create(
data: CreateStoreInput,
householdId: string,
createdBy: string,
): Promise<StoreDocument> {
const store = new StoreModel({ ...data, householdId, createdBy });
const saved = await store.save();
return saved.toObject();
return saved.toObject() as StoreDocument;
}
public async update(id: string, householdId: string, data: UpdateStoreInput) {
return StoreModel.findOneAndUpdate(
public async update(
id: string,
householdId: string,
data: UpdateStoreInput,
): Promise<StoreDocument | null> {
const doc = await StoreModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: data },
{ new: true, lean: true },
).exec();
return doc as unknown as StoreDocument | null;
}
public async deactivate(id: string, householdId: string) {
return StoreModel.findOneAndUpdate(
public async deactivate(id: string, householdId: string): Promise<StoreDocument | null> {
const doc = await StoreModel.findOneAndUpdate(
{ _id: id, householdId },
{ $set: { isActive: false } },
{ new: true, lean: true },
).exec();
return doc as unknown as StoreDocument | null;
}
}

View file

@ -8,12 +8,16 @@ import {
StoreQuerySchema,
StoreResponseSchema,
StoreListResponseSchema,
type CreateStoreInput,
type UpdateStoreInput,
type StoreQueryInput,
} from '@meshitrack/shared';
import { StoresRepository } from './stores.repository.js';
import { StoresService } from './stores.service.js';
import type { StoreDocument } from '../../schemas/store.schema.js';
type AnyStoreDoc = {
_id: string | { toString: () => string };
interface SerializedStoreResponse {
_id: string;
householdId: string;
name: string;
address?: string;
@ -23,31 +27,47 @@ type AnyStoreDoc = {
tags: string[];
isActive: boolean;
createdBy: string;
createdAt: string | Date | { toISOString: () => string };
updatedAt: string | Date | { toISOString: () => string };
};
function toIso(v: string | Date | { toISOString: () => string }): string {
if (typeof v === 'string') return v;
return v.toISOString();
createdAt: string;
updatedAt: string;
}
function toStoreResponse(rawDoc: unknown) {
const doc = rawDoc as AnyStoreDoc;
return {
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
function toStoreResponse(doc: StoreDocument): SerializedStoreResponse {
const docAny = doc as unknown as {
address?: string | null;
location?: { lat: number; lng: number } | null;
url?: string | null;
notes?: string | null;
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
const response: SerializedStoreResponse = {
_id: doc._id.toString(),
householdId: doc.householdId,
name: doc.name,
...(doc.address != null ? { address: doc.address } : {}),
...(doc.location != null ? { location: doc.location } : {}),
...(doc.url != null ? { url: doc.url } : {}),
...(doc.notes != null ? { notes: doc.notes } : {}),
tags: doc.tags,
tags: doc.tags || [],
isActive: doc.isActive,
createdBy: doc.createdBy,
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
if (docAny.address) response.address = docAny.address;
if (docAny.location) response.location = docAny.location;
if (docAny.url) response.url = docAny.url;
if (docAny.notes) response.notes = docAny.notes;
return response;
}
declare module '@fastify/awilix' {
@ -77,7 +97,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const result = await service.list(request.params.householdId, request.query);
const params = request.params as { householdId: string };
const query = request.query as StoreQueryInput;
const result = await service.list(params.householdId, query);
return reply.send({
data: result.data.map(toStoreResponse),
pagination: result.pagination,
@ -94,7 +116,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.getById(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const store = await service.getById(params.id, params.householdId);
return reply.send(toStoreResponse(store));
},
});
@ -109,11 +132,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.create(
request.body,
request.params.householdId,
request.user.keycloakId,
);
const params = request.params as { householdId: string };
const body = request.body as CreateStoreInput;
const store = await service.create(body, params.householdId, request.user.keycloakId);
return reply.status(201).send(toStoreResponse(store));
},
});
@ -128,11 +149,9 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.update(
request.params.id,
request.params.householdId,
request.body,
);
const params = request.params as { householdId: string; id: string };
const body = request.body as UpdateStoreInput;
const store = await service.update(params.id, params.householdId, body);
return reply.send(toStoreResponse(store));
},
});
@ -146,7 +165,8 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('storesService');
const store = await service.deactivate(request.params.id, request.params.householdId);
const params = request.params as { householdId: string; id: string };
const store = await service.deactivate(params.id, params.householdId);
return reply.send(toStoreResponse(store));
},
});

View file

@ -1,4 +1,5 @@
import type { StoresRepository } from './stores.repository.js';
import type { StoreDocument } from '../../schemas/store.schema.js';
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
@ -13,28 +14,39 @@ export class StoresService {
this.storesRepository = storesRepository;
}
public async list(householdId: string, query: StoreQueryInput) {
public async list(
householdId: string,
query: StoreQueryInput,
): Promise<{ data: StoreDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
return this.storesRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
public async getById(id: string, householdId: string): Promise<StoreDocument> {
const store = await this.storesRepository.findById(id, householdId);
if (!store) throw new NotFoundError('Store not found');
return store;
}
public async create(data: CreateStoreInput, householdId: string, userId: string) {
public async create(
data: CreateStoreInput,
householdId: string,
userId: string,
): Promise<StoreDocument> {
return this.storesRepository.create(data, householdId, userId);
}
public async update(id: string, householdId: string, data: UpdateStoreInput) {
public async update(
id: string,
householdId: string,
data: UpdateStoreInput,
): Promise<StoreDocument> {
await this.getById(id, householdId);
const updated = await this.storesRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Store not found');
return updated;
}
public async deactivate(id: string, householdId: string) {
public async deactivate(id: string, householdId: string): Promise<StoreDocument> {
await this.getById(id, householdId);
const updated = await this.storesRepository.deactivate(id, householdId);
if (!updated) throw new NotFoundError('Store not found');

View file

@ -1,32 +1,47 @@
import type mongoose from 'mongoose';
import { UserModel } from '../../schemas/user.schema.js';
import type { UserDocument } from '../../schemas/user.schema.js';
import type { CreateUserInput, UpdateUserInput } from '@meshitrack/shared';
export class UsersRepository {
public async findByKeycloakId(keycloakId: string, session?: mongoose.ClientSession) {
return UserModel.findOne({ keycloakId }, null, { session }).lean().exec();
public async findByKeycloakId(
keycloakId: string,
session?: mongoose.ClientSession,
): Promise<UserDocument | null> {
const doc = await UserModel.findOne({ keycloakId }, null, { session }).lean().exec();
return doc as unknown as UserDocument | null;
}
public async findById(id: string) {
return UserModel.findById(id).lean().exec();
public async findById(id: string): Promise<UserDocument | null> {
const doc = await UserModel.findById(id).lean().exec();
return doc as unknown as UserDocument | null;
}
public async create(data: CreateUserInput) {
public async create(data: CreateUserInput): Promise<UserDocument> {
const user = new UserModel(data);
const saved = await user.save();
return saved.toObject();
return saved.toObject() as UserDocument;
}
public async update(keycloakId: string, data: UpdateUserInput, session?: mongoose.ClientSession) {
return UserModel.findOneAndUpdate(
public async update(
keycloakId: string,
data: UpdateUserInput,
session?: mongoose.ClientSession,
): Promise<UserDocument | null> {
const doc = await UserModel.findOneAndUpdate(
{ keycloakId },
{ $set: data },
{ new: true, lean: true, session },
).exec();
return doc as unknown as UserDocument | null;
}
public async upsertFromToken(keycloakId: string, email: string, displayName: string) {
return UserModel.findOneAndUpdate(
public async upsertFromToken(
keycloakId: string,
email: string,
displayName: string,
): Promise<UserDocument | null> {
const doc = await UserModel.findOneAndUpdate(
{ keycloakId },
{
$set: { email, displayName },
@ -34,5 +49,6 @@ export class UsersRepository {
},
{ upsert: true, new: true, lean: true },
).exec();
return doc as unknown as UserDocument | null;
}
}

View file

@ -5,31 +5,43 @@ import { UserResponseSchema } from '@meshitrack/shared';
import { UsersRepository } from './users.repository.js';
import { UsersService } from './users.service.js';
import { NotFoundError } from '../../common/errors.js';
import type { UserDocument } from '../../schemas/user.schema.js';
type AnyUserDoc = {
_id: string | { toString: () => string };
interface SerializedUserResponse {
_id: string;
keycloakId: string;
displayName: string;
email: string;
householdIds: string[];
defaultHouseholdId?: string | null;
createdAt: string | { toISOString: () => string };
updatedAt: string | { toISOString: () => string };
};
defaultHouseholdId: string | null;
createdAt: string;
updatedAt: string;
}
function toUserResponse(doc: UserDocument): SerializedUserResponse {
const docAny = doc as unknown as {
createdAt?: { toISOString?: () => string } | string;
updatedAt?: { toISOString?: () => string } | string;
};
const createdAtStr =
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
? docAny.createdAt.toISOString()
: String(docAny.createdAt || '');
const updatedAtStr =
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
? docAny.updatedAt.toISOString()
: String(docAny.updatedAt || '');
function toUserResponse(doc: AnyUserDoc) {
const id = typeof doc._id === 'string' ? doc._id : doc._id.toString();
const createdAt = typeof doc.createdAt === 'string' ? doc.createdAt : doc.createdAt.toISOString();
const updatedAt = typeof doc.updatedAt === 'string' ? doc.updatedAt : doc.updatedAt.toISOString();
return {
_id: id,
_id: doc._id.toString(),
keycloakId: doc.keycloakId,
displayName: doc.displayName,
email: doc.email,
householdIds: doc.householdIds,
defaultHouseholdId: doc.defaultHouseholdId ?? null,
createdAt,
updatedAt,
createdAt: createdAtStr,
updatedAt: updatedAtStr,
};
}

View file

@ -1,4 +1,5 @@
import type { UsersRepository } from './users.repository.js';
import type { UserDocument } from '../../schemas/user.schema.js';
import type { AuthUser } from '../../common/types.js';
import { NotFoundError } from '../../common/errors.js';
@ -13,11 +14,11 @@ export class UsersService {
this.usersRepository = usersRepository;
}
public async syncFromToken(user: AuthUser) {
public async syncFromToken(user: AuthUser): Promise<UserDocument | null> {
return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName);
}
public async getProfile(keycloakId: string) {
public async getProfile(keycloakId: string): Promise<UserDocument> {
const user = await this.usersRepository.findByKeycloakId(keycloakId);
if (!user) {
throw new NotFoundError('User not found');

View file

@ -1,3 +1,4 @@
/* eslint-disable */
/**
* Migration: normalize legacy dosage unit values to current DosageUnit enum.
*

View file

@ -1,3 +1,4 @@
/* eslint-disable */
import mongoose from 'mongoose';
// Fixed ID shared with the Keycloak test users' householdIds attribute.

View file

@ -23,22 +23,22 @@ describe(RefillsService.name, () => {
getLatestForMedicine: vi.fn(),
compareStores: vi.fn(),
};
const mockPurchasesRepo = {
getPendingMedicineStock: vi.fn(),
const mockShoppingListsRepo = {
findActiveByHousehold: vi.fn(),
};
let service: RefillsService;
beforeEach(() => {
vi.clearAllMocks();
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([]);
service = new RefillsService({
refillsRepository: mockRepo as never,
regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never,
cabinetService: mockCabinetService as never,
medicinePricesRepository: mockPricesRepo as never,
purchasesRepository: mockPurchasesRepo as never,
shoppingListsRepository: mockShoppingListsRepo as never,
});
});
@ -138,7 +138,7 @@ describe(RefillsService.name, () => {
expect(result[0].cheapestOption?.storeName).toBe('CVS');
});
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
it('includes pendingOrderStock and daysUntilEmptyWithOrders from active shopping lists', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{
medicineId: 'med-1',
@ -151,8 +151,12 @@ describe(RefillsService.name, () => {
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
{ medicineId: 'med-1', totalUnits: 60 },
mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([
{
items: [
{ productId: 'med-1', quantity: 60, checked: false },
],
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);