Add additional lint rules

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

View file

@ -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<ShoppingListDocument[]>`, `Promise<void>`).
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<T>` 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.

View file

@ -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<Document | null>`).
* 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<ShoppingListDocument>`).
* 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<AxiosResponse<T>>` or type mappers.
* Eliminate implicit parameters.
* **Verification Runner**:
```bash
cd packages/web
npx eslint src/services --fix
npx vitest run tests/services
```

122
docs/style-guidelines.md Normal file
View file

@ -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<ShoppingListDocument[]> {
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<ShoppingList[]> {
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.

View file

@ -26,13 +26,79 @@ export default tseslint.config(
'error', 'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }, { 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 // Relax some rules in test files
files: ['**/*.test.ts'], files: ['**/*.test.ts'],
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-member-accessibility': 'off', '@typescript-eslint/explicit-member-accessibility': 'off',
}, },
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import type { MedicinePricesRepository } from './medicine-prices.repository.js'; import type { MedicinePricesRepository } from './medicine-prices.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js'; import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { StoresRepository } from '../stores/stores.repository.js'; import type { StoresRepository } from '../stores/stores.repository.js';
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
import type { import type {
CreateMedicinePriceRecordInput, CreateMedicinePriceRecordInput,
MedicinePriceHistoryQueryInput, MedicinePriceHistoryQueryInput,
@ -33,7 +34,7 @@ export class MedicinePricesService {
data: CreateMedicinePriceRecordInput, data: CreateMedicinePriceRecordInput,
householdId: string, householdId: string,
userId: string, userId: string,
) { ): Promise<MedicinePriceDocument> {
const product = await this.medicineProductsRepository.findById( const product = await this.medicineProductsRepository.findById(
data.medicineProductId, data.medicineProductId,
householdId, householdId,
@ -70,11 +71,27 @@ export class MedicinePricesService {
householdId: string, householdId: string,
medicineId: string, medicineId: string,
query: MedicinePriceHistoryQueryInput, query: MedicinePriceHistoryQueryInput,
) { ): Promise<{
data: MedicinePriceDocument[];
pagination: { cursor: string | null; hasMore: boolean };
}> {
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query); 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); return this.medicinePricesRepository.compareStores(householdId, medicineId);
} }
@ -91,7 +108,32 @@ export class MedicinePricesService {
return record ? (record.pricePerUnit as number) : null; 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); return this.medicinePricesRepository.getAnalytics(householdId, query);
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,22 +23,22 @@ describe(RefillsService.name, () => {
getLatestForMedicine: vi.fn(), getLatestForMedicine: vi.fn(),
compareStores: vi.fn(), compareStores: vi.fn(),
}; };
const mockPurchasesRepo = { const mockShoppingListsRepo = {
getPendingMedicineStock: vi.fn(), findActiveByHousehold: vi.fn(),
}; };
let service: RefillsService; let service: RefillsService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]); mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([]);
service = new RefillsService({ service = new RefillsService({
refillsRepository: mockRepo as never, refillsRepository: mockRepo as never,
regimensService: mockRegimensService as never, regimensService: mockRegimensService as never,
cabinetRepository: mockCabinetRepo as never, cabinetRepository: mockCabinetRepo as never,
cabinetService: mockCabinetService as never, cabinetService: mockCabinetService as never,
medicinePricesRepository: mockPricesRepo 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'); 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([ mockRegimensService.calculateBurnRates.mockResolvedValue([
{ {
medicineId: 'med-1', medicineId: 'med-1',
@ -151,8 +151,12 @@ describe(RefillsService.name, () => {
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]); mockPricesRepo.compareStores.mockResolvedValue([]);
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([ mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([
{ medicineId: 'med-1', totalUnits: 60 }, {
items: [
{ productId: 'med-1', quantity: 60, checked: false },
],
},
]); ]);
const result = await service.getAlerts('hh1', 'user-1', 7); const result = await service.getAlerts('hh1', 'user-1', 7);

View file

@ -47,6 +47,102 @@ export default tseslint.config(
'error', 'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }, { 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, prettierRecommended,

View file

@ -1,11 +1,17 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import useSWR, { mutate } from 'swr'; import useSWR from 'swr';
import { useApi } from '@/lib/useApi'; import { useApi } from '@/lib/useApi';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, Button, Icon, Pill } from '@/components/ui'; import { Card, Button, Icon, Pill } from '@/components/ui';
import type {
BasketStoreComparisonResponse,
ShoppingItem,
ShoppingListResponse,
} from '@/services/shopping-lists';
import { type ServingUnit } from '@meshitrack/shared';
import { import {
getShoppingList, getShoppingList,
addShoppingItem, addShoppingItem,
@ -15,6 +21,7 @@ import {
getBasketStoreComparison, getBasketStoreComparison,
updateShoppingList, updateShoppingList,
} from '@/services/shopping-lists'; } from '@/services/shopping-lists';
import type { ProductResponse } from '@/services/products';
import { listProducts } from '@/services/products'; import { listProducts } from '@/services/products';
import { useShoppingListSync } from '@/lib/useShoppingListSync'; import { useShoppingListSync } from '@/lib/useShoppingListSync';
@ -24,9 +31,11 @@ export default function ShoppingListDetailsPage() {
const router = useRouter(); const router = useRouter();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [storeOptions, setStoreOptions] = useState<any[]>([]); const [storeOptions, setStoreOptions] = useState<
const [isStoreLoading, setIsStoreLoading] = useState(false); BasketStoreComparisonResponse['singleStoreOptions']
const [products, setProducts] = useState<any[]>([]); >([]);
const [_isStoreLoading, setIsStoreLoading] = useState(false);
const [products, setProducts] = useState<ProductResponse[]>([]);
const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProductId, setSelectedProductId] = useState('');
const [customItemName, setCustomItemName] = useState(''); const [customItemName, setCustomItemName] = useState('');
const [qty, setQty] = useState(1); const [qty, setQty] = useState(1);
@ -36,10 +45,12 @@ export default function ShoppingListDetailsPage() {
// Load core list context // Load core list context
const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null; const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR( const {
swrKey, data: list,
() => getShoppingList(householdId!, listId) mutate: mutateList,
); isLoading: listLoading,
error: listError,
} = useSWR<ShoppingListResponse, Error>(swrKey, () => getShoppingList(householdId!, listId));
useEffect(() => { useEffect(() => {
if (listError) setError(listError.message || 'Shopping list not found'); if (listError) setError(listError.message || 'Shopping list not found');
@ -61,20 +72,25 @@ export default function ShoppingListDetailsPage() {
// Pre-load household products for predictive inputs // Pre-load household products for predictive inputs
useEffect(() => { useEffect(() => {
if (!householdId) return; if (!householdId) return;
listProducts(householdId).then(res => setProducts(res.data)).catch(console.error); listProducts(householdId)
.then((res) => setProducts(res.data))
.catch(console.error);
}, [householdId]); }, [householdId]);
// Handle WS Remote Event Broadcasts // Handle WS Remote Event Broadcasts
const handleRemoteSync = useCallback((msg: any) => { const handleRemoteSync = useCallback(
console.log('🔔 Remote state delta payload:', msg); (msg: unknown) => {
mutateList(); // Revalidate with server on remote changes console.log('🔔 Remote state delta payload:', msg);
}, [mutateList]); void mutateList(); // Revalidate with server on remote changes
},
[mutateList],
);
// Inject Real-Time Hooks // Inject Real-Time Hooks
const { isConnected, toggleItemCheck } = useShoppingListSync( const { isConnected, toggleItemCheck } = useShoppingListSync(
householdId || '', householdId || '',
listId, listId,
handleRemoteSync handleRemoteSync,
); );
// 1. Perform Live Interactivity (Toggle Checks) // 1. Perform Live Interactivity (Toggle Checks)
@ -82,10 +98,16 @@ export default function ShoppingListDetailsPage() {
const nextChecked = !currentChecked; const nextChecked = !currentChecked;
// Optimistic Client Update // Optimistic Client Update
mutateList(async (prev: any) => ({ void mutateList(
...prev, async (prev) => {
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it) if (!prev) return undefined;
}), { revalidate: false }); return {
...prev,
items: prev.items.map((it) => (it.id === itemId ? { ...it, checked: nextChecked } : it)),
};
},
{ revalidate: false },
);
// Emit to WS Channel // Emit to WS Channel
toggleItemCheck(itemId, nextChecked); toggleItemCheck(itemId, nextChecked);
@ -93,10 +115,10 @@ export default function ShoppingListDetailsPage() {
// Persist standard Rest fallback ensuring safety // Persist standard Rest fallback ensuring safety
try { try {
await updateShoppingItem(householdId!, listId, itemId, { checked: nextChecked }); await updateShoppingItem(householdId!, listId, itemId, { checked: nextChecked });
mutateList(); void mutateList();
} catch (err) { } catch (err) {
console.error('Persistent toggle sync fail', err); console.error('Persistent toggle sync fail', err);
mutateList(); void mutateList();
} }
}; };
@ -109,18 +131,18 @@ export default function ShoppingListDetailsPage() {
productId: selectedProductId || undefined, productId: selectedProductId || undefined,
customName: !selectedProductId ? customItemName.trim() : undefined, customName: !selectedProductId ? customItemName.trim() : undefined,
quantity: qty, quantity: qty,
unit: unit as any, unit: unit as ServingUnit,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}); });
mutateList(updated); void mutateList(updated);
// Clear inputs // Clear inputs
setSelectedProductId(''); setSelectedProductId('');
setCustomItemName(''); setCustomItemName('');
setQty(1); setQty(1);
setNotes(''); setNotes('');
} catch (err: any) { } catch (err) {
alert(err.message); alert(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
setIsAdding(false); setIsAdding(false);
} }
@ -128,47 +150,60 @@ export default function ShoppingListDetailsPage() {
const handleDeleteItem = async (itemId: string) => { const handleDeleteItem = async (itemId: string) => {
// Optimistic delete // Optimistic delete
mutateList(async (prev: any) => ({ void mutateList(
...prev, async (prev) => {
items: prev.items.filter((it: any) => it.id !== itemId) if (!prev) return undefined;
}), { revalidate: false }); return {
...prev,
items: prev.items.filter((it) => it.id !== itemId),
};
},
{ revalidate: false },
);
try { try {
const updated = await removeShoppingItem(householdId!, listId, itemId); const updated = await removeShoppingItem(householdId!, listId, itemId);
mutateList(updated); void mutateList(updated);
} catch (err: any) { } catch (err) {
window.alert(err.message); const message = err instanceof Error ? err.message : 'Unknown error';
mutateList(); window.alert(message);
void mutateList();
} }
}; };
// 3. Execute Final Checkout / Pantry Sync // 3. Execute Final Checkout / Pantry Sync
const handleSyncToPantry = async () => { 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 { try {
const res = await syncToPantry(householdId!, listId); const res = await syncToPantry(householdId!, listId);
window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`); window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`);
// Mark list as completed automatically if all are done // 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) { if (allChecked) {
await updateShoppingList(householdId!, listId, { status: 'completed' as any }); await updateShoppingList(householdId!, listId, { status: 'completed' });
} }
mutateList(); void mutateList();
} catch (err: any) { } catch (err) {
window.alert('Migration sync error: ' + err.message); const message = err instanceof Error ? err.message : 'Unknown error';
window.alert('Migration sync error: ' + message);
} }
}; };
// Collate items categorized for satisfying view // Collate items categorized for satisfying view
const categorizedItems = useMemo(() => { const categorizedItems = useMemo(() => {
if (!list) return {}; if (!list) return {};
const groups: Record<string, any[]> = {}; const groups: Record<string, ShoppingItem[]> = {};
list.items.forEach((it: any) => { list.items.forEach((it) => {
const cat = it.category || 'Other / Misc'; const cat = it.category || 'Other / Misc';
if (!groups[cat]) groups[cat] = []; if (!groups[cat]) groups[cat] = [];
groups[cat].push(it); groups[cat].push(it);
@ -176,11 +211,12 @@ export default function ShoppingListDetailsPage() {
return groups; return groups;
}, [list]); }, [list]);
if (isAuthLoading || listLoading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>; if (isAuthLoading || listLoading)
if (error || !list) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>; return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
if (error || !list)
return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length; const itemsPendingSync = list.items.filter((i) => i.checked && !i.addedToPantry).length;
const checkedCount = list.items.filter((i: any) => i.checked).length;
const totalCount = list.items.length; const totalCount = list.items.length;
return ( return (
@ -190,64 +226,137 @@ export default function ShoppingListDetailsPage() {
subtitle="Perform live checkout check-offs synchronously across multiple household devices." subtitle="Perform live checkout check-offs synchronously across multiple household devices."
/> />
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, padding: '0 32px', marginTop: -12, marginBottom: 12, maxWidth: 1400, margin: '-12px auto 12px' }}> <div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
fontSize: 13,
padding: '0 32px',
marginTop: -12,
marginBottom: 12,
maxWidth: 1400,
margin: '-12px auto 12px',
}}
>
<Pill tone={list.status === 'completed' ? 'ok' : 'info'}>{list.status}</Pill> <Pill tone={list.status === 'completed' ? 'ok' : 'info'}>{list.status}</Pill>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--ink-muted)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--ink-muted)' }}>
<div style={{ <div
width: 8, height: 8, borderRadius: '50%', style={{
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)', width: 8,
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none', height: 8,
}} /> borderRadius: '50%',
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)',
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none',
}}
/>
{isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'} {isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'}
</div> </div>
</div> </div>
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}> <div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
{/* Top Action Strip */} {/* Top Action Strip */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, justifyContent: 'space-between', flexWrap: 'wrap' }}> <div
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
justifyContent: 'space-between',
flexWrap: 'wrap',
}}
>
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}> <Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Hub <Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Hub
</Button> </Button>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<Button variant="ghost" onClick={fetchComparisons}> <Button
variant="ghost"
onClick={() => {
void fetchComparisons();
}}
>
<Icon name="trend" style={{ marginRight: 6, width: 16 }} /> Check Lowest Store Options <Icon name="trend" style={{ marginRight: 6, width: 16 }} /> Check Lowest Store Options
</Button> </Button>
{itemsPendingSync > 0 && ( {itemsPendingSync > 0 && (
<Button onClick={handleSyncToPantry} style={{ background: 'var(--success)', borderColor: 'var(--success)', color: '#fff' }}> <Button
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync} items to Pantry onClick={() => {
void handleSyncToPantry();
}}
style={{
background: 'var(--success)',
borderColor: 'var(--success)',
color: '#fff',
}}
>
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync}{' '}
items to Pantry
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{/* Main Workspace Split Grid */} {/* Main Workspace Split Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }}> <div
style={{
display: 'grid',
gridTemplateColumns: '1fr 380px',
gap: 32,
alignItems: 'start',
}}
>
{/* Left: Categorized Checklist Grid */} {/* Left: Categorized Checklist Grid */}
<div> <div>
{totalCount === 0 ? ( {totalCount === 0 ? (
<Card style={{ padding: 40, textAlign: 'center', background: 'var(--bg-elev)', border: '1px dashed var(--border)' }}> <Card
<Icon name="list" style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }} /> style={{
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>Checklist is Empty</h4> padding: 40,
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>Add missing ingredients using the pane on the right.</p> textAlign: 'center',
background: 'var(--bg-elev)',
border: '1px dashed var(--border)',
}}
>
<Icon
name="list"
style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }}
/>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>
Checklist is Empty
</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>
Add missing ingredients using the pane on the right.
</p>
</Card> </Card>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{Object.entries(categorizedItems).map(([cat, items]: [string, any]) => ( {Object.entries(categorizedItems).map(([cat, items]) => (
<div key={cat}> <div key={cat}>
<h4 style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12, borderBottom: '1px solid var(--border)', paddingBottom: 6 }}> <h4
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: 12,
borderBottom: '1px solid var(--border)',
paddingBottom: 6,
}}
>
{cat} {cat}
</h4> </h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it: any) => ( {items.map((it) => (
<div <div
key={it.id} key={it.id}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 14, display: 'flex',
padding: '12px 16px', background: 'var(--bg-elev)', alignItems: 'center',
border: '1px solid var(--border)', borderRadius: 'var(--r-md)', gap: 14,
padding: '12px 16px',
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
transition: 'all 0.15s', transition: 'all 0.15s',
opacity: it.checked ? 0.65 : 1, opacity: it.checked ? 0.65 : 1,
textDecoration: it.checked ? 'line-through' : 'none', textDecoration: it.checked ? 'line-through' : 'none',
@ -255,32 +364,70 @@ export default function ShoppingListDetailsPage() {
> >
{/* Checkbox circle */} {/* Checkbox circle */}
<button <button
onClick={() => handleToggleCheck(it.id, it.checked)} onClick={() => {
aria-label={`Toggle check for ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`} void handleToggleCheck(it.id, it.checked);
}}
aria-label={`Toggle check for ${it.productId ? products.find((p) => p._id === it.productId)?.name : it.customName}`}
style={{ style={{
width: 22, height: 22, borderRadius: '50%', width: 22,
height: 22,
borderRadius: '50%',
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`, border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
background: it.checked ? 'var(--success, #10b981)' : 'transparent', background: it.checked ? 'var(--success, #10b981)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex',
cursor: 'pointer', flexShrink: 0, padding: 0, alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
padding: 0,
}} }}
> >
{it.checked && <Icon name="check" style={{ width: 12, color: '#fff' }} />} {it.checked && (
<Icon name="check" style={{ width: 12, color: '#fff' }} />
)}
</button> </button>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: it.checked ? 'var(--ink-muted)' : 'var(--ink)' }}> <div
{it.productId ? products.find(p => p._id === it.productId)?.name || 'Ingredient Loading...' : it.customName} style={{
fontWeight: 600,
fontSize: 14,
color: it.checked ? 'var(--ink-muted)' : 'var(--ink)',
}}
>
{it.productId
? products.find((p) => p._id === it.productId)?.name ||
'Ingredient Loading...'
: it.customName}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'flex', gap: 10, marginTop: 2 }}> <div
<span>Qty: {it.quantity} {it.unit}</span> style={{
fontSize: 11,
color: 'var(--ink-muted)',
display: 'flex',
gap: 10,
marginTop: 2,
}}
>
<span>
Qty: {it.quantity} {it.unit}
</span>
{it.notes && <span> Note: {it.notes}</span>} {it.notes && <span> Note: {it.notes}</span>}
</div> </div>
</div> </div>
{/* Estimated Price Tag */} {/* Estimated Price Tag */}
{it.estimatedPrice && !it.checked && ( {it.estimatedPrice && !it.checked && (
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', background: 'var(--bg)', padding: '4px 8px', borderRadius: 'var(--r-sm)' }}> <div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--ink-muted)',
background: 'var(--bg)',
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
}}
>
~${it.estimatedPrice.toFixed(2)} ~${it.estimatedPrice.toFixed(2)}
</div> </div>
)} )}
@ -293,9 +440,18 @@ export default function ShoppingListDetailsPage() {
)} )}
<button <button
onClick={() => handleDeleteItem(it.id)} onClick={() => {
aria-label={`Delete ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`} void handleDeleteItem(it.id);
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }} }}
aria-label={`Delete ${it.productId ? products.find((p) => p._id === it.productId)?.name : it.customName}`}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: 6,
color: 'var(--ink-muted)',
opacity: 0.5,
}}
> >
<Icon name="trash" style={{ width: 14 }} /> <Icon name="trash" style={{ width: 14 }} />
</button> </button>
@ -310,13 +466,26 @@ export default function ShoppingListDetailsPage() {
{/* Right Side Panel: Context Inputs */} {/* Right Side Panel: Context Inputs */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* Pane A: Add New Item */} {/* Pane A: Add New Item */}
<Card style={{ padding: 20 }}> <Card style={{ padding: 20 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}> <h4
style={{
fontSize: 15,
fontWeight: 600,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Icon name="plus" style={{ width: 16 }} /> Add Grocery Item <Icon name="plus" style={{ width: 16 }} /> Add Grocery Item
</h4> </h4>
<form onSubmit={handleAddItem} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form
onSubmit={(e) => {
void handleAddItem(e);
}}
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
>
<div> <div>
<label style={labelStyle}>Link Product Catalog</label> <label style={labelStyle}>Link Product Catalog</label>
<select <select
@ -328,7 +497,11 @@ export default function ShoppingListDetailsPage() {
style={selectStyle} style={selectStyle}
> >
<option value="">-- Create Manual Custom Input --</option> <option value="">-- Create Manual Custom Input --</option>
{products.map(p => <option key={p._id} value={p._id}>{p.name}</option>)} {products.map((p) => (
<option key={p._id} value={p._id}>
{p.name}
</option>
))}
</select> </select>
</div> </div>
@ -340,7 +513,7 @@ export default function ShoppingListDetailsPage() {
required required
placeholder="e.g., Generic Flour" placeholder="e.g., Generic Flour"
value={customItemName} value={customItemName}
onChange={e => setCustomItemName(e.target.value)} onChange={(e) => setCustomItemName(e.target.value)}
style={inputStyle} style={inputStyle}
/> />
</div> </div>
@ -355,13 +528,17 @@ export default function ShoppingListDetailsPage() {
min="0.01" min="0.01"
step="any" step="any"
value={qty} value={qty}
onChange={e => setQty(parseFloat(e.target.value) || 0)} onChange={(e) => setQty(parseFloat(e.target.value) || 0)}
style={inputStyle} style={inputStyle}
/> />
</div> </div>
<div> <div>
<label style={labelStyle}>Unit</label> <label style={labelStyle}>Unit</label>
<select value={unit} onChange={e => setUnit(e.target.value)} style={selectStyle}> <select
value={unit}
onChange={(e) => setUnit(e.target.value)}
style={selectStyle}
>
<option value="g">Grams</option> <option value="g">Grams</option>
<option value="ml">Milliliters</option> <option value="ml">Milliliters</option>
<option value="piece">Pieces</option> <option value="piece">Pieces</option>
@ -376,7 +553,7 @@ export default function ShoppingListDetailsPage() {
type="text" type="text"
placeholder="Brand preference, etc." placeholder="Brand preference, etc."
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
style={inputStyle} style={inputStyle}
/> />
</div> </div>
@ -390,19 +567,67 @@ export default function ShoppingListDetailsPage() {
{/* Pane B: Real-Time Store Optimizer */} {/* Pane B: Real-Time Store Optimizer */}
{storeOptions.length > 0 && ( {storeOptions.length > 0 && (
<Card style={{ padding: 20 }}> <Card style={{ padding: 20 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}> <h4
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store Basket Rank style={{
fontSize: 15,
fontWeight: 600,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store
Basket Rank
</h4> </h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{storeOptions.map((opt, idx) => ( {storeOptions.map((opt, idx) => (
<div key={opt.storeId} style={{ padding: 12, background: 'var(--bg)', border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)', borderRadius: 'var(--r-md)' }}> <div
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> key={opt.storeId}
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>{opt.storeName}</span> style={{
<span style={{ fontSize: 14, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${opt.estimatedTotal.toFixed(2)}</span> padding: 12,
background: 'var(--bg)',
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
borderRadius: 'var(--r-md)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>
{opt.storeName}
</span>
<span
style={{
fontSize: 14,
fontWeight: 700,
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
}}
>
${opt.estimatedTotal.toFixed(2)}
</span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}> <div
<span>Covered: {opt.itemsCovered}/{totalCount} products</span> style={{
{idx === 0 && <span style={{ color: 'var(--success)', fontWeight: 600 }}>Cheapest Single Trip</span>} display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ink-muted)',
marginTop: 4,
}}
>
<span>
Covered: {opt.itemsCovered}/{totalCount} products
</span>
{idx === 0 && (
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
Cheapest Single Trip
</span>
)}
</div> </div>
</div> </div>
))} ))}
@ -417,18 +642,34 @@ export default function ShoppingListDetailsPage() {
} }
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--ink-muted)', display: 'block',
textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, fontSize: 11,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.03em',
marginBottom: 6,
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', width: '100%',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', padding: '8px 12px',
fontSize: 13, outline: 'none', borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 13,
outline: 'none',
}; };
const selectStyle: React.CSSProperties = { const selectStyle: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)', width: '100%',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', padding: '8px 12px',
fontSize: 13, outline: 'none', height: 36, borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 13,
outline: 'none',
height: 36,
}; };

View file

@ -4,14 +4,25 @@ import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@/lib/useApi'; import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, Button, Icon, Pill } from '@/components/ui'; import { Card, Button, Icon, Pill } from '@/components/ui';
import type { ShoppingListResponse } from '@/services/shopping-lists';
import { getShoppingLists, createShoppingList } 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 { listMealPlans } from '@/services/meal-plans';
import { generateFromMealPlan } from '@/services/shopping-lists'; import { generateFromMealPlan } from '@/services/shopping-lists';
import Link from 'next/link'; import Link from 'next/link';
interface MetricCardProps {
icon: string;
title: string;
value: string;
subtitle: string;
color: string;
link?: string;
}
export default function ShoppingListsPage() { export default function ShoppingListsPage() {
const { householdId, isLoading } = useApi(); const { householdId, isLoading } = useApi();
const [lists, setLists] = useState<any[]>([]); const [lists, setLists] = useState<ShoppingListResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -21,7 +32,7 @@ export default function ShoppingListsPage() {
// Form States // Form States
const [newListName, setNewListName] = useState(''); const [newListName, setNewListName] = useState('');
const [recentMealPlans, setRecentMealPlans] = useState<any[]>([]); const [recentMealPlans, setRecentMealPlans] = useState<MealPlanResponse[]>([]);
const [mealPlanLoading, setMealPlanLoading] = useState(false); const [mealPlanLoading, setMealPlanLoading] = useState(false);
const fetchLists = useCallback(async () => { const fetchLists = useCallback(async () => {
@ -36,15 +47,16 @@ export default function ShoppingListsPage() {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}); });
setLists(data); setLists(data);
} catch (err: any) { } catch (err) {
setError(err.message || 'Failed to load shopping lists'); const message = err instanceof Error ? err.message : 'Failed to load shopping lists';
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [householdId]); }, [householdId]);
useEffect(() => { useEffect(() => {
fetchLists(); void fetchLists();
}, [fetchLists]); }, [fetchLists]);
const handleCreateList = async (e: React.FormEvent) => { const handleCreateList = async (e: React.FormEvent) => {
@ -59,8 +71,9 @@ export default function ShoppingListsPage() {
setIsCreateModalOpen(false); setIsCreateModalOpen(false);
// Redirect or update list // Redirect or update list
setLists((prev) => [res, ...prev]); setLists((prev) => [res, ...prev]);
} catch (err: any) { } catch (err) {
alert(err.message || 'Failed to create list'); 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); const res = await generateFromMealPlan(householdId!, mealPlanId);
setIsGapModalOpen(false); setIsGapModalOpen(false);
setLists((prev) => [res, ...prev]); setLists((prev) => [res, ...prev]);
} catch (err: any) { } catch (err) {
alert(err.message || 'Failed to generate groceries from meal plan'); const message =
err instanceof Error ? err.message : 'Failed to generate groceries from meal plan';
alert(message);
} }
}; };
if (isLoading) return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />; if (isLoading)
return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />;
if (!householdId) return <div style={{ padding: 32 }}>Please join a household.</div>; if (!householdId) return <div style={{ padding: 32 }}>Please join a household.</div>;
const activeLists = lists.filter(l => l.status === 'active' || l.status === 'shopping'); const activeLists = lists.filter((l) => l.status === 'active' || l.status === 'shopping');
const completedLists = lists.filter(l => l.status === 'completed' || l.status === 'archived'); const completedLists = lists.filter((l) => l.status === 'completed' || l.status === 'archived');
// Derive stats // Derive stats
const totalActiveCost = activeLists.reduce((sum, l) => sum + (l.totalEstimatedCost || 0), 0); 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 ( return (
<> <>
<SetPageHeader title="Grocery & Shopping" subtitle="Streamline your checklist, check gaps, and compare costs." /> <SetPageHeader
title="Grocery & Shopping"
subtitle="Streamline your checklist, check gaps, and compare costs."
/>
<div style={{ padding: '28px 32px 64px', maxWidth: 1300, margin: '0 auto' }}> <div style={{ padding: '28px 32px 64px', maxWidth: 1300, margin: '0 auto' }}>
{/* 1. Beautiful Stats Band */} {/* 1. Beautiful Stats Band */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 20, marginBottom: 32 }}> <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 20,
marginBottom: 32,
}}
>
<MetricCard <MetricCard
icon="store" icon="store"
title="Active Lists" title="Active Lists"
@ -128,7 +157,7 @@ export default function ShoppingListsPage() {
<MetricCard <MetricCard
icon="trend" icon="trend"
title="Spending Trend" title="Spending Trend"
value="Analyics" value="Analytics"
subtitle="Visualize price fluctuations" subtitle="Visualize price fluctuations"
color="var(--ink-muted)" color="var(--ink-muted)"
link="/shopping-lists/prices" link="/shopping-lists/prices"
@ -136,10 +165,26 @@ export default function ShoppingListsPage() {
</div> </div>
{/* 2. Action Row */} {/* 2. Action Row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 16 }}> <div
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>Checklists & Baskets</h3> style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 16,
}}
>
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
Checklists & Baskets
</h3>
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<Button variant="ghost" onClick={openGapModal}> <Button
variant="ghost"
onClick={() => {
void openGapModal();
}}
>
<Icon name="zap" style={{ marginRight: 6, width: 16 }} /> <Icon name="zap" style={{ marginRight: 6, width: 16 }} />
Generate from Meal Plan Generate from Meal Plan
</Button> </Button>
@ -150,19 +195,70 @@ export default function ShoppingListsPage() {
</div> </div>
</div> </div>
{error && <div style={{ color: 'var(--danger)', padding: 16, background: 'var(--danger-soft)', borderRadius: 'var(--r-md)', marginBottom: 24 }}>{error}</div>} {error && (
<div
style={{
color: 'var(--danger)',
padding: 16,
background: 'var(--danger-soft)',
borderRadius: 'var(--r-md)',
marginBottom: 24,
}}
>
{error}
</div>
)}
{/* 3. Lists Grid */} {/* 3. Lists Grid */}
{loading ? ( {loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}> <div
{[1, 2, 3].map(i => <div key={i} style={{ height: 180, borderRadius: 'var(--r-lg)', border: '1px solid var(--border)', background: 'var(--bg-elev)', opacity: 0.4 }} />)} style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 180,
borderRadius: 'var(--r-lg)',
border: '1px solid var(--border)',
background: 'var(--bg-elev)',
opacity: 0.4,
}}
/>
))}
</div> </div>
) : activeLists.length === 0 && completedLists.length === 0 ? ( ) : activeLists.length === 0 && completedLists.length === 0 ? (
<div style={{ textAlign: 'center', padding: '80px 24px', background: 'var(--bg-elev)', border: '1px dashed var(--border)', borderRadius: 'var(--r-lg)' }}> <div
<Icon name="store" style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }} /> style={{
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>No Shopping Lists Found</h4> textAlign: 'center',
<p style={{ color: 'var(--ink-muted)', fontSize: 14, marginBottom: 24, maxWidth: 400, margin: '0 auto 24px' }}> padding: '80px 24px',
Create an empty manual checklist, or dynamically auto-generate missing ingredients directly from your meal plan! background: 'var(--bg-elev)',
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
}}
>
<Icon
name="store"
style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }}
/>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>
No Shopping Lists Found
</h4>
<p
style={{
color: 'var(--ink-muted)',
fontSize: 14,
marginBottom: 24,
maxWidth: 400,
margin: '0 auto 24px',
}}
>
Create an empty manual checklist, or dynamically auto-generate missing ingredients
directly from your meal plan!
</p> </p>
<Button onClick={() => setIsCreateModalOpen(true)}>Create First List</Button> <Button onClick={() => setIsCreateModalOpen(true)}>Create First List</Button>
</div> </div>
@ -170,7 +266,14 @@ export default function ShoppingListsPage() {
<> <>
{/* Active Section */} {/* Active Section */}
{activeLists.length > 0 && ( {activeLists.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20, marginBottom: 40 }}> <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
marginBottom: 40,
}}
>
{activeLists.map((list) => ( {activeLists.map((list) => (
<ShoppingListCard key={list._id} list={list} /> <ShoppingListCard key={list._id} list={list} />
))} ))}
@ -180,8 +283,25 @@ export default function ShoppingListsPage() {
{/* Past Section */} {/* Past Section */}
{completedLists.length > 0 && ( {completedLists.length > 0 && (
<> <>
<h4 style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 16 }}>Completed Runs</h4> <h4
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}> style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 16,
}}
>
Completed Runs
</h4>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 20,
}}
>
{completedLists.map((list) => ( {completedLists.map((list) => (
<ShoppingListCard key={list._id} list={list} /> <ShoppingListCard key={list._id} list={list} />
))} ))}
@ -196,10 +316,26 @@ export default function ShoppingListsPage() {
{isCreateModalOpen && ( {isCreateModalOpen && (
<div style={overlayStyle} onClick={() => setIsCreateModalOpen(false)}> <div style={overlayStyle} onClick={() => setIsCreateModalOpen(false)}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>Create Shopping List</h3> <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
<form onSubmit={handleCreateList}> Create Shopping List
</h3>
<form
onSubmit={(e) => {
void handleCreateList(e);
}}
>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', marginBottom: 6 }}>Checklist Name</label> <label
style={{
display: 'block',
fontSize: 12,
fontWeight: 500,
color: 'var(--ink-muted)',
marginBottom: 6,
}}
>
Checklist Name
</label>
<input <input
type="text" type="text"
required required
@ -211,7 +347,9 @@ export default function ShoppingListsPage() {
/> />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>Cancel</Button> <Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>
Cancel
</Button>
<Button type="submit">Create</Button> <Button type="submit">Create</Button>
</div> </div>
</form> </form>
@ -225,28 +363,58 @@ export default function ShoppingListsPage() {
<div style={modalStyle} onClick={(e) => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Scan Meal Plan Gaps</h3> <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Scan Meal Plan Gaps</h3>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 20 }}> <p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 20 }}>
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!
</p> </p>
{mealPlanLoading ? ( {mealPlanLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}>Loading schedules...</div> <div style={{ textAlign: 'center', padding: 20 }}>Loading schedules...</div>
) : recentMealPlans.length === 0 ? ( ) : recentMealPlans.length === 0 ? (
<div style={{ padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, textAlign: 'center', fontSize: 14, color: 'var(--ink-muted)' }}> <div
style={{
padding: 16,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 8,
textAlign: 'center',
fontSize: 14,
color: 'var(--ink-muted)',
}}
>
No meal plans configured. Build a plan first! No meal plans configured. Build a plan first!
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxHeight: 300, overflowY: 'auto', marginBottom: 20 }}> <div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
maxHeight: 300,
overflowY: 'auto',
marginBottom: 20,
}}
>
{recentMealPlans.slice(0, 5).map((plan) => { {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 ( return (
<button <button
key={plan._id} key={plan._id}
onClick={() => handleGenerateFromPlan(plan._id)} onClick={() => {
void handleGenerateFromPlan(plan._id);
}}
style={planRowStyle} style={planRowStyle}
> >
<div style={{ textAlign: 'left' }}> <div style={{ textAlign: 'left' }}>
<div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>Week of {dateStr}</div> <div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span></div> Week of {dateStr}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span>
</div>
</div> </div>
<Icon name="chevronRight" style={{ width: 16, color: 'var(--ink-muted)' }} /> <Icon name="chevronRight" style={{ width: 16, color: 'var(--ink-muted)' }} />
</button> </button>
@ -256,7 +424,9 @@ export default function ShoppingListsPage() {
)} )}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>Close</Button> <Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>
Close
</Button>
</div> </div>
</div> </div>
</div> </div>
@ -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 = ( const content = (
<Card style={{ padding: 20, height: '100%', display: 'flex', alignItems: 'center', gap: 16, position: 'relative', overflow: 'hidden', cursor: link ? 'pointer' : 'default' }}> <Card
<div style={{ width: 48, height: 48, borderRadius: '50%', background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> style={{
padding: 20,
height: '100%',
display: 'flex',
alignItems: 'center',
gap: 16,
position: 'relative',
overflow: 'hidden',
cursor: link ? 'pointer' : 'default',
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: '50%',
background: `${color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name={icon} style={{ width: 22, height: 22, color }} /> <Icon name={icon} style={{ width: 22, height: 22, color }} />
</div> </div>
<div> <div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>{title}</div> <div
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>{value}</div> style={{
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', alignItems: 'center', gap: 4 }}> fontSize: 12,
fontWeight: 500,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
letterSpacing: '0.02em',
}}
>
{title}
</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>
{value}
</div>
<div
style={{
fontSize: 12,
color: 'var(--ink-muted)',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{subtitle} {subtitle}
{link && <Icon name="chevronRight" style={{ width: 12 }} />} {link && <Icon name="chevronRight" style={{ width: 12 }} />}
</div> </div>
</div> </div>
</Card> </Card>
); );
return link ? <Link href={link} style={{ textDecoration: 'none' }}>{content}</Link> : content; return link ? (
<Link href={link} style={{ textDecoration: 'none' }}>
{content}
</Link>
) : (
content
);
} }
function ShoppingListCard({ list }: { list: any }) { function ShoppingListCard({ list }: { list: ShoppingListResponse }) {
const total = list.items.length; 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 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'; const isActive = list.status === 'active' || list.status === 'shopping';
return ( return (
<Link href={`/shopping-lists/${list._id}`} style={{ textDecoration: 'none' }}> <Link href={`/shopping-lists/${list._id}`} style={{ textDecoration: 'none' }}>
<Card style={{ <Card
padding: 20, style={{
transition: 'all 0.2s ease', padding: 20,
border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)', transition: 'all 0.2s ease',
position: 'relative', border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)',
background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)', position: 'relative',
display: 'flex', background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)',
flexDirection: 'column', display: 'flex',
gap: 12, flexDirection: 'column',
cursor: 'pointer', gap: 12,
}}> cursor: 'pointer',
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}> }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
}}
>
<div> <div>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', margin: 0, lineHeight: 1.3 }}>{list.name}</h4> <h4
<span style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'inline-block', marginTop: 4 }}>Created {date}</span> style={{
fontSize: 16,
fontWeight: 600,
color: 'var(--ink)',
margin: 0,
lineHeight: 1.3,
}}
>
{list.name}
</h4>
<span
style={{
fontSize: 11,
color: 'var(--ink-muted)',
display: 'inline-block',
marginTop: 4,
}}
>
Created {date}
</span>
</div> </div>
<Pill tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}> <Pill
tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}
>
{list.status === 'shopping' ? 'Live' : list.status} {list.status === 'shopping' ? 'Live' : list.status}
</Pill> </Pill>
</div> </div>
<div style={{ fontSize: 13, color: 'var(--ink-muted)', display: 'flex', gap: 16, alignItems: 'center' }}> <div
style={{
fontSize: 13,
color: 'var(--ink-muted)',
display: 'flex',
gap: 16,
alignItems: 'center',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="list" style={{ width: 14 }} /> {checked}/{total} items <Icon name="list" style={{ width: 14 }} /> {checked}/{total} items
</span> </span>
{list.totalEstimatedCost && ( {list.totalEstimatedCost && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontWeight: 600, color: 'var(--ink)' }}> <span
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontWeight: 600,
color: 'var(--ink)',
}}
>
${list.totalEstimatedCost.toFixed(2)} ${list.totalEstimatedCost.toFixed(2)}
</span> </span>
)} )}
@ -328,12 +595,30 @@ function ShoppingListCard({ list }: { list: any }) {
{/* Custom Progress Bar */} {/* Custom Progress Bar */}
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginBottom: 4 }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ink-muted)',
marginBottom: 4,
}}
>
<span>Progress</span> <span>Progress</span>
<span>{progress}%</span> <span>{progress}%</span>
</div> </div>
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}> <div
<div style={{ height: '100%', width: `${progress}%`, background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)', borderRadius: 3, transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)' }} /> style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}
>
<div
style={{
height: '100%',
width: `${progress}%`,
background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)',
borderRadius: 3,
transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div> </div>
</div> </div>
</Card> </Card>
@ -342,26 +627,50 @@ function ShoppingListCard({ list }: { list: any }) {
} }
const overlayStyle: React.CSSProperties = { const overlayStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, position: 'fixed',
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)', top: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, 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, padding: 16,
}; };
const modalStyle: React.CSSProperties = { const modalStyle: React.CSSProperties = {
background: 'var(--bg-elev)', border: '1px solid var(--border)', background: 'var(--bg-elev)',
borderRadius: 'var(--r-lg)', padding: 24, width: '100%', maxWidth: 460, 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)', boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', borderRadius: 'var(--r-md)', width: '100%',
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)', padding: '10px 14px',
fontSize: 14, outline: 'none', borderRadius: 'var(--r-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--ink)',
fontSize: 14,
outline: 'none',
}; };
const planRowStyle: React.CSSProperties = { const planRowStyle: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex',
padding: '12px 16px', background: 'var(--bg)', border: '1px solid var(--border)', justifyContent: 'space-between',
borderRadius: 'var(--r-md)', width: '100%', cursor: 'pointer', transition: 'all 0.15s', 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',
}; };

View file

@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from 'react';
import { useApi } from '@/lib/useApi'; import { useApi } from '@/lib/useApi';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { SetPageHeader } from '@/components/layout/SetPageHeader'; 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 { getPriceAnalytics } from '@/services/prices';
import { import {
ResponsiveContainer, ResponsiveContainer,
@ -16,14 +17,25 @@ import {
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
Legend,
Cell, Cell,
} from 'recharts'; } from 'recharts';
interface SpendingByCategoryItem {
category: string;
total: number;
}
interface AverageBasketByStoreItem {
storeId: string;
storeName: string;
avgTotal: number;
tripCount: number;
}
export default function PricesAnalyticsPage() { export default function PricesAnalyticsPage() {
const { householdId, isLoading } = useApi(); const { householdId, isLoading } = useApi();
const router = useRouter(); const router = useRouter();
const [data, setData] = useState<any>(null); const [data, setData] = useState<FoodSpendingAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -33,21 +45,31 @@ export default function PricesAnalyticsPage() {
try { try {
const result = await getPriceAnalytics(householdId); const result = await getPriceAnalytics(householdId);
setData(result); setData(result);
} catch (err: any) { } catch (err) {
setError(err.message || 'Failed to load analytics'); const message = err instanceof Error ? err.message : 'Failed to load analytics';
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [householdId]); }, [householdId]);
useEffect(() => { useEffect(() => {
loadAnalytics(); void loadAnalytics();
}, [loadAnalytics]); }, [loadAnalytics]);
if (isLoading || loading) return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>; if (isLoading || loading)
if (error || !data) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>; return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>;
if (error || !data)
return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6']; const COLORS = [
'var(--brand)',
'var(--success)',
'var(--warning)',
'#a855f7',
'#ec4899',
'#3b82f6',
];
return ( return (
<> <>
@ -57,7 +79,6 @@ export default function PricesAnalyticsPage() {
/> />
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}> <div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}> <Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Checklists <Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Checklists
@ -66,34 +87,76 @@ export default function PricesAnalyticsPage() {
{/* 1. Immediate Red Alert Banner: Inflation Markup >10% */} {/* 1. Immediate Red Alert Banner: Inflation Markup >10% */}
{data.priceAlerts.length > 0 && ( {data.priceAlerts.length > 0 && (
<div style={{ <div
background: 'rgba(239, 68, 68, 0.08)', style={{
border: '1px solid rgba(239, 68, 68, 0.3)', background: 'rgba(239, 68, 68, 0.08)',
borderRadius: 'var(--r-lg)', border: '1px solid rgba(239, 68, 68, 0.3)',
padding: 20, marginBottom: 32, borderRadius: 'var(--r-lg)',
display: 'flex', gap: 16, alignItems: 'flex-start' padding: 20,
}}> marginBottom: 32,
<div style={{ display: 'flex',
width: 40, height: 40, borderRadius: '50%', gap: 16,
background: 'var(--danger)', display: 'flex', alignItems: 'flex-start',
alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
}}> >
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--danger)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon name="alert" style={{ width: 20, color: '#fff' }} /> <Icon name="alert" style={{ width: 20, color: '#fff' }} />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>Significant Inflation Markers Detected</h4> <h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>The following item markups exceeded the baseline 10% deviation thresholds compared to their trailing averages:</p> Significant Inflation Markers Detected
</h4>
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>
The following item markups exceeded the baseline 10% deviation thresholds compared
to their trailing averages:
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}> <div
{data.priceAlerts.map((alert: any, idx: number) => ( style={{
<div key={idx} style={{ padding: 12, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}
>
{data.priceAlerts.map((alert, idx) => (
<div
key={idx}
style={{
padding: 12,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div> <div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>{alert.productName}</div> <div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>At {alert.storeName}</div> {alert.productName}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
At {alert.storeName}
</div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>+{alert.changePercent.toFixed(0)}%</div> <div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>${alert.previousPrice} ${alert.currentPrice}</div> +{alert.changePercent.toFixed(0)}%
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
${alert.previousPrice} ${alert.currentPrice}
</div>
</div> </div>
</div> </div>
))} ))}
@ -103,22 +166,44 @@ export default function PricesAnalyticsPage() {
)} )}
{/* 2. Grid Layout for Interactive Charts */} {/* 2. Grid Layout for Interactive Charts */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))', gap: 28, marginBottom: 32 }}> <div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))',
gap: 28,
marginBottom: 32,
}}
>
{/* Time Series Spend Trend */} {/* Time Series Spend Trend */}
<Card style={{ padding: 24 }}> <Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Monthly Spending Velocities</h4> <h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Monthly Spending Velocities
</h4>
<div style={{ height: 300 }}> <div style={{ height: 300 }}>
{data.spendingOverTime.length === 0 ? ( {data.spendingOverTime.length === 0 ? (
<div style={emptyStyle}>No historical spend records found.</div> <div style={emptyStyle}>No historical spend records found.</div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data.spendingOverTime} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}> <LineChart
data={data.spendingOverTime}
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="period" stroke="var(--ink-muted)" fontSize={11} tickLine={false} /> <XAxis
dataKey="period"
stroke="var(--ink-muted)"
fontSize={11}
tickLine={false}
/>
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} /> <YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={tooltipStyle} />
<Line type="monotone" dataKey="total" stroke="var(--brand)" strokeWidth={3} activeDot={{ r: 6 }} /> <Line
type="monotone"
dataKey="total"
stroke="var(--brand)"
strokeWidth={3}
activeDot={{ r: 6 }}
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
@ -127,21 +212,33 @@ export default function PricesAnalyticsPage() {
{/* Category Distribution */} {/* Category Distribution */}
<Card style={{ padding: 24 }}> <Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Spending Distrubution by Category</h4> <h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Spending Distribution by Category
</h4>
<div style={{ height: 300 }}> <div style={{ height: 300 }}>
{data.spendingByCategory.length === 0 ? ( {data.spendingByCategory.length === 0 ? (
<div style={emptyStyle}>No categorized allocations recorded yet.</div> <div style={emptyStyle}>No categorized allocations recorded yet.</div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data.spendingByCategory} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}> <BarChart
data={data.spendingByCategory}
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="category" stroke="var(--ink-muted)" fontSize={11} tickLine={false} /> <XAxis
dataKey="category"
stroke="var(--ink-muted)"
fontSize={11}
tickLine={false}
/>
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} /> <YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} /> <Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="total" radius={[4, 4, 0, 0]}> <Bar dataKey="total" radius={[4, 4, 0, 0]}>
{data.spendingByCategory.map((entry: any, index: number) => ( {(data.spendingByCategory as SpendingByCategoryItem[]).map(
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> (_entry, index) => (
))} <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
),
)}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -152,46 +249,99 @@ export default function PricesAnalyticsPage() {
{/* 3. Average Basket Comparisons (Grid of Stores) */} {/* 3. Average Basket Comparisons (Grid of Stores) */}
<Card style={{ padding: 24 }}> <Card style={{ padding: 24 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Average Complete Basket Totals per Store</h4> <h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
Average Complete Basket Totals per Store
</h4>
{data.averageBasketByStore.length === 0 ? ( {data.averageBasketByStore.length === 0 ? (
<div style={emptyStyle}>Create multiple shopping trips to visualize basket trends.</div> <div style={emptyStyle}>Create multiple shopping trips to visualize basket trends.</div>
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 20 }}> <div
{data.averageBasketByStore.sort((a:any, b:any) => a.avgTotal - b.avgTotal).map((store: any, idx: number) => ( style={{
<div key={store.storeId} style={{ display: 'grid',
padding: 20, background: 'var(--bg-elev)', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)', gap: 20,
borderRadius: 'var(--r-md)', position: 'relative', overflow: 'hidden' }}
}}> >
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', marginBottom: 8 }}>{store.storeName}</div> {(data.averageBasketByStore as AverageBasketByStoreItem[])
<div style={{ fontSize: 28, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${store.avgTotal.toFixed(2)}</div> .sort((a, b) => a.avgTotal - b.avgTotal)
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>Based on {store.tripCount} simulated checkouts</div> .map((store, idx) => (
{idx === 0 && ( <div
<div style={{ key={store.storeId}
position: 'absolute', top: 0, right: 0, style={{
background: 'var(--success)', color: '#fff', padding: 20,
fontSize: 9, padding: '4px 8px', borderBottomLeftRadius: 'var(--r-sm)', background: 'var(--bg-elev)',
fontWeight: 700, textTransform: 'uppercase' border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
}}>Best Value</div> borderRadius: 'var(--r-md)',
)} position: 'relative',
</div> overflow: 'hidden',
))} }}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--ink-muted)',
textTransform: 'uppercase',
marginBottom: 8,
}}
>
{store.storeName}
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
}}
>
${store.avgTotal.toFixed(2)}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>
Based on {store.tripCount} simulated checkouts
</div>
{idx === 0 && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'var(--success)',
color: '#fff',
fontSize: 9,
padding: '4px 8px',
borderBottomLeftRadius: 'var(--r-sm)',
fontWeight: 700,
textTransform: 'uppercase',
}}
>
Best Value
</div>
)}
</div>
))}
</div> </div>
)} )}
</Card> </Card>
</div> </div>
</> </>
); );
} }
const emptyStyle: React.CSSProperties = { const emptyStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex',
height: '100%', color: 'var(--ink-muted)', fontSize: 13, border: '1px dashed var(--border)', alignItems: 'center',
borderRadius: 'var(--r-md)' justifyContent: 'center',
height: '100%',
color: 'var(--ink-muted)',
fontSize: 13,
border: '1px dashed var(--border)',
borderRadius: 'var(--r-md)',
}; };
const tooltipStyle: React.CSSProperties = { const tooltipStyle: React.CSSProperties = {
background: '#1f2937', border: '1px solid #374151', borderRadius: 8, background: '#1f2937',
color: '#fff', fontSize: 12, border: '1px solid #374151',
borderRadius: 8,
color: '#fff',
fontSize: 12,
}; };

View file

@ -332,14 +332,14 @@ function StoresContent({ householdId }: { householdId: string }) {
}, [householdId, search, filterTag]); }, [householdId, search, filterTag]);
useEffect(() => { useEffect(() => {
fetchStores(); void fetchStores();
}, [fetchStores]); }, [fetchStores]);
async function handleDeactivate(store: Store) { async function handleDeactivate(store: Store) {
if (!confirm(`Deactivate "${store.name}"?`)) return; if (!confirm(`Deactivate "${store.name}"?`)) return;
try { try {
await deactivateStore(householdId, store._id); await deactivateStore(householdId, store._id);
fetchStores(); void fetchStores();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to deactivate store'); setError(err instanceof Error ? err.message : 'Failed to deactivate store');
} }
@ -376,7 +376,7 @@ function StoresContent({ householdId }: { householdId: string }) {
householdId={householdId} householdId={householdId}
onSaved={() => { onSaved={() => {
setShowForm(false); setShowForm(false);
fetchStores(); void fetchStores();
}} }}
onCancel={() => setShowForm(false)} onCancel={() => setShowForm(false)}
/> />
@ -388,7 +388,7 @@ function StoresContent({ householdId }: { householdId: string }) {
initial={editingStore} initial={editingStore}
onSaved={() => { onSaved={() => {
setEditingStore(null); setEditingStore(null);
fetchStores(); void fetchStores();
}} }}
onCancel={() => setEditingStore(null)} onCancel={() => setEditingStore(null)}
/> />

View file

@ -13,20 +13,68 @@ interface AccentTokens {
const ACCENTS: Record<Accent, AccentTokens> = { const ACCENTS: Record<Accent, AccentTokens> = {
sage: { sage: {
light: { brand: '#10b981', deep: '#059669', soft: 'rgba(16,185,129,0.1)', softInk: '#047857', brandInk: '#ffffff' }, light: {
dark: { brand: '#34d399', deep: '#10b981', soft: 'rgba(52,211,153,0.15)', softInk: '#6ee7b7', brandInk: '#022c22' }, brand: '#10b981',
deep: '#059669',
soft: 'rgba(16,185,129,0.1)',
softInk: '#047857',
brandInk: '#ffffff',
},
dark: {
brand: '#34d399',
deep: '#10b981',
soft: 'rgba(52,211,153,0.15)',
softInk: '#6ee7b7',
brandInk: '#022c22',
},
}, },
cobalt: { cobalt: {
light: { brand: '#3b82f6', deep: '#2563eb', soft: 'rgba(59,130,246,0.1)', softInk: '#1d4ed8', brandInk: '#ffffff' }, light: {
dark: { brand: '#60a5fa', deep: '#3b82f6', soft: 'rgba(96,165,250,0.15)', softInk: '#93c5fd', brandInk: '#172554' }, brand: '#3b82f6',
deep: '#2563eb',
soft: 'rgba(59,130,246,0.1)',
softInk: '#1d4ed8',
brandInk: '#ffffff',
},
dark: {
brand: '#60a5fa',
deep: '#3b82f6',
soft: 'rgba(96,165,250,0.15)',
softInk: '#93c5fd',
brandInk: '#172554',
},
}, },
terracotta: { terracotta: {
light: { brand: '#f43f5e', deep: '#e11d48', soft: 'rgba(244,63,94,0.1)', softInk: '#be123c', brandInk: '#ffffff' }, light: {
dark: { brand: '#fb7185', deep: '#f43f5e', soft: 'rgba(251,113,133,0.15)', softInk: '#fda4af', brandInk: '#4c0519' }, brand: '#f43f5e',
deep: '#e11d48',
soft: 'rgba(244,63,94,0.1)',
softInk: '#be123c',
brandInk: '#ffffff',
},
dark: {
brand: '#fb7185',
deep: '#f43f5e',
soft: 'rgba(251,113,133,0.15)',
softInk: '#fda4af',
brandInk: '#4c0519',
},
}, },
graphite: { graphite: {
light: { brand: '#52525b', deep: '#3f3f46', soft: 'rgba(82,82,91,0.1)', softInk: '#27272a', brandInk: '#ffffff' }, light: {
dark: { brand: '#a1a1aa', deep: '#71717a', soft: 'rgba(161,161,170,0.15)', softInk: '#d4d4d8', brandInk: '#18181b' }, brand: '#52525b',
deep: '#3f3f46',
soft: 'rgba(82,82,91,0.1)',
softInk: '#27272a',
brandInk: '#ffffff',
},
dark: {
brand: '#a1a1aa',
deep: '#71717a',
soft: 'rgba(161,161,170,0.15)',
softInk: '#d4d4d8',
brandInk: '#18181b',
},
}, },
}; };

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
'use client'; 'use client';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';

View file

@ -1,17 +1,18 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { getShoppingListSyncSocketUrl } from '@/services/shopping-lists'; import { getShoppingListSyncSocketUrl } from '@/services/shopping-lists';
export interface SyncUpdateMessage { export interface SyncUpdateMessage {
type: 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_REMOVED'; type: 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_REMOVED';
itemId?: string; itemId?: string;
item?: any; item?: unknown;
updates?: any; updates?: unknown;
} }
export function useShoppingListSync( export function useShoppingListSync(
householdId: string, householdId: string,
listId: string, listId: string,
onRemoteChange: (msg: SyncUpdateMessage) => void onRemoteChange: (msg: SyncUpdateMessage) => void,
) { ) {
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@ -40,7 +41,7 @@ export function useShoppingListSync(
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const payload: SyncUpdateMessage = JSON.parse(event.data); const payload = JSON.parse(event.data) as SyncUpdateMessage;
onRemoteChange(payload); onRemoteChange(payload);
} catch (err) { } catch (err) {
console.error('Failed parsing real-time grocery payload', err); console.error('Failed parsing real-time grocery payload', err);
@ -60,7 +61,9 @@ export function useShoppingListSync(
reconnectAttemptsRef.current += 1; reconnectAttemptsRef.current += 1;
const delay = Math.min(1000 * reconnectAttemptsRef.current, 5000); const delay = Math.min(1000 * reconnectAttemptsRef.current, 5000);
setTimeout(() => { setTimeout(() => {
console.log(`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`); console.log(
`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`,
);
connect(); connect();
}, delay); }, delay);
} }
@ -89,7 +92,7 @@ export function useShoppingListSync(
type: 'TOGGLE_ITEM', type: 'TOGGLE_ITEM',
itemId, itemId,
checked, checked,
}) }),
); );
} }
}, []); }, []);

View file

@ -15,33 +15,6 @@ class ApiClient {
return BASE_URL; return BASE_URL;
} }
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
return headers;
}
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = await res.json();
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
public async get<T>(url: string): Promise<T> { public async get<T>(url: string): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, { const res = await fetch(`${BASE_URL}${url}`, {
headers: this.getHeaders(), headers: this.getHeaders(),
@ -78,6 +51,33 @@ class ApiClient {
}); });
return this.handleResponse<T>(res); return this.handleResponse<T>(res);
} }
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this._accessToken) {
headers['Authorization'] = `Bearer ${this._accessToken}`;
}
return headers;
}
private async handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
let message: string;
try {
const body = (await res.json()) as { message?: string };
message = body.message || `Request failed: ${res.status}`;
} catch {
message = `Request failed: ${res.status} ${res.statusText}`;
}
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();

View file

@ -44,88 +44,65 @@ export interface ShoppingGapReport {
export async function listMealPlans( export async function listMealPlans(
householdId: string, householdId: string,
query?: MealPlanQuery query?: MealPlanQuery,
): Promise<MealPlanListResponse> { ): Promise<MealPlanListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query?.cursor) params.set('cursor', query.cursor); if (query?.cursor) params.set('cursor', query.cursor);
if (query?.limit) params.set('limit', String(query.limit)); if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString(); const qs = params.toString();
return apiClient.get<MealPlanListResponse>( return apiClient.get<MealPlanListResponse>(
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}` `/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`,
); );
} }
export async function getMealPlanByWeek( export async function getMealPlanByWeek(
householdId: string, householdId: string,
weekStartDate: string weekStartDate: string,
): Promise<MealPlanResponse | { message: string }> { ): Promise<MealPlanResponse | { message: string }> {
return apiClient.get<MealPlanResponse | { message: string }>( return apiClient.get<MealPlanResponse | { message: string }>(
`/households/${householdId}/meal-plans/week/${weekStartDate}` `/households/${householdId}/meal-plans/week/${weekStartDate}`,
); );
} }
export async function getMealPlan( export async function getMealPlan(householdId: string, id: string): Promise<MealPlanResponse> {
householdId: string, return apiClient.get<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`);
id: string
): Promise<MealPlanResponse> {
return apiClient.get<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}`
);
} }
export async function createMealPlan( export async function createMealPlan(
householdId: string, householdId: string,
data: CreateMealPlanInput data: CreateMealPlanInput,
): Promise<MealPlanResponse> { ): Promise<MealPlanResponse> {
return apiClient.post<MealPlanResponse>( return apiClient.post<MealPlanResponse>(`/households/${householdId}/meal-plans`, data);
`/households/${householdId}/meal-plans`,
data
);
} }
export async function updateMealPlan( export async function updateMealPlan(
householdId: string, householdId: string,
id: string, id: string,
data: UpdateMealPlanInput data: UpdateMealPlanInput,
): Promise<MealPlanResponse> { ): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>( return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`, data);
`/households/${householdId}/meal-plans/${id}`,
data
);
} }
export async function updateMealPlanStatus( export async function updateMealPlanStatus(
householdId: string, householdId: string,
id: string, id: string,
status: MealPlanStatus status: MealPlanStatus,
): Promise<MealPlanResponse> { ): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>( return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}/status`, {
`/households/${householdId}/meal-plans/${id}/status`, status,
{ status } });
);
} }
export async function deleteMealPlan( export async function deleteMealPlan(householdId: string, id: string): Promise<void> {
householdId: string,
id: string
): Promise<void> {
return apiClient.delete(`/households/${householdId}/meal-plans/${id}`); return apiClient.delete(`/households/${householdId}/meal-plans/${id}`);
} }
export async function getSuggestions( export async function getSuggestions(householdId: string, limit = 5): Promise<RecipeSuggestion[]> {
householdId: string,
limit = 5
): Promise<RecipeSuggestion[]> {
return apiClient.get<RecipeSuggestion[]>( return apiClient.get<RecipeSuggestion[]>(
`/households/${householdId}/meal-plans/suggestions?limit=${limit}` `/households/${householdId}/meal-plans/suggestions?limit=${limit}`,
); );
} }
export async function getShoppingGap( export async function getShoppingGap(householdId: string, id: string): Promise<ShoppingGapReport> {
householdId: string, return apiClient.get<ShoppingGapReport>(`/households/${householdId}/meal-plans/${id}/gap`);
id: string
): Promise<ShoppingGapReport> {
return apiClient.get<ShoppingGapReport>(
`/households/${householdId}/meal-plans/${id}/gap`
);
} }

View file

@ -1,45 +1,42 @@
import { apiClient } from './api-client'; import { apiClient } from './api-client';
import type { z } from 'zod/v4'; import type { z } from 'zod/v4';
import type { import type { NutritionTargetResponseSchema, SetNutritionTargetInput } from '@meshitrack/shared';
NutritionTargetResponseSchema,
SetNutritionTargetInput,
} from '@meshitrack/shared';
export type NutritionTargetResponse = z.infer<typeof NutritionTargetResponseSchema>; export type NutritionTargetResponse = z.infer<typeof NutritionTargetResponseSchema>;
export async function getActiveNutritionTarget( export async function getActiveNutritionTarget(
householdId: string householdId: string,
): Promise<NutritionTargetResponse | { message: string }> { ): Promise<NutritionTargetResponse | { message: string }> {
return apiClient.get<NutritionTargetResponse | { message: string }>( return apiClient.get<NutritionTargetResponse | { message: string }>(
`/households/${householdId}/nutrition-targets` `/households/${householdId}/nutrition-targets`,
); );
} }
export async function getNutritionTargetHistory( export async function getNutritionTargetHistory(
householdId: string householdId: string,
): Promise<NutritionTargetResponse[]> { ): Promise<NutritionTargetResponse[]> {
return apiClient.get<NutritionTargetResponse[]>( return apiClient.get<NutritionTargetResponse[]>(
`/households/${householdId}/nutrition-targets/history` `/households/${householdId}/nutrition-targets/history`,
); );
} }
export async function setNutritionTarget( export async function setNutritionTarget(
householdId: string, householdId: string,
data: SetNutritionTargetInput data: SetNutritionTargetInput,
): Promise<NutritionTargetResponse> { ): Promise<NutritionTargetResponse> {
return apiClient.post<NutritionTargetResponse>( return apiClient.post<NutritionTargetResponse>(
`/households/${householdId}/nutrition-targets`, `/households/${householdId}/nutrition-targets`,
data data,
); );
} }
export async function calculateTargetPreset( export async function calculateTargetPreset(
householdId: string, householdId: string,
calories: number, calories: number,
strategy: 'maintenance' | 'loss' | 'gain' strategy: 'maintenance' | 'loss' | 'gain',
): Promise<SetNutritionTargetInput> { ): Promise<SetNutritionTargetInput> {
return apiClient.post<SetNutritionTargetInput>( return apiClient.post<SetNutritionTargetInput>(
`/households/${householdId}/nutrition-targets/presets`, `/households/${householdId}/nutrition-targets/presets`,
{ calories, strategy } { calories, strategy },
); );
} }

View file

@ -12,20 +12,20 @@ import type {
type PriceRecordResponse = z.infer<typeof PriceRecordResponseSchema>; type PriceRecordResponse = z.infer<typeof PriceRecordResponseSchema>;
type PriceHistoryResponse = z.infer<typeof PriceHistoryResponseSchema>; type PriceHistoryResponse = z.infer<typeof PriceHistoryResponseSchema>;
type FoodStoreComparisonResponse = z.infer<typeof FoodStoreComparisonResponseSchema>; type FoodStoreComparisonResponse = z.infer<typeof FoodStoreComparisonResponseSchema>;
type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>; export type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>;
type CreatePriceRecordInput = z.infer<typeof CreatePriceRecordSchema>; type CreatePriceRecordInput = z.infer<typeof CreatePriceRecordSchema>;
type BulkPriceRecordInput = z.infer<typeof BulkPriceRecordInputSchema>; type BulkPriceRecordInput = z.infer<typeof BulkPriceRecordInputSchema>;
export async function recordPrice( export async function recordPrice(
householdId: string, householdId: string,
data: CreatePriceRecordInput data: CreatePriceRecordInput,
): Promise<PriceRecordResponse> { ): Promise<PriceRecordResponse> {
return apiClient.post<PriceRecordResponse>(`/households/${householdId}/prices`, data); return apiClient.post<PriceRecordResponse>(`/households/${householdId}/prices`, data);
} }
export async function recordBulkPrices( export async function recordBulkPrices(
householdId: string, householdId: string,
data: BulkPriceRecordInput data: BulkPriceRecordInput,
): Promise<PriceRecordResponse[]> { ): Promise<PriceRecordResponse[]> {
return apiClient.post<PriceRecordResponse[]>(`/households/${householdId}/prices/bulk`, data); return apiClient.post<PriceRecordResponse[]>(`/households/${householdId}/prices/bulk`, data);
} }
@ -39,7 +39,7 @@ export async function getPriceHistory(
endDate?: string; endDate?: string;
cursor?: string; cursor?: string;
limit?: number; limit?: number;
} },
): Promise<PriceHistoryResponse> { ): Promise<PriceHistoryResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query?.storeId) params.set('storeId', query.storeId); if (query?.storeId) params.set('storeId', query.storeId);
@ -49,23 +49,23 @@ export async function getPriceHistory(
if (query?.limit) params.set('limit', String(query.limit)); if (query?.limit) params.set('limit', String(query.limit));
const qs = params.toString(); const qs = params.toString();
return apiClient.get<PriceHistoryResponse>( return apiClient.get<PriceHistoryResponse>(
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}` `/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`,
); );
} }
export async function compareStores( export async function compareStores(
householdId: string, householdId: string,
productId: string productId: string,
): Promise<FoodStoreComparisonResponse> { ): Promise<FoodStoreComparisonResponse> {
return apiClient.get<FoodStoreComparisonResponse>( return apiClient.get<FoodStoreComparisonResponse>(
`/households/${householdId}/prices/compare/${productId}` `/households/${householdId}/prices/compare/${productId}`,
); );
} }
export async function getPriceAnalytics( export async function getPriceAnalytics(
householdId: string householdId: string,
): Promise<FoodSpendingAnalyticsResponse> { ): Promise<FoodSpendingAnalyticsResponse> {
return apiClient.get<FoodSpendingAnalyticsResponse>( return apiClient.get<FoodSpendingAnalyticsResponse>(
`/households/${householdId}/prices/analytics` `/households/${householdId}/prices/analytics`,
); );
} }

View file

@ -7,8 +7,8 @@ import type {
UpdateProductInput, UpdateProductInput,
} from '@meshitrack/shared'; } from '@meshitrack/shared';
type ProductResponse = z.infer<typeof ProductResponseSchema>; export type ProductResponse = z.infer<typeof ProductResponseSchema>;
type ProductListResponse = z.infer<typeof ProductListResponseSchema>; export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
export interface ProductQuery { export interface ProductQuery {
q?: string; q?: string;
@ -96,12 +96,16 @@ export async function importProducts(
if (!res.ok) { if (!res.ok) {
let message: string; let message: string;
try { try {
const body = await res.json(); const body = (await res.json()) as { message?: string };
message = body.message || `Import failed: ${res.status}`; message = body.message || `Import failed: ${res.status}`;
} catch { } catch {
message = `Import failed: ${res.status} ${res.statusText}`; message = `Import failed: ${res.status} ${res.statusText}`;
} }
throw new Error(message); throw new Error(message);
} }
return res.json(); return res.json() as Promise<{
imported: number;
skippedDuplicates: number;
errors: { row: number; message: string }[];
}>;
} }

View file

@ -2,6 +2,7 @@ import { apiClient } from './api-client';
import type { z } from 'zod/v4'; import type { z } from 'zod/v4';
import type { import type {
ShoppingListResponseSchema, ShoppingListResponseSchema,
ShoppingItemSchema,
CreateShoppingListSchema, CreateShoppingListSchema,
UpdateShoppingListSchema, UpdateShoppingListSchema,
AddShoppingItemSchema, AddShoppingItemSchema,
@ -10,25 +11,31 @@ import type {
BasketStoreComparisonResponseSchema, BasketStoreComparisonResponseSchema,
} from '@meshitrack/shared'; } from '@meshitrack/shared';
type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>; export type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>;
type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>; export type ShoppingItem = z.infer<typeof ShoppingItemSchema>;
type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>; export type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>; export type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>; export type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
type ShoppingListSyncToPantryResponse = z.infer<typeof ShoppingListSyncToPantryResponseSchema>; export type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>; export type ShoppingListSyncToPantryResponse = z.infer<
typeof ShoppingListSyncToPantryResponseSchema
>;
export type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>;
export async function getShoppingLists(householdId: string): Promise<ShoppingListResponse[]> { export async function getShoppingLists(householdId: string): Promise<ShoppingListResponse[]> {
return apiClient.get<ShoppingListResponse[]>(`/households/${householdId}/shopping-lists`); return apiClient.get<ShoppingListResponse[]>(`/households/${householdId}/shopping-lists`);
} }
export async function getShoppingList(householdId: string, id: string): Promise<ShoppingListResponse> { export async function getShoppingList(
householdId: string,
id: string,
): Promise<ShoppingListResponse> {
return apiClient.get<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`); return apiClient.get<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`);
} }
export async function createShoppingList( export async function createShoppingList(
householdId: string, householdId: string,
data: CreateShoppingListInput data: CreateShoppingListInput,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists`, data); return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists`, data);
} }
@ -36,9 +43,12 @@ export async function createShoppingList(
export async function updateShoppingList( export async function updateShoppingList(
householdId: string, householdId: string,
id: string, id: string,
data: UpdateShoppingListInput data: UpdateShoppingListInput,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.patch<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`, data); return apiClient.patch<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}`,
data,
);
} }
export async function deleteShoppingList(householdId: string, id: string): Promise<void> { export async function deleteShoppingList(householdId: string, id: string): Promise<void> {
@ -50,30 +60,33 @@ export async function deleteShoppingList(householdId: string, id: string): Promi
export async function addShoppingItem( export async function addShoppingItem(
householdId: string, householdId: string,
id: string, id: string,
data: AddShoppingItemInput data: AddShoppingItemInput,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}/items`, data); return apiClient.post<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items`,
data,
);
} }
export async function updateShoppingItem( export async function updateShoppingItem(
householdId: string, householdId: string,
id: string, id: string,
itemId: string, itemId: string,
data: UpdateShoppingItemInput data: UpdateShoppingItemInput,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.patch<ShoppingListResponse>( return apiClient.patch<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`, `/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
data data,
); );
} }
export async function removeShoppingItem( export async function removeShoppingItem(
householdId: string, householdId: string,
id: string, id: string,
itemId: string itemId: string,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.delete<ShoppingListResponse>( return apiClient.delete<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/${id}/items/${itemId}` `/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
); );
} }
@ -81,28 +94,28 @@ export async function removeShoppingItem(
export async function generateFromMealPlan( export async function generateFromMealPlan(
householdId: string, householdId: string,
mealPlanId: string mealPlanId: string,
): Promise<ShoppingListResponse> { ): Promise<ShoppingListResponse> {
return apiClient.post<ShoppingListResponse>( return apiClient.post<ShoppingListResponse>(
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}` `/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`,
); );
} }
export async function syncToPantry( export async function syncToPantry(
householdId: string, householdId: string,
id: string id: string,
): Promise<ShoppingListSyncToPantryResponse> { ): Promise<ShoppingListSyncToPantryResponse> {
return apiClient.post<ShoppingListSyncToPantryResponse>( return apiClient.post<ShoppingListSyncToPantryResponse>(
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry` `/households/${householdId}/shopping-lists/${id}/sync-to-pantry`,
); );
} }
export async function getBasketStoreComparison( export async function getBasketStoreComparison(
householdId: string, householdId: string,
id: string id: string,
): Promise<BasketStoreComparisonResponse> { ): Promise<BasketStoreComparisonResponse> {
return apiClient.get<BasketStoreComparisonResponse>( return apiClient.get<BasketStoreComparisonResponse>(
`/households/${householdId}/shopping-lists/${id}/stores` `/households/${householdId}/shopping-lists/${id}/stores`,
); );
} }

View file

@ -7,10 +7,10 @@ import type {
UpdateStoreSchema, UpdateStoreSchema,
} from '@meshitrack/shared'; } from '@meshitrack/shared';
type StoreResponse = z.infer<typeof StoreResponseSchema>; export type StoreResponse = z.infer<typeof StoreResponseSchema>;
type StoreListResponse = z.infer<typeof StoreListResponseSchema>; export type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
type CreateStoreInput = z.infer<typeof CreateStoreSchema>; export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>; export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
export async function listStores( export async function listStores(
householdId: string, householdId: string,

View file

@ -14,9 +14,20 @@ describe('ShoppingListsPage', () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(useApiModule.useApi).mockReturnValue({ vi.mocked(useApiModule.useApi).mockReturnValue({
householdId: 'hh1', householdId: 'hh1',
householdIds: ['hh1'],
isLoading: false, isLoading: false,
user: null, isAuthenticated: true,
token: '123', profile: {
_id: 'user1',
keycloakId: 'user1',
displayName: 'Test User',
email: 'test@example.com',
householdIds: ['hh1'],
defaultHouseholdId: 'hh1',
createdAt: '2026-05-10T00:00:00.000Z',
updatedAt: '2026-05-10T00:00:00.000Z',
},
refreshProfile: vi.fn(),
}); });
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([ vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
{ id: 'list2', name: 'Completed Costco', status: 'completed', items: [], totalEstimatedCost: 50, createdAt: '2026-05-09' } as any, { id: 'list2', name: 'Completed Costco', status: 'completed', items: [], totalEstimatedCost: 50, createdAt: '2026-05-09' } as any,
@ -68,9 +79,7 @@ describe('ShoppingListsPage', () => {
it('opens generate from meal plan modal', async () => { it('opens generate from meal plan modal', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({ vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any], data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, pagination: { cursor: null, hasMore: false }
page: 1,
limit: 10,
}); });
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({ vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11' id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
@ -104,7 +113,7 @@ describe('ShoppingListsPage', () => {
it('can cancel/close creation and gap scanning modals', async () => { it('can cancel/close creation and gap scanning modals', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({ vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [], data: [],
total: 0, page: 1, limit: 10 pagination: { cursor: null, hasMore: false }
}); });
const { container } = render(<ShoppingListsPage />); const { container } = render(<ShoppingListsPage />);
@ -165,7 +174,7 @@ describe('ShoppingListsPage', () => {
it('handles generate from meal plan failure', async () => { it('handles generate from meal plan failure', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({ vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any], data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, page: 1, limit: 10 pagination: { cursor: null, hasMore: false }
}); });
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed')); vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
@ -314,7 +323,7 @@ describe('ShoppingListsPage', () => {
it('handles gap scanner generation failure with generic error fallback', async () => { it('handles gap scanner generation failure with generic error fallback', async () => {
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({ vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any], data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
total: 1, page: 1, limit: 10 pagination: { cursor: null, hasMore: false }
}); });
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue({}); vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue({});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});

View file

@ -130,7 +130,7 @@ describe('Icon', () => {
}); });
it('renders null for invalid icon name', () => { it('renders null for invalid icon name', () => {
const { container } = render(<Icon name="nonexistent" as any />); const { container } = render(<Icon name={"nonexistent" as any} />);
const svg = container.querySelector('svg'); const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument(); expect(svg).toBeInTheDocument();
expect(svg?.childNodes.length).toBe(0); expect(svg?.childNodes.length).toBe(0);

View file

@ -80,7 +80,7 @@ describe('shopping-lists service', () => {
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1'); const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync'); expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
apiClient.baseUrl = 'https://api.meshitrack.com'; (apiClient as any).baseUrl = 'https://api.meshitrack.com';
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1'); const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync'); expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');