diff --git a/docs/lint_resolution_plan.md b/docs/lint_resolution_plan.md new file mode 100644 index 0000000..82d6370 --- /dev/null +++ b/docs/lint_resolution_plan.md @@ -0,0 +1,54 @@ +# MeshiTrack ESLint Remediation & Clean-up Plan + +This document establishes a highly structured, quantitative, and actionable plan to resolve the remaining **ESLint violations** across the MeshiTrack monorepo. Following the auto-formatting runs (which successfully wiped out all 279 Prettier warnings), we have mapped out all remaining violations into distinct, manageable categories. + +--- + +## 📊 1. Quantitative Breakdown of Remaining Violations + +| Rule ID | API Package Violations | Web Package Violations | Total Violations | Critical Remediation Category | +| :--- | :---: | :---: | :---: | :--- | +| **`@typescript-eslint/explicit-function-return-type`** | 165 | 50 | **215** | Stage 2: Return Boundary Annotations | +| **`@typescript-eslint/no-unsafe-member-access`** | 63 | 117 | **180** | Stage 3: Unsafe-`any` Remediation & Unknown Parsing | +| **`@typescript-eslint/naming-convention`** | 76 | 43 | **119** | Stage 1: Fine-tuning Rule Rules | +| **`@typescript-eslint/no-unsafe-assignment`** | 43 | 33 | **76** | Stage 3: Unsafe-`any` Remediation & Unknown Parsing | +| **`@typescript-eslint/no-explicit-any`** | 15 | 38 | **53** | Stage 3: Type-safe Mocking & unknown Shift | +| **`@typescript-eslint/no-unsafe-return`** / **`no-unsafe-call`** | 43 | 25 | **68** | Stage 3: Unsafe-`any` Remediation & Unknown Parsing | +| **`@typescript-eslint/no-floating-promises`** / **`no-misused-promises`** | 4 | 26 | **30** | Stage 4: Safe Asynchronous Execution | +| **`@typescript-eslint/member-ordering`** | 15 | 4 | **19** | Stage 5: Structural Class Ordering | +| **Others** (e.g. `unused-vars`, `prefer-const`) | 5 | 5 | **10** | Stage 6: Minor Cleanups | +| **Total Structural Errors** | **428** | **341** | **769** | | + +--- + +## 📅 2. Stage-by-Stage Remediation Strategy + +### 🛠️ Stage 1: Rule Refinement (Remediation: ~120 Errors Resolved) +Before refactoring active files, we fine-tune our rules to represent real-world Javascript/TypeScript practices: +1. **Allow `PascalCase` on variables**: We have already enabled `PascalCase` for variables in both packages, allowing React component functions (e.g. `const TopBar = ...`) and database model objects (e.g. `const UserModel = ...`) to parse correctly. +2. **Exclude dynamic API/Database keys**: We will configure `@typescript-eslint/naming-convention` to ignore property names containing leading/trailing underscores (like `_id` and `__v`) or specific HTTP headers in controllers. + +--- + +### 📝 Stage 2: Explicit Boundary Return Types (Remediation: 215 Errors Resolved) +To prevent type-drift, we will add explicit return signatures to the codebase: +1. **Service and Repository Boundaries**: Add explicit return types to all public methods in all services (e.g., `Promise`, `Promise`). +2. **Fastify Route Handlers**: Specify return types or configure ESLint to allow implicit handler return values since Fastify structures routing objects automatically. + +--- + +### 🛡️ Stage 3: Unsafe-`any` Eradication (Remediation: 377 Errors Resolved) +The largest source of logic leaks resides in un-typed values and `as any` casting: +1. **Shift to `unknown` for Boundary Payloads**: For Fastify dynamic requests (`request.body`, `request.params`) and Next.js dynamic states, specify types as `unknown` and parse them safely via **Zod schemas**. +2. **Standardize Test Suite Mocks**: Replace `as any` mock variables in unit tests with type-safe mock generators or `Partial` objects (e.g. using `vi.mocked()` correctly). + +--- + +### ⚡ Stage 4: Floating Promises & misuses (Remediation: 30 Errors Resolved) +1. **Await floating promises**: Add correct `await` statements to database connections and route registrations. +2. **Explicit Background Tasks**: Mark intentional background hooks (like event logs) with `void` (e.g. `void this.logEvent(...)`) to document intention. + +--- + +### 🧱 Stage 5: Structural Class Member Ordering (Remediation: 19 Errors Resolved) +1. Organize the 19 classes failing the ordering rule to place fields at the top, followed by constructors, followed by public methods, followed by private methods. diff --git a/docs/modules_cleanup_blueprint.md b/docs/modules_cleanup_blueprint.md new file mode 100644 index 0000000..635a86b --- /dev/null +++ b/docs/modules_cleanup_blueprint.md @@ -0,0 +1,166 @@ +# MeshiTrack Monorepo Clean-up Blueprint + +This playbook maps out the precise, incremental, batch-by-batch process to refactor all remaining active modules in both `@meshitrack/api` and `@meshitrack/web` to achieve complete ESLint compliance under our strict new guidelines, while keeping our **100.00% test coverage** pristine. + +--- + +## 🚀 The Core Refactoring Strategy + +For every class/module we clean up, we follow the established **Shopping Lists Reference Standard**: +1. **Repositories**: + * Add explicit type parameter annotations for return values (e.g. `: Promise`). + * Put private helper methods at the very bottom (under public methods) to satisfy `@typescript-eslint/member-ordering`. + * Type query filters explicitly rather than using `any`. +2. **Services**: + * Keep class properties explicit and `readonly` (e.g. `private readonly repository: Repository`). + * Add explicit return signatures (e.g. `: Promise`). + * Avoid `any` completely; use precise casts (e.g. `as ServingUnit`) or type guards. +3. **Routes**: + * Cast Fastify inputs explicitly (`request.params as { ... }` and `request.body as ...`). + * Annotate serialization return contracts explicitly (e.g. `serializeList(doc: Document): SerializedContract`). + * Wrap WebSocket handshakes or un-typed third party plugins in local ESLint overrides. + +--- + +## 📅 The Batch Playbook + +### 🧱 Batch 1: Core Identity & Household Foundation +Enforces structural types on our identity schemas, auth profiles, and tenant containers. + +* **Target Backend Files**: + * `packages/api/src/modules/users/users.repository.ts` + * `packages/api/src/modules/users/users.service.ts` + * `packages/api/src/modules/users/users.routes.ts` + * `packages/api/src/modules/households/households.repository.ts` + * `packages/api/src/modules/households/households.service.ts` + * `packages/api/src/modules/households/households.routes.ts` +* **Key Focus**: + * Ensure user keycloak sync mapping has explicit return contracts. + * Cast tenant properties safely across fastify injection boundaries. +* **Verification Runner**: + ```bash + cd packages/api + npx eslint src/modules/users src/modules/households --fix + npx vitest run tests/modules/users tests/modules/households + ``` + +--- + +### 💊 Batch 2: Medicine Cabinet & Consumption Logs +Refactors the active cabinet inventory, item counts, and historical events tracking. + +* **Target Backend Files**: + * `packages/api/src/modules/medicines/medicines.repository.ts` + * `packages/api/src/modules/medicines/medicines.service.ts` + * `packages/api/src/modules/medicines/medicines.routes.ts` + * `packages/api/src/modules/cabinet/cabinet.repository.ts` + * `packages/api/src/modules/cabinet/cabinet.service.ts` + * `packages/api/src/modules/cabinet/cabinet.routes.ts` + * `packages/api/src/modules/cabinet-events/cabinet-events.repository.ts` + * `packages/api/src/modules/cabinet-events/cabinet-events.service.ts` + * `packages/api/src/modules/cabinet-events/cabinet-events.routes.ts` +* **Key Focus**: + * Clean up dosage calculations in the service layer, replacing implicit float types. + * Ensure strict sorting sequence in historical event queries. +* **Verification Runner**: + ```bash + cd packages/api + npx eslint src/modules/medicines src/modules/cabinet src/modules/cabinet-events --fix + npx vitest run tests/modules/medicines tests/modules/cabinet tests/modules/cabinet-events + ``` + +--- + +### 🏪 Batch 3: Store Catalog & Price Registry +Enforces type safety on retail outlets, product mappings, and historical pricing logs. + +* **Target Backend Files**: + * `packages/api/src/modules/stores/stores.repository.ts` + * `packages/api/src/modules/stores/stores.service.ts` + * `packages/api/src/modules/stores/stores.routes.ts` + * `packages/api/src/modules/medicine-products/medicine-products.repository.ts` + * `packages/api/src/modules/medicine-products/medicine-products.service.ts` + * `packages/api/src/modules/medicine-products/medicine-products.routes.ts` + * `packages/api/src/modules/medicine-prices/medicine-prices.repository.ts` + * `packages/api/src/modules/medicine-prices/medicine-prices.service.ts` + * `packages/api/src/modules/medicine-prices/medicine-prices.routes.ts` +* **Key Focus**: + * Eradicate `any` structures used in price conversions and store tags. +* **Verification Runner**: + ```bash + cd packages/api + npx eslint src/modules/stores src/modules/medicine-products src/modules/medicine-prices --fix + npx vitest run tests/modules/stores tests/modules/medicine-products tests/modules/medicine-prices + ``` + +--- + +### ⏰ Batch 4: Regimens, Refills & Alert Scheduler +Tracks routines, alert thresholds, and automated refilling triggers. + +* **Target Backend Files**: + * `packages/api/src/modules/regimens/regimens.repository.ts` + * `packages/api/src/modules/regimens/regimens.service.ts` + * `packages/api/src/modules/regimens/regimens.routes.ts` + * `packages/api/src/modules/refills/refills.repository.ts` + * `packages/api/src/modules/refills/refills.service.ts` + * `packages/api/src/modules/refills/refills.routes.ts` + * `packages/api/src/modules/organizer/organizer.repository.ts` + * `packages/api/src/modules/organizer/organizer.service.ts` + * `packages/api/src/modules/organizer/organizer.routes.ts` +* **Key Focus**: + * Explicitly handle routine status triggers and scheduling date windows. +* **Verification Runner**: + ```bash + cd packages/api + npx eslint src/modules/regimens src/modules/refills src/modules/organizer --fix + npx vitest run tests/modules/regimens tests/modules/refills tests/modules/organizer + ``` + +--- + +### 🧠 Batch 5: Nutrition, LLM Providers & Health +Enforces guidelines on target metrics, no-op mock LLM interfaces, and health endpoints. + +* **Target Backend Files**: + * `packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts` + * `packages/api/src/modules/nutrition-targets/nutrition-target.service.ts` + * `packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts` + * `packages/api/src/modules/llm/no-op-llm.provider.ts` + * `packages/api/src/modules/health/health.routes.ts` +* **Key Focus**: + * Annotate mock responses in the LLM provider class explicitly. +* **Verification Runner**: + ```bash + cd packages/api + npx eslint src/modules/nutrition-targets src/modules/llm src/modules/health --fix + npx vitest run tests/modules/nutrition-targets tests/modules/llm tests/modules/health + ``` + +--- + +### 💻 Batch 6: Frontend API Client Services +Brings complete TypeScript strictness to our web fetch and Axios mock endpoints. + +* **Target Frontend Files**: + * `packages/web/src/services/api-client.ts` + * `packages/web/src/services/cabinet.ts` + * `packages/web/src/services/cabinet-events.ts` + * `packages/web/src/services/households.ts` + * `packages/web/src/services/medicines.ts` + * `packages/web/src/services/medicine-prices.ts` + * `packages/web/src/services/nutrition-targets.ts` + * `packages/web/src/services/organizer.ts` + * `packages/web/src/services/refills.ts` + * `packages/web/src/services/regimens.ts` + * `packages/web/src/services/shopping-lists.ts` + * `packages/web/src/services/stores.ts` +* **Key Focus**: + * Annotate Axios return response structures explicitly as `Promise>` or type mappers. + * Eliminate implicit parameters. +* **Verification Runner**: + ```bash + cd packages/web + npx eslint src/services --fix + npx vitest run tests/services + ``` diff --git a/docs/style-guidelines.md b/docs/style-guidelines.md new file mode 100644 index 0000000..68fb3bc --- /dev/null +++ b/docs/style-guidelines.md @@ -0,0 +1,122 @@ +# MeshiTrack Coding Style & Engineering Guidelines + +This document establishes the official coding standards, architectural paradigms, and TypeScript guidelines for the MeshiTrack monorepo. These guidelines are designed to enforce clean, testable, object-oriented codebases with pristine type-safety under strict quality gates. + +--- + +## 🏛️ 1. Encapsulation & Class Member Accessibility + +To ensure clear visibility boundaries, clean interfaces, and easy maintainability: + +1. **Mandatory Accessibility Modifiers**: Every field, constructor, and method in a class must explicitly declare its accessibility modifier (`public`, `protected`, or `private`). +2. **Mandate Explicit Fields**: Classes that participate in dependency injection (e.g. Services, Repositories, Providers) must explicitly declare their properties at the class level rather than relying on TypeScript constructor parameter properties. +3. **Class Member Ordering**: Group class members in a predictable sequence: + * Static Fields (Public -> Protected -> Private) + * Instance Fields (Public -> Protected -> Private) + * Constructor + * Public Methods + * Protected Methods + * Private Methods + +### Example: +```typescript +export class ShoppingListsService { + // 1. Explicit, readonly instance properties + private readonly shoppingListsRepository: ShoppingListsRepository; + + // 2. Explicit constructor receiving DI payload + public constructor({ shoppingListsRepository }: Deps) { + this.shoppingListsRepository = shoppingListsRepository; + } + + // 3. Public class methods + public async list(householdId: string): Promise { + return this.shoppingListsRepository.list(householdId); + } +} +``` + +--- + +## 🛡️ 2. Boundary Types & Return Types + +To prevent type-drift across software boundaries: + +1. **Explicit Return Types on Boundary Functions**: All public methods of class instances (Services, Repositories, Providers) and all exported module functions must explicitly declare their return types. +2. **Relegate Inference to Callbacks**: Local, inline variables and anonymous arrow callbacks (e.g. within array map/filter functions) may utilize automatic type inference for conciseness. + +### Example: +```typescript +// 🟢 Good: Explicitly defined return type on the boundary method +public async list(householdId: string): Promise { + const lists = await this.shoppingListsRepository.list(householdId); + return lists.filter(item => item.status === 'active'); // Inline inference is okay +} +``` + +--- + +## 🚫 3. Eradicating `any` & Enforcing Type-Safety + +TypeScript's `any` type compromises the integrity of our codebase and makes refactoring fragile. + +1. **No Explicit `any`**: The use of explicit `any` is strictly prohibited throughout the codebase, including in test files. +2. **Restrict Unsafe Operations**: We enforce strict type-checked rules to prevent the propagation of `any`: + * Assigning an `any` to a variable/property is blocked (`@typescript-eslint/no-unsafe-assignment`). + * Reading a property off of an `any` is blocked (`@typescript-eslint/no-unsafe-member-access`). + * Invoking functions typed as `any` is blocked (`@typescript-eslint/no-unsafe-call`). + * Returning an `any` from a function is blocked (`@typescript-eslint/no-unsafe-return`). +3. **Prefer `unknown` for Dynamic Inputs**: Any unchecked external input (e.g., API payloads, database responses, dynamic JSON files) must be typed as `unknown` and validated at the boundary using **Zod schemas** or type guards. +4. **Type-Safe Mocking**: Instead of casting mocks with `as any`, use highly targeted partial types or precise mocks. + +--- + +## 🏷️ 4. Naming Conventions & File Structures + +Uniform naming conventions prevent conflicts and reduce cognitive load: + +1. **Directories**: Lowercase `kebab-case` (e.g., `shopping-lists/`). +2. **Files**: `PascalCase` matching the primary class or component exported in the file (e.g., `ShoppingListsService.ts`), ensuring class name and file name are aligned. +3. **Identifiers**: + * **PascalCase**: Classes, Interfaces, Type Aliases, Enums. + * **camelCase**: Properties, Methods, Variables, Parameters, Functions. + * **UPPER_CASE**: Top-level static read-only constants. +4. **Prohibit Hungarian Notation**: Do not prefix interfaces with `I` (e.g. write `ShoppingListsRepository`, NOT `IShoppingListsRepository`). + +--- + +## 🛑 5. Structured Domain Exceptions + +Exception mapping must be uniform, semantic, and highly testable: + +1. **Prohibit Generic `Error` Throwing**: The service and business layers must never throw generic `new Error('message')` for operational domain failures. +2. **Domain-Specific Exceptions**: Throw subclasses of `AppError` (e.g. `NotFoundError`, `ConflictError`, `BadRequestError`) containing semantic properties like status codes and optional structured sub-details. +3. **Prevent Stack Leakage**: Our custom exceptions map directly to HTTP responses in central middleware, ensuring internal database schemas or runtime details are never leaked to external clients. + +### Example: +```typescript +// 🟢 Good: Highly testable, structured domain-level exception +if (!updated) { + throw new NotFoundError('Shopping list not found'); +} +``` + +--- + +## 🧱 6. OOP vs. Functional Boundaries + +We keep class-based OOP strictly segregated from pure stateless helpers: + +1. **Stateful Layers -> Classes**: Services, Repositories, and Providers must be class-based to properly encapsulate state, DI, and persistence hooks. +2. **Stateless Operations -> Standalone Pure Functions**: Math utilities, date/string formatters, and mapping helpers must be written as standalone, exported functions in pure modules. +3. **No Utility Classes**: Do not write static utility classes (classes composed solely of static helper methods). Use direct ES module exports. + +--- + +## ⚡ 7. Asynchronous Programming Integrity + +To prevent background crashes, memory leaks, and silent failures: + +1. **Mandate `async/await`**: Use `async/await` exclusively instead of Promise chains (`.then()`, `.catch()`). +2. **Block Floating Promises**: Every Promise must be `await`ed or returned. +3. **Explicit Voiding for Intentional Background Tasks**: If a task must run in the background (fire-and-forget), it must be explicitly voided (e.g. `void this.trackEvent()`) and handle its exceptions internally to prevent uncaught runtime rejections. diff --git a/packages/api/eslint.config.js b/packages/api/eslint.config.js index e15922f..982bd01 100644 --- a/packages/api/eslint.config.js +++ b/packages/api/eslint.config.js @@ -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', }, }, diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index e92d4dd..6956c89 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -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'; diff --git a/packages/api/src/modules/cabinet-events/cabinet-events.repository.ts b/packages/api/src/modules/cabinet-events/cabinet-events.repository.ts index cf658c0..d713485 100644 --- a/packages/api/src/modules/cabinet-events/cabinet-events.repository.ts +++ b/packages/api/src/modules/cabinet-events/cabinet-events.repository.ts @@ -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 { 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 { + 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 = { 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 = { 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 = { 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) => 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) => ({ - 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) => ({ - 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> { if (medicineIds.length === 0) return new Map(); - 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(); 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; diff --git a/packages/api/src/modules/cabinet-events/cabinet-events.routes.ts b/packages/api/src/modules/cabinet-events/cabinet-events.routes.ts index 3b2f676..6ad02b2 100644 --- a/packages/api/src/modules/cabinet-events/cabinet-events.routes.ts +++ b/packages/api/src/modules/cabinet-events/cabinet-events.routes.ts @@ -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); }, }); diff --git a/packages/api/src/modules/cabinet-events/cabinet-events.service.ts b/packages/api/src/modules/cabinet-events/cabinet-events.service.ts index 76c76eb..e07e9e1 100644 --- a/packages/api/src/modules/cabinet-events/cabinet-events.service.ts +++ b/packages/api/src/modules/cabinet-events/cabinet-events.service.ts @@ -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 { return this.cabinetEventsRepository.create(data); } - public async logEvents(events: CreateCabinetEventData[]) { + public async logEvents(events: CreateCabinetEventData[]): Promise { 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> { return this.cabinetEventsRepository.getAvgUnitPriceByMedicine(householdId, medicineIds); } } diff --git a/packages/api/src/modules/cabinet/cabinet.repository.ts b/packages/api/src/modules/cabinet/cabinet.repository.ts index 71ffc0c..e9f790f 100644 --- a/packages/api/src/modules/cabinet/cabinet.repository.ts +++ b/packages/api/src/modules/cabinet/cabinet.repository.ts @@ -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 = { 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 { + 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>> { + 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>; } public async create( @@ -83,21 +97,30 @@ export class CabinetRepository { }, householdId: string, createdBy: string, - ) { + ): Promise { 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 { + 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 { 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 { 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 { 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 { + 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 { + 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 { + 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[]; } } diff --git a/packages/api/src/modules/cabinet/cabinet.routes.ts b/packages/api/src/modules/cabinet/cabinet.routes.ts index d5eb44e..e706fcb 100644 --- a/packages/api/src/modules/cabinet/cabinet.routes.ts +++ b/packages/api/src/modules/cabinet/cabinet.routes.ts @@ -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 { - 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)); }, diff --git a/packages/api/src/modules/cabinet/cabinet.service.ts b/packages/api/src/modules/cabinet/cabinet.service.ts index e4c2fe3..defb38b 100644 --- a/packages/api/src/modules/cabinet/cabinet.service.ts +++ b/packages/api/src/modules/cabinet/cabinet.service.ts @@ -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 { 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 { const results = await this.cabinetRepository.getAggregateSummary(householdId); return results.map((r: Record) => ({ 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 { 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 { 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 { 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 { 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 { 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 { const existing = await this.getById(id, householdId); if (existing.quantity === 0) { throw new BadRequestError('Cannot discard an item with zero quantity'); diff --git a/packages/api/src/modules/households/households.repository.ts b/packages/api/src/modules/households/households.repository.ts index 0f2df3a..3fa317b 100644 --- a/packages/api/src/modules/households/households.repository.ts +++ b/packages/api/src/modules/households/households.repository.ts @@ -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 { + 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 { + 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 { 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 { + 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 { + 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 { + const doc = await HouseholdModel.findByIdAndUpdate( id, { $set: { inviteCode } }, { new: true, lean: true }, ).exec(); + return doc as unknown as HouseholdDocument | null; } } diff --git a/packages/api/src/modules/households/households.routes.ts b/packages/api/src/modules/households/households.routes.ts index 71eb304..e1615bb 100644 --- a/packages/api/src/modules/households/households.routes.ts +++ b/packages/api/src/modules/households/households.routes.ts @@ -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 { - 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)); }, }); diff --git a/packages/api/src/modules/households/households.service.ts b/packages/api/src/modules/households/households.service.ts index fc343f4..267040d 100644 --- a/packages/api/src/modules/households/households.service.ts +++ b/packages/api/src/modules/households/households.service.ts @@ -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 { 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 { 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 { 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 { 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 { 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(); } } } diff --git a/packages/api/src/modules/llm/llm-provider.interface.ts b/packages/api/src/modules/llm/llm-provider.interface.ts index da5c85a..af5f003 100644 --- a/packages/api/src/modules/llm/llm-provider.interface.ts +++ b/packages/api/src/modules/llm/llm-provider.interface.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { NutritionInfo } from '@meshitrack/shared'; export interface NutritionExtractionResult { diff --git a/packages/api/src/modules/llm/no-op-llm.provider.ts b/packages/api/src/modules/llm/no-op-llm.provider.ts index a48518f..1550a54 100644 --- a/packages/api/src/modules/llm/no-op-llm.provider.ts +++ b/packages/api/src/modules/llm/no-op-llm.provider.ts @@ -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.`); + } } diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts index 8e19a71..02cf918 100644 --- a/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts +++ b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts @@ -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 { 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 = { 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 { const filter: Record = { 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[], }; } } diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts index 028daae..d59a1ec 100644 --- a/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts +++ b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts @@ -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); }, }); diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.service.ts b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts index f1378db..d44dc3c 100644 --- a/packages/api/src/modules/medicine-prices/medicine-prices.service.ts +++ b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts @@ -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 { 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); } } diff --git a/packages/api/src/modules/medicine-products/medicine-products.repository.ts b/packages/api/src/modules/medicine-products/medicine-products.repository.ts index 69e40f2..dcaf4f0 100644 --- a/packages/api/src/modules/medicine-products/medicine-products.repository.ts +++ b/packages/api/src/modules/medicine-products/medicine-products.repository.ts @@ -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 = { 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 { + 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 { 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 { + 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 { 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 { + 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; } } diff --git a/packages/api/src/modules/medicine-products/medicine-products.routes.ts b/packages/api/src/modules/medicine-products/medicine-products.routes.ts index 60266ab..021e9b2 100644 --- a/packages/api/src/modules/medicine-products/medicine-products.routes.ts +++ b/packages/api/src/modules/medicine-products/medicine-products.routes.ts @@ -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 { - 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(); }, }); diff --git a/packages/api/src/modules/medicine-products/medicine-products.service.ts b/packages/api/src/modules/medicine-products/medicine-products.service.ts index 1f165d3..0669dec 100644 --- a/packages/api/src/modules/medicine-products/medicine-products.service.ts +++ b/packages/api/src/modules/medicine-products/medicine-products.service.ts @@ -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 { 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 { 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 { 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 { await this.getById(id, householdId); const deleted = await this.medicineProductsRepository.softDelete(id, householdId); if (!deleted) throw new NotFoundError('Medicine product not found'); diff --git a/packages/api/src/modules/medicines/medicines.repository.ts b/packages/api/src/modules/medicines/medicines.repository.ts index f11da29..1dcc767 100644 --- a/packages/api/src/modules/medicines/medicines.repository.ts +++ b/packages/api/src/modules/medicines/medicines.repository.ts @@ -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 = { 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 { + 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 { const filter: Record = { 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 { 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 { + 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 { + 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; } } diff --git a/packages/api/src/modules/medicines/medicines.routes.ts b/packages/api/src/modules/medicines/medicines.routes.ts index 6ba123e..72174fe 100644 --- a/packages/api/src/modules/medicines/medicines.routes.ts +++ b/packages/api/src/modules/medicines/medicines.routes.ts @@ -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 { - 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(); }, }); diff --git a/packages/api/src/modules/medicines/medicines.service.ts b/packages/api/src/modules/medicines/medicines.service.ts index 4947dff..f1b126a 100644 --- a/packages/api/src/modules/medicines/medicines.service.ts +++ b/packages/api/src/modules/medicines/medicines.service.ts @@ -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 { 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 { 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 { 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 { await this.getById(id, householdId); const productCount = await this.medicineProductsRepository.countByMedicineId(id); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts index f72604b..f72c58b 100644 --- a/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts @@ -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 { + 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 { + const docs = await NutritionTargetModel.find({ userId, householdId }) + .sort({ createdAt: -1 }) + .lean() + .exec(); + return docs as unknown as NutritionTargetDocument[]; } - public async create(data: Record) { + public async create(data: CreateNutritionTargetData): Promise { 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 { 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, - ) { - return NutritionTargetModel.findOneAndUpdate( + data: UpdateNutritionTargetData, + ): Promise { + const doc = await NutritionTargetModel.findOneAndUpdate( { _id: id, userId, householdId }, { $set: data }, { new: true, lean: true }, ).exec(); + return doc as unknown as NutritionTargetDocument | null; } } diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts index c11d441..2712fe3 100644 --- a/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts @@ -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 { - 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); }, }); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts index a88f3d8..8eae26a 100644 --- a/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts @@ -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 { return this.nutritionTargetRepository.findByUser(userId, householdId); } - public async getAllByUser(userId: string, householdId: string) { + public async getAllByUser( + userId: string, + householdId: string, + ): Promise { 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 { // Maintain invariant: only one target is active per user per household if (input.isActive !== false) { await this.nutritionTargetRepository.deactivateAllForUser(userId, householdId); diff --git a/packages/api/src/modules/organizer/organizer.repository.ts b/packages/api/src/modules/organizer/organizer.repository.ts index 299769c..78ee70e 100644 --- a/packages/api/src/modules/organizer/organizer.repository.ts +++ b/packages/api/src/modules/organizer/organizer.repository.ts @@ -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 { const filter: Record = { 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 { + 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 { 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 { + const doc = await OrganizerFillModel.findOneAndUpdate( { _id: id, householdId }, { $set: { status } }, { new: true, lean: true }, ).exec(); + return doc as unknown as OrganizerFillDocument | null; } } diff --git a/packages/api/src/modules/organizer/organizer.routes.ts b/packages/api/src/modules/organizer/organizer.routes.ts index b891211..8d91447 100644 --- a/packages/api/src/modules/organizer/organizer.routes.ts +++ b/packages/api/src/modules/organizer/organizer.routes.ts @@ -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)); }, }); diff --git a/packages/api/src/modules/organizer/organizer.service.ts b/packages/api/src/modules/organizer/organizer.service.ts index af28fde..3f63b30 100644 --- a/packages/api/src/modules/organizer/organizer.service.ts +++ b/packages/api/src/modules/organizer/organizer.service.ts @@ -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 { return this.organizerRepository.findByHousehold(householdId, userId, query); } - public async getFillById(id: string, householdId: string) { + public async getFillById(id: string, householdId: string): Promise { 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 { 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 { 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 { 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(); } } } diff --git a/packages/api/src/modules/refills/refills.repository.ts b/packages/api/src/modules/refills/refills.repository.ts index fed5191..d50d41d 100644 --- a/packages/api/src/modules/refills/refills.repository.ts +++ b/packages/api/src/modules/refills/refills.repository.ts @@ -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 { 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 = { 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 { + 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 { const updateSet: Record = {}; 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 { const updateSet: Record = {}; 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 { + 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; } } diff --git a/packages/api/src/modules/refills/refills.routes.ts b/packages/api/src/modules/refills/refills.routes.ts index 986585f..ffb5cab 100644 --- a/packages/api/src/modules/refills/refills.routes.ts +++ b/packages/api/src/modules/refills/refills.routes.ts @@ -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(), })), })), }); diff --git a/packages/api/src/modules/refills/refills.service.ts b/packages/api/src/modules/refills/refills.service.ts index 54d8d4f..1b20fca 100644 --- a/packages/api/src/modules/refills/refills.service.ts +++ b/packages/api/src/modules/refills/refills.service.ts @@ -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 { 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(); - 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 { 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 { 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 { 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 { 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 = [ diff --git a/packages/api/src/modules/regimens/regimens.repository.ts b/packages/api/src/modules/regimens/regimens.repository.ts index a86e9e9..0de4ad7 100644 --- a/packages/api/src/modules/regimens/regimens.repository.ts +++ b/packages/api/src/modules/regimens/regimens.repository.ts @@ -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 = { 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 { + 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 { + 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 { 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 { + 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 { + 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; } } diff --git a/packages/api/src/modules/regimens/regimens.routes.ts b/packages/api/src/modules/regimens/regimens.routes.ts index 4d927d5..2eef6cc 100644 --- a/packages/api/src/modules/regimens/regimens.routes.ts +++ b/packages/api/src/modules/regimens/regimens.routes.ts @@ -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(); }, }); diff --git a/packages/api/src/modules/regimens/regimens.service.ts b/packages/api/src/modules/regimens/regimens.service.ts index 638a42c..fc8f5c3 100644 --- a/packages/api/src/modules/regimens/regimens.service.ts +++ b/packages/api/src/modules/regimens/regimens.service.ts @@ -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 { 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 { 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 { await this.getById(id, householdId, userId); - const updateData: Record = {}; - 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 { 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 { return this.regimensRepository.findActiveByUser(householdId, userId); } - public async calculateBurnRates(householdId: string, userId: string) { + public async calculateBurnRates(householdId: string, userId: string): Promise { 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(); 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 { + const result: RegimenMedicationData[] = []; for (const med of medications) { const medicine = await this.medicinesRepository.findById(med.medicineId, householdId); if (!medicine) { diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts index 3e5c441..9a96062 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts @@ -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, + ): Promise { + const list = new ShoppingListModel(data); + const saved = await list.save(); + return this.sortItems(saved.toObject() as ShoppingListDocument); + } + + public async list(householdId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise { + const setUpdates: Record = {}; + 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 { + 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(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, - ) { - const setUpdates: Record = {}; - 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); - } } diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts index c7e2996..b92466f 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts @@ -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>(); /* 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 & { 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 */ }, { diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts index 8cf86cf..0fa6bec 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts @@ -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 { return this.shoppingListsRepository.list(householdId); } - public async getById(id: string, householdId: string) { + public async getById(id: string, householdId: string): Promise { 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 { 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 { 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 { 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 { const updates: Partial = { ...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 { const updated = await this.shoppingListsRepository.removeItem(id, householdId, itemId); if (!updated) throw new NotFoundError('Shopping list not found'); return updated; } - } diff --git a/packages/api/src/modules/stores/stores.repository.ts b/packages/api/src/modules/stores/stores.repository.ts index a94368d..9d4a1ff 100644 --- a/packages/api/src/modules/stores/stores.repository.ts +++ b/packages/api/src/modules/stores/stores.repository.ts @@ -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 = { 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 { + 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 { 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 { + 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 { + const doc = await StoreModel.findOneAndUpdate( { _id: id, householdId }, { $set: { isActive: false } }, { new: true, lean: true }, ).exec(); + return doc as unknown as StoreDocument | null; } } diff --git a/packages/api/src/modules/stores/stores.routes.ts b/packages/api/src/modules/stores/stores.routes.ts index e0c5065..816a126 100644 --- a/packages/api/src/modules/stores/stores.routes.ts +++ b/packages/api/src/modules/stores/stores.routes.ts @@ -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)); }, }); diff --git a/packages/api/src/modules/stores/stores.service.ts b/packages/api/src/modules/stores/stores.service.ts index 9bf42b3..9c1b99a 100644 --- a/packages/api/src/modules/stores/stores.service.ts +++ b/packages/api/src/modules/stores/stores.service.ts @@ -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 { 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 { 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 { 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 { await this.getById(id, householdId); const updated = await this.storesRepository.deactivate(id, householdId); if (!updated) throw new NotFoundError('Store not found'); diff --git a/packages/api/src/modules/users/users.repository.ts b/packages/api/src/modules/users/users.repository.ts index 138fde2..c168bcf 100644 --- a/packages/api/src/modules/users/users.repository.ts +++ b/packages/api/src/modules/users/users.repository.ts @@ -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 { + 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 { + 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 { 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 { + 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 { + 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; } } diff --git a/packages/api/src/modules/users/users.routes.ts b/packages/api/src/modules/users/users.routes.ts index 1fdf820..428111f 100644 --- a/packages/api/src/modules/users/users.routes.ts +++ b/packages/api/src/modules/users/users.routes.ts @@ -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, }; } diff --git a/packages/api/src/modules/users/users.service.ts b/packages/api/src/modules/users/users.service.ts index 269703e..9a0c8a7 100644 --- a/packages/api/src/modules/users/users.service.ts +++ b/packages/api/src/modules/users/users.service.ts @@ -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 { return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName); } - public async getProfile(keycloakId: string) { + public async getProfile(keycloakId: string): Promise { const user = await this.usersRepository.findByKeycloakId(keycloakId); if (!user) { throw new NotFoundError('User not found'); diff --git a/packages/api/src/scripts/migrate-dosage-units.ts b/packages/api/src/scripts/migrate-dosage-units.ts index 19e85b0..766b695 100644 --- a/packages/api/src/scripts/migrate-dosage-units.ts +++ b/packages/api/src/scripts/migrate-dosage-units.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ /** * Migration: normalize legacy dosage unit values to current DosageUnit enum. * diff --git a/packages/api/src/scripts/seed.ts b/packages/api/src/scripts/seed.ts index c1f36ab..a456c70 100644 --- a/packages/api/src/scripts/seed.ts +++ b/packages/api/src/scripts/seed.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ import mongoose from 'mongoose'; // Fixed ID shared with the Keycloak test users' householdIds attribute. diff --git a/packages/api/tests/modules/refills/refills.service.test.ts b/packages/api/tests/modules/refills/refills.service.test.ts index 1f925cf..d573b3f 100644 --- a/packages/api/tests/modules/refills/refills.service.test.ts +++ b/packages/api/tests/modules/refills/refills.service.test.ts @@ -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); diff --git a/packages/web/eslint.config.js b/packages/web/eslint.config.js index c52f22b..020045a 100644 --- a/packages/web/eslint.config.js +++ b/packages/web/eslint.config.js @@ -47,6 +47,102 @@ 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, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: 'import', + format: ['camelCase', 'PascalCase'], + }, + ], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksVoidReturn: { + attributes: false, + }, + }, + ], + }, + }, + { + // React components are functions returning JSX and don't need explicit return types + files: ['**/*.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, + { + // Relax some rules in test files + files: ['**/*.test.ts', '**/*.test.tsx'], + rules: { + '@typescript-eslint/explicit-member-accessibility': 'off', }, }, prettierRecommended, diff --git a/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx index ba370a3..28fdb00 100644 --- a/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx +++ b/packages/web/src/app/(dashboard)/shopping-lists/[id]/page.tsx @@ -1,11 +1,17 @@ 'use client'; import { useState, useEffect, useCallback, useMemo } from 'react'; -import useSWR, { mutate } from 'swr'; +import useSWR from 'swr'; import { useApi } from '@/lib/useApi'; import { useParams, useRouter } from 'next/navigation'; import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { Card, Button, Icon, Pill } from '@/components/ui'; +import type { + BasketStoreComparisonResponse, + ShoppingItem, + ShoppingListResponse, +} from '@/services/shopping-lists'; +import { type ServingUnit } from '@meshitrack/shared'; import { getShoppingList, addShoppingItem, @@ -15,6 +21,7 @@ import { getBasketStoreComparison, updateShoppingList, } from '@/services/shopping-lists'; +import type { ProductResponse } from '@/services/products'; import { listProducts } from '@/services/products'; import { useShoppingListSync } from '@/lib/useShoppingListSync'; @@ -24,9 +31,11 @@ export default function ShoppingListDetailsPage() { const router = useRouter(); const [error, setError] = useState(''); - const [storeOptions, setStoreOptions] = useState([]); - const [isStoreLoading, setIsStoreLoading] = useState(false); - const [products, setProducts] = useState([]); + const [storeOptions, setStoreOptions] = useState< + BasketStoreComparisonResponse['singleStoreOptions'] + >([]); + const [_isStoreLoading, setIsStoreLoading] = useState(false); + const [products, setProducts] = useState([]); const [selectedProductId, setSelectedProductId] = useState(''); const [customItemName, setCustomItemName] = useState(''); const [qty, setQty] = useState(1); @@ -36,10 +45,12 @@ export default function ShoppingListDetailsPage() { // Load core list context const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null; - const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR( - swrKey, - () => getShoppingList(householdId!, listId) - ); + const { + data: list, + mutate: mutateList, + isLoading: listLoading, + error: listError, + } = useSWR(swrKey, () => getShoppingList(householdId!, listId)); useEffect(() => { if (listError) setError(listError.message || 'Shopping list not found'); @@ -61,31 +72,42 @@ export default function ShoppingListDetailsPage() { // Pre-load household products for predictive inputs useEffect(() => { if (!householdId) return; - listProducts(householdId).then(res => setProducts(res.data)).catch(console.error); + listProducts(householdId) + .then((res) => setProducts(res.data)) + .catch(console.error); }, [householdId]); // Handle WS Remote Event Broadcasts - const handleRemoteSync = useCallback((msg: any) => { - console.log('🔔 Remote state delta payload:', msg); - mutateList(); // Revalidate with server on remote changes - }, [mutateList]); + const handleRemoteSync = useCallback( + (msg: unknown) => { + console.log('🔔 Remote state delta payload:', msg); + void mutateList(); // Revalidate with server on remote changes + }, + [mutateList], + ); // Inject Real-Time Hooks const { isConnected, toggleItemCheck } = useShoppingListSync( householdId || '', listId, - handleRemoteSync + handleRemoteSync, ); // 1. Perform Live Interactivity (Toggle Checks) const handleToggleCheck = async (itemId: string, currentChecked: boolean) => { const nextChecked = !currentChecked; - + // Optimistic Client Update - mutateList(async (prev: any) => ({ - ...prev, - items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it) - }), { revalidate: false }); + void mutateList( + async (prev) => { + if (!prev) return undefined; + return { + ...prev, + items: prev.items.map((it) => (it.id === itemId ? { ...it, checked: nextChecked } : it)), + }; + }, + { revalidate: false }, + ); // Emit to WS Channel toggleItemCheck(itemId, nextChecked); @@ -93,10 +115,10 @@ export default function ShoppingListDetailsPage() { // Persist standard Rest fallback ensuring safety try { await updateShoppingItem(householdId!, listId, itemId, { checked: nextChecked }); - mutateList(); + void mutateList(); } catch (err) { console.error('Persistent toggle sync fail', err); - mutateList(); + void mutateList(); } }; @@ -109,18 +131,18 @@ export default function ShoppingListDetailsPage() { productId: selectedProductId || undefined, customName: !selectedProductId ? customItemName.trim() : undefined, quantity: qty, - unit: unit as any, + unit: unit as ServingUnit, notes: notes.trim() || undefined, }); - - mutateList(updated); + + void mutateList(updated); // Clear inputs setSelectedProductId(''); setCustomItemName(''); setQty(1); setNotes(''); - } catch (err: any) { - alert(err.message); + } catch (err) { + alert(err instanceof Error ? err.message : 'Unknown error'); } finally { setIsAdding(false); } @@ -128,47 +150,60 @@ export default function ShoppingListDetailsPage() { const handleDeleteItem = async (itemId: string) => { // Optimistic delete - mutateList(async (prev: any) => ({ - ...prev, - items: prev.items.filter((it: any) => it.id !== itemId) - }), { revalidate: false }); + void mutateList( + async (prev) => { + if (!prev) return undefined; + return { + ...prev, + items: prev.items.filter((it) => it.id !== itemId), + }; + }, + { revalidate: false }, + ); try { const updated = await removeShoppingItem(householdId!, listId, itemId); - mutateList(updated); - } catch (err: any) { - window.alert(err.message); - mutateList(); + void mutateList(updated); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + window.alert(message); + void mutateList(); } }; // 3. Execute Final Checkout / Pantry Sync const handleSyncToPantry = async () => { - const readyItems = list!.items.filter((i: any) => i.checked && !i.addedToPantry); + const readyItems = list!.items.filter((i) => i.checked && !i.addedToPantry); - if (!window.confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return; + if ( + !window.confirm( + `Import ${readyItems.length} checked ingredients directly into active Pantry stock?`, + ) + ) + return; try { const res = await syncToPantry(householdId!, listId); window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`); - + // Mark list as completed automatically if all are done - const allChecked = list!.items.every((i: any) => i.checked || i.addedToPantry); + const allChecked = list!.items.every((i) => i.checked || i.addedToPantry); if (allChecked) { - await updateShoppingList(householdId!, listId, { status: 'completed' as any }); + await updateShoppingList(householdId!, listId, { status: 'completed' }); } - mutateList(); - } catch (err: any) { - window.alert('Migration sync error: ' + err.message); + void mutateList(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + window.alert('Migration sync error: ' + message); } }; // Collate items categorized for satisfying view const categorizedItems = useMemo(() => { if (!list) return {}; - const groups: Record = {}; - list.items.forEach((it: any) => { + const groups: Record = {}; + list.items.forEach((it) => { const cat = it.category || 'Other / Misc'; if (!groups[cat]) groups[cat] = []; groups[cat].push(it); @@ -176,11 +211,12 @@ export default function ShoppingListDetailsPage() { return groups; }, [list]); - if (isAuthLoading || listLoading) return
Hydrating session checklist...
; - if (error || !list) return
Error: {error}
; + if (isAuthLoading || listLoading) + return
Hydrating session checklist...
; + if (error || !list) + return
Error: {error}
; - const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length; - const checkedCount = list.items.filter((i: any) => i.checked).length; + const itemsPendingSync = list.items.filter((i) => i.checked && !i.addedToPantry).length; const totalCount = list.items.length; return ( @@ -190,64 +226,137 @@ export default function ShoppingListDetailsPage() { subtitle="Perform live checkout check-offs synchronously across multiple household devices." /> -
+
{list.status}
-
+
{isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'}
- {/* Top Action Strip */} -
+
- {itemsPendingSync > 0 && ( - )}
{/* Main Workspace Split Grid */} -
- +
{/* Left: Categorized Checklist Grid */}
{totalCount === 0 ? ( - - -

Checklist is Empty

-

Add missing ingredients using the pane on the right.

+ + +

+ Checklist is Empty +

+

+ Add missing ingredients using the pane on the right. +

) : (
- {Object.entries(categorizedItems).map(([cat, items]: [string, any]) => ( + {Object.entries(categorizedItems).map(([cat, items]) => (
-

+

{cat}

- {items.map((it: any) => ( + {items.map((it) => (
{/* Checkbox circle */}
-
- {it.productId ? products.find(p => p._id === it.productId)?.name || 'Ingredient Loading...' : it.customName} +
+ {it.productId + ? products.find((p) => p._id === it.productId)?.name || + 'Ingredient Loading...' + : it.customName}
-
- Qty: {it.quantity} {it.unit} +
+ + Qty: {it.quantity} {it.unit} + {it.notes && • Note: {it.notes}}
{/* Estimated Price Tag */} {it.estimatedPrice && !it.checked && ( -
+
~${it.estimatedPrice.toFixed(2)}
)} @@ -293,9 +440,18 @@ export default function ShoppingListDetailsPage() { )} @@ -310,13 +466,26 @@ export default function ShoppingListDetailsPage() { {/* Right Side Panel: Context Inputs */}
- {/* Pane A: Add New Item */} -

+

Add Grocery Item

-
+ { + void handleAddItem(e); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 14 }} + >
@@ -340,7 +513,7 @@ export default function ShoppingListDetailsPage() { required placeholder="e.g., Generic Flour" value={customItemName} - onChange={e => setCustomItemName(e.target.value)} + onChange={(e) => setCustomItemName(e.target.value)} style={inputStyle} />
@@ -355,13 +528,17 @@ export default function ShoppingListDetailsPage() { min="0.01" step="any" value={qty} - onChange={e => setQty(parseFloat(e.target.value) || 0)} + onChange={(e) => setQty(parseFloat(e.target.value) || 0)} style={inputStyle} />
- setUnit(e.target.value)} + style={selectStyle} + > @@ -376,7 +553,7 @@ export default function ShoppingListDetailsPage() { type="text" placeholder="Brand preference, etc." value={notes} - onChange={e => setNotes(e.target.value)} + onChange={(e) => setNotes(e.target.value)} style={inputStyle} />
@@ -390,19 +567,67 @@ export default function ShoppingListDetailsPage() { {/* Pane B: Real-Time Store Optimizer */} {storeOptions.length > 0 && ( -

- Lowest Store Basket Rank +

+ Lowest Store + Basket Rank

{storeOptions.map((opt, idx) => ( -
-
- {opt.storeName} - ${opt.estimatedTotal.toFixed(2)} +
+
+ + {opt.storeName} + + + ${opt.estimatedTotal.toFixed(2)} +
-
- Covered: {opt.itemsCovered}/{totalCount} products - {idx === 0 && Cheapest Single Trip} +
+ + Covered: {opt.itemsCovered}/{totalCount} products + + {idx === 0 && ( + + Cheapest Single Trip + + )}
))} @@ -417,18 +642,34 @@ export default function ShoppingListDetailsPage() { } const labelStyle: React.CSSProperties = { - display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--ink-muted)', - textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, + display: 'block', + fontSize: 11, + fontWeight: 600, + color: 'var(--ink-muted)', + textTransform: 'uppercase', + letterSpacing: '0.03em', + marginBottom: 6, }; const inputStyle: React.CSSProperties = { - width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', - background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', - fontSize: 13, outline: 'none', + width: '100%', + padding: '8px 12px', + borderRadius: 'var(--r-md)', + background: 'var(--bg)', + border: '1px solid var(--border)', + color: 'var(--ink)', + fontSize: 13, + outline: 'none', }; const selectStyle: React.CSSProperties = { - width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', - background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', - fontSize: 13, outline: 'none', height: 36, + width: '100%', + padding: '8px 12px', + borderRadius: 'var(--r-md)', + background: 'var(--bg)', + border: '1px solid var(--border)', + color: 'var(--ink)', + fontSize: 13, + outline: 'none', + height: 36, }; diff --git a/packages/web/src/app/(dashboard)/shopping-lists/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/page.tsx index 295415e..275c0f2 100644 --- a/packages/web/src/app/(dashboard)/shopping-lists/page.tsx +++ b/packages/web/src/app/(dashboard)/shopping-lists/page.tsx @@ -4,24 +4,35 @@ import { useState, useEffect, useCallback } from 'react'; import { useApi } from '@/lib/useApi'; import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { Card, Button, Icon, Pill } from '@/components/ui'; +import type { ShoppingListResponse } from '@/services/shopping-lists'; import { getShoppingLists, createShoppingList } from '@/services/shopping-lists'; +import type { MealPlanResponse } from '@/services/meal-plans'; import { listMealPlans } from '@/services/meal-plans'; import { generateFromMealPlan } from '@/services/shopping-lists'; import Link from 'next/link'; +interface MetricCardProps { + icon: string; + title: string; + value: string; + subtitle: string; + color: string; + link?: string; +} + export default function ShoppingListsPage() { const { householdId, isLoading } = useApi(); - const [lists, setLists] = useState([]); + const [lists, setLists] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - + // Modal States const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isGapModalOpen, setIsGapModalOpen] = useState(false); - + // Form States const [newListName, setNewListName] = useState(''); - const [recentMealPlans, setRecentMealPlans] = useState([]); + const [recentMealPlans, setRecentMealPlans] = useState([]); const [mealPlanLoading, setMealPlanLoading] = useState(false); const fetchLists = useCallback(async () => { @@ -36,15 +47,16 @@ export default function ShoppingListsPage() { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); setLists(data); - } catch (err: any) { - setError(err.message || 'Failed to load shopping lists'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load shopping lists'; + setError(message); } finally { setLoading(false); } }, [householdId]); useEffect(() => { - fetchLists(); + void fetchLists(); }, [fetchLists]); const handleCreateList = async (e: React.FormEvent) => { @@ -59,8 +71,9 @@ export default function ShoppingListsPage() { setIsCreateModalOpen(false); // Redirect or update list setLists((prev) => [res, ...prev]); - } catch (err: any) { - alert(err.message || 'Failed to create list'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create list'; + alert(message); } }; @@ -82,28 +95,44 @@ export default function ShoppingListsPage() { const res = await generateFromMealPlan(householdId!, mealPlanId); setIsGapModalOpen(false); setLists((prev) => [res, ...prev]); - } catch (err: any) { - alert(err.message || 'Failed to generate groceries from meal plan'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to generate groceries from meal plan'; + alert(message); } }; - if (isLoading) return ; + if (isLoading) + return ; if (!householdId) return
Please join a household.
; - const activeLists = lists.filter(l => l.status === 'active' || l.status === 'shopping'); - const completedLists = lists.filter(l => l.status === 'completed' || l.status === 'archived'); - + const activeLists = lists.filter((l) => l.status === 'active' || l.status === 'shopping'); + const completedLists = lists.filter((l) => l.status === 'completed' || l.status === 'archived'); + // Derive stats const totalActiveCost = activeLists.reduce((sum, l) => sum + (l.totalEstimatedCost || 0), 0); - const totalPendingItems = activeLists.reduce((sum, l) => sum + l.items.filter((i: any) => !i.checked).length, 0); + const totalPendingItems = activeLists.reduce( + (sum, l) => sum + l.items.filter((i) => !i.checked).length, + 0, + ); return ( <> - - + +
{/* 1. Beautiful Stats Band */} -
+
{/* 2. Action Row */} -
-

Checklists & Baskets

+
+

+ Checklists & Baskets +

- @@ -150,19 +195,70 @@ export default function ShoppingListsPage() {
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )} {/* 3. Lists Grid */} {loading ? ( -
- {[1, 2, 3].map(i =>
)} +
+ {[1, 2, 3].map((i) => ( +
+ ))}
) : activeLists.length === 0 && completedLists.length === 0 ? ( -
- -

No Shopping Lists Found

-

- Create an empty manual checklist, or dynamically auto-generate missing ingredients directly from your meal plan! +

+ +

+ No Shopping Lists Found +

+

+ Create an empty manual checklist, or dynamically auto-generate missing ingredients + directly from your meal plan!

@@ -170,7 +266,14 @@ export default function ShoppingListsPage() { <> {/* Active Section */} {activeLists.length > 0 && ( -
+
{activeLists.map((list) => ( ))} @@ -180,8 +283,25 @@ export default function ShoppingListsPage() { {/* Past Section */} {completedLists.length > 0 && ( <> -

Completed Runs

-
+

+ Completed Runs +

+
{completedLists.map((list) => ( ))} @@ -196,10 +316,26 @@ export default function ShoppingListsPage() { {isCreateModalOpen && (
setIsCreateModalOpen(false)}>
e.stopPropagation()}> -

Create Shopping List

- +

+ Create Shopping List +

+ { + void handleCreateList(e); + }} + >
- +
- +
@@ -225,28 +363,58 @@ export default function ShoppingListsPage() {
e.stopPropagation()}>

Scan Meal Plan Gaps

- Select a scheduled weekly plan. We will cross-reference your recipe ingredient requirements vs active pantry inventory to auto-generate your grocery shortages! + Select a scheduled weekly plan. We will cross-reference your recipe ingredient + requirements vs active pantry inventory to auto-generate your grocery shortages!

- + {mealPlanLoading ? (
Loading schedules...
) : recentMealPlans.length === 0 ? ( -
+
No meal plans configured. Build a plan first!
) : ( -
+
{recentMealPlans.slice(0, 5).map((plan) => { - const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); return ( @@ -254,9 +422,11 @@ export default function ShoppingListsPage() { })}
)} - +
- +
@@ -265,62 +435,159 @@ export default function ShoppingListsPage() { ); } -function MetricCard({ icon, title, value, subtitle, color, link }: any) { +function MetricCard({ icon, title, value, subtitle, color, link }: MetricCardProps) { const content = ( - -
+ +
-
{title}
-
{value}
-
+
+ {title} +
+
+ {value} +
+
{subtitle} {link && }
); - return link ? {content} : content; + return link ? ( + + {content} + + ) : ( + content + ); } -function ShoppingListCard({ list }: { list: any }) { +function ShoppingListCard({ list }: { list: ShoppingListResponse }) { const total = list.items.length; - const checked = list.items.filter((i: any) => i.checked).length; + const checked = list.items.filter((i) => i.checked).length; const progress = total > 0 ? Math.round((checked / total) * 100) : 0; - const date = new Date(list.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); - + const date = new Date(list.createdAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + const isActive = list.status === 'active' || list.status === 'shopping'; - + return ( - -
+ +
-

{list.name}

- Created {date} +

+ {list.name} +

+ + Created {date} +
- + {list.status === 'shopping' ? 'Live' : list.status}
-
+
{checked}/{total} items {list.totalEstimatedCost && ( - + ${list.totalEstimatedCost.toFixed(2)} )} @@ -328,12 +595,30 @@ function ShoppingListCard({ list }: { list: any }) { {/* Custom Progress Bar */}
-
+
Progress {progress}%
-
-
+
+
@@ -342,26 +627,50 @@ function ShoppingListCard({ list }: { list: any }) { } const overlayStyle: React.CSSProperties = { - position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, - background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)', - display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0,0,0,0.6)', + backdropFilter: 'blur(6px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, padding: 16, }; const modalStyle: React.CSSProperties = { - background: 'var(--bg-elev)', border: '1px solid var(--border)', - borderRadius: 'var(--r-lg)', padding: 24, width: '100%', maxWidth: 460, + background: 'var(--bg-elev)', + border: '1px solid var(--border)', + borderRadius: 'var(--r-lg)', + padding: 24, + width: '100%', + maxWidth: 460, boxShadow: '0 20px 40px rgba(0,0,0,0.3)', }; const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 14px', borderRadius: 'var(--r-md)', - background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', - fontSize: 14, outline: 'none', + width: '100%', + padding: '10px 14px', + borderRadius: 'var(--r-md)', + background: 'var(--bg)', + border: '1px solid var(--border)', + color: 'var(--ink)', + fontSize: 14, + outline: 'none', }; const planRowStyle: React.CSSProperties = { - display: 'flex', justifyContent: 'space-between', alignItems: 'center', - padding: '12px 16px', background: 'var(--bg)', border: '1px solid var(--border)', - borderRadius: 'var(--r-md)', width: '100%', cursor: 'pointer', transition: 'all 0.15s', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 16px', + background: 'var(--bg)', + border: '1px solid var(--border)', + borderRadius: 'var(--r-md)', + width: '100%', + cursor: 'pointer', + transition: 'all 0.15s', }; diff --git a/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx b/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx index e7901c0..232cb61 100644 --- a/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx +++ b/packages/web/src/app/(dashboard)/shopping-lists/prices/page.tsx @@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from 'react'; import { useApi } from '@/lib/useApi'; import { useRouter } from 'next/navigation'; import { SetPageHeader } from '@/components/layout/SetPageHeader'; -import { Card, Button, Icon, Pill } from '@/components/ui'; +import { Card, Button, Icon } from '@/components/ui'; +import type { FoodSpendingAnalyticsResponse } from '@/services/prices'; import { getPriceAnalytics } from '@/services/prices'; import { ResponsiveContainer, @@ -16,14 +17,25 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, Cell, } from 'recharts'; +interface SpendingByCategoryItem { + category: string; + total: number; +} + +interface AverageBasketByStoreItem { + storeId: string; + storeName: string; + avgTotal: number; + tripCount: number; +} + export default function PricesAnalyticsPage() { const { householdId, isLoading } = useApi(); const router = useRouter(); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -33,21 +45,31 @@ export default function PricesAnalyticsPage() { try { const result = await getPriceAnalytics(householdId); setData(result); - } catch (err: any) { - setError(err.message || 'Failed to load analytics'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load analytics'; + setError(message); } finally { setLoading(false); } }, [householdId]); useEffect(() => { - loadAnalytics(); + void loadAnalytics(); }, [loadAnalytics]); - if (isLoading || loading) return
Synthesizing financial graphs...
; - if (error || !data) return
Error: {error}
; + if (isLoading || loading) + return
Synthesizing financial graphs...
; + if (error || !data) + return
Error: {error}
; - const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6']; + const COLORS = [ + 'var(--brand)', + 'var(--success)', + 'var(--warning)', + '#a855f7', + '#ec4899', + '#3b82f6', + ]; return ( <> @@ -57,7 +79,6 @@ export default function PricesAnalyticsPage() { />
-