Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
29
.antigravity/settings.local.json
Normal file
29
.antigravity/settings.local.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"run_command(npm run *)",
|
||||||
|
"run_command(npm test *)",
|
||||||
|
"run_command(npm install)",
|
||||||
|
"view_file(*)",
|
||||||
|
"list_dir(*)",
|
||||||
|
"grep_search(*)",
|
||||||
|
"replace_file_content(*)",
|
||||||
|
"multi_replace_file_content(*)",
|
||||||
|
"write_to_file(*)",
|
||||||
|
"command_status(*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"run_command(grep *)",
|
||||||
|
"run_command(ls *)",
|
||||||
|
"run_command(cat *)",
|
||||||
|
"run_command(head *)",
|
||||||
|
"run_command(tail *)",
|
||||||
|
"run_command(npx *)",
|
||||||
|
"run_command(*|*)",
|
||||||
|
"run_command(*>*)",
|
||||||
|
"run_command(*&*)",
|
||||||
|
"run_command(*Select-Object*)",
|
||||||
|
"run_command(*Select-String*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -42,7 +42,8 @@ The `docs/instructions/` directory contains best practices and conventions that
|
||||||
- **Always use `grep_search` or `file_search` to find files and patterns** — never use `Select-String`, `grep`, `find`, or `rg` in terminal commands.
|
- **Always use `grep_search` or `file_search` to find files and patterns** — never use `Select-String`, `grep`, `find`, or `rg` in terminal commands.
|
||||||
- **Run scripts only via `npm run <script>`** — never use `npx`, and never add flags like `2>&1`, pipes (`|`), or `Select-Object` to `npm run` commands.
|
- **Run scripts only via `npm run <script>`** — never use `npx`, and never add flags like `2>&1`, pipes (`|`), or `Select-Object` to `npm run` commands.
|
||||||
- **Use `execution_subagent` for build/test/lint commands** — it runs `npm run build`, `npm run test`, and `npm run lint` and returns relevant output without piping.
|
- **Use `execution_subagent` for build/test/lint commands** — it runs `npm run build`, `npm run test`, and `npm run lint` and returns relevant output without piping.
|
||||||
- **Every implementation task must end with**: `npm run build`, `npm run test`, and `npm run lint` all passing.
|
- **Never call tools in parallel** — always wait for one tool call to complete before calling the next. This includes file reads, searches, and terminal commands.
|
||||||
|
- **Every implementation task must end with**: `npm run build`, `npm run test:cov`, and `npm run lint` all passing. `npm run test:cov` enforces coverage thresholds (100% lines/functions/statements, 90% branches for `packages/api` and `packages/shared`). Do not use `npm run test` alone -- always use `test:cov` to catch coverage regressions.
|
||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
|
|
||||||
|
|
|
||||||
164
ANTIGRAVITY.md
Normal file
164
ANTIGRAVITY.md
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# ANTIGRAVITY.md
|
||||||
|
|
||||||
|
This file provides guidance to Antigravity (the Gemini-based coding assistant) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MeshiTrack is a self-hosted medicine & nutrition management platform. It is a TypeScript monorepo with three packages: `packages/api` (Fastify backend), `packages/web` (Next.js frontend), and `packages/shared` (types + Zod schemas used by both). Medicine tracking is implemented first (Phases 1-4), followed by food tracking (Phases 5-9).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Root (all packages via Turborepo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start all services in dev mode
|
||||||
|
npm run build # Build all packages (shared → api/web)
|
||||||
|
npm run test # Run all tests
|
||||||
|
npm run test:cov # Run all tests with coverage
|
||||||
|
npm run lint # Lint all packages
|
||||||
|
npm run lint-fix # Auto-fix lint issues
|
||||||
|
npm run typecheck # Type-check all packages
|
||||||
|
npm run clean # Remove build artifacts
|
||||||
|
npm run seed # Seed the database (delegates to packages/api)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -w packages/api # Run API tests
|
||||||
|
npm run test:cov -w packages/api # API tests with coverage
|
||||||
|
npm run test -- --watch -w packages/api # Watch mode
|
||||||
|
npm run dev -w packages/api # API dev server only
|
||||||
|
```
|
||||||
|
|
||||||
|
### API package (packages/api)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # tsx watch src/main.ts
|
||||||
|
npm run build # tsc
|
||||||
|
npm run seed # tsx src/scripts/seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml up -d # Start all services
|
||||||
|
docker compose -f docker/docker-compose.yml down # Stop all services
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Workflow
|
||||||
|
|
||||||
|
Every feature or phase implementation must follow this "Vertical Slice" approach:
|
||||||
|
|
||||||
|
1. **Shared Layer**: Define types, enums, and Zod v4 schemas in `packages/shared`. Add unit tests.
|
||||||
|
2. **Database Layer**: Create the Mongoose schema and Repository in `packages/api`. All reads must use `.lean().exec()`.
|
||||||
|
3. **Service Layer**: Implement business logic in the Service class, using Awilix for constructor injection. Add unit tests.
|
||||||
|
4. **Route Layer**: Create the Fastify route plugin and register it. Add route tests (using `app.inject`).
|
||||||
|
5. **Web API Client**: Implement the frontend service in `packages/web/src/services/`. Add unit tests.
|
||||||
|
6. **Web UI**: Create the Next.js pages and components. Use Server Components by default. Add component tests (React Testing Library).
|
||||||
|
|
||||||
|
**Verification Gate (Mandatory)**:
|
||||||
|
Before considering a task complete, run:
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run test:cov` (must meet 100/100/100/90 for API/Shared and 90/85/75/85 for Web)
|
||||||
|
- `npm run lint`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Monorepo Structure
|
||||||
|
|
||||||
|
- **`packages/shared`** — Single source of truth for all domain types, enums, and Zod v4 schemas. Consumed by both `api` and `web`. Must be pure TypeScript with no Node.js, browser, or framework dependencies.
|
||||||
|
- **`packages/api`** — Fastify 5 backend. ESM-only, TypeScript strict. Uses Awilix for DI, Mongoose 9 for MongoDB, jose 6 for JWT verification.
|
||||||
|
- **`packages/web`** — Next.js 16 (React 19) frontend. App Router, Tailwind CSS 4.
|
||||||
|
|
||||||
|
### API Layer Architecture (Fastify + Awilix)
|
||||||
|
|
||||||
|
The API follows a **routes → services → repositories** pattern with Awilix constructor-injection DI:
|
||||||
|
|
||||||
|
- Each domain feature lives in `src/modules/<feature>/` with files: `*.routes.ts`, `*.service.ts`, `*.repository.ts`
|
||||||
|
- Route plugins use `fastify-plugin` (`fp()`) to export routes and register Awilix dependencies
|
||||||
|
- Services receive dependencies via destructured constructor: `constructor({ productsRepository }: { productsRepository: ProductsRepository })`
|
||||||
|
- Resolve services per-request via `request.diScope.resolve<T>('serviceName')`
|
||||||
|
- All Mongoose read queries must use `.lean().exec()`
|
||||||
|
|
||||||
|
Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules.
|
||||||
|
|
||||||
|
### Domain Modules
|
||||||
|
|
||||||
|
**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `purchases/`, `refills/`
|
||||||
|
**Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/`
|
||||||
|
**Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/`
|
||||||
|
|
||||||
|
### Multi-tenancy
|
||||||
|
|
||||||
|
Every domain document is scoped to a `householdId`. A Fastify `preHandler` hook validates the `householdId` from the URI against the user's `householdIds[]` JWT claim. **Every data query must filter by `householdId`.**
|
||||||
|
|
||||||
|
Routes can opt out with `config: { public: true }` (skips auth) or `config: { skipHousehold: true }` (skips household validation).
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Keycloak is the OIDC provider. The API verifies JWTs via `jose`. The custom Keycloak protocol mapper injects `householdIds[]` into the JWT claims.
|
||||||
|
|
||||||
|
### Shared Package Rules
|
||||||
|
|
||||||
|
- All domain types and Zod schemas live here — never duplicate types across packages
|
||||||
|
- Import from `'zod/v4'` (not `'zod'`)
|
||||||
|
- Use `z.enum()` for enums, `z.email()` / `z.url()` as top-level calls
|
||||||
|
- Every directory has a barrel `index.ts`
|
||||||
|
- Use `import type` for type-only imports
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
All list endpoints use cursor-based pagination. **Never use `skip()`** on MongoDB queries. Response shape:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ data: T[], pagination: { cursor: string | null, hasMore: boolean, total?: number } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `ForbiddenError`, etc.). The global Fastify error handler maps them to the standard `ApiError` response shape (`statusCode`, `error`, `message`, `timestamp`, `path`).
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
1. **No `any` types** — use `unknown` + Zod validation at boundaries
|
||||||
|
2. **ESM everywhere** — `"type": "module"`, `.js` extensions on all imports, `import type` for type-only
|
||||||
|
3. **Cursor-based pagination only** — never `skip()` for large collections
|
||||||
|
4. **Zod v4** — import from `'zod/v4'`
|
||||||
|
5. **`.lean().exec()`** on all Mongoose read queries
|
||||||
|
6. **`householdId` filter** on every domain query — this is the multi-tenancy boundary
|
||||||
|
7. **No emojis** — never use emoji characters in source code, UI text, console output, or documentation
|
||||||
|
8. **`npx` is banned** — never run `npx` for any reason. Use `npm run <script>` for all test, lint, build, and tool invocations. No exceptions.
|
||||||
|
9. **Never pipe or redirect `npm run` commands** — run `npm run <script>` exactly as written; never append `2>&1`, `|`, `Select-Object`, `Select-String`, or any other shell constructs to it.
|
||||||
|
10. **Never use shell commands to read files** — always use `view_file` tool. Commands like `Get-Content`, `cat`, `head`, `tail` will be denied.
|
||||||
|
11. **Never use shell commands to search** — always use `grep_search` or `file_search`. Commands like `Select-String`, `grep`, `rg`, `find` will be denied.
|
||||||
|
12. **Never call tools in parallel** — always wait for one tool call to complete before calling the next. This applies to all tools: file reads, searches, and terminal commands.
|
||||||
|
13. **Every implementation task must end with `npm run build`, `npm run test:cov`, and `npm run lint` all passing.** Always use `test:cov` (not `test`) to enforce coverage thresholds. If coverage fails, write additional tests before considering the task done.
|
||||||
|
14. **No `.js` extensions on `@/` imports in `packages/web`** — Next.js resolves TypeScript files directly; `.js` extensions on `@/` path-alias imports break Turbopack and webpack. Only use `.js` extensions in `packages/api` and `packages/shared` (Node ESM).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit tests**: Vitest, co-located with source files as `*.routes.test.ts` / `*.service.test.ts`
|
||||||
|
- **Integration tests**: Vitest + `mongodb-memory-server` (`*.integration.test.ts`)
|
||||||
|
- **Component tests**: React Testing Library (`__tests__/*.test.tsx`)
|
||||||
|
- **E2E**: Playwright (root-level `e2e/`)
|
||||||
|
- **Use `ClassName.name` for `describe` labels** (not string literals)
|
||||||
|
- Coverage targets:
|
||||||
|
- `packages/api` & `packages/shared`: 100% lines/functions/statements, 90% branches
|
||||||
|
- `packages/web`: 90% lines, 85% functions, 75% branches, 85% statements
|
||||||
|
- Mark untestable lines with `/* v8 ignore */`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Before writing code, consult the relevant docs:
|
||||||
|
|
||||||
|
- `docs/instructions/` — coding conventions, Fastify patterns, Next.js patterns, MongoDB, Zod/TypeScript, testing, Docker, Keycloak, Turborepo
|
||||||
|
- `docs/phases/` — per-phase specs with schemas, endpoints, and business logic
|
||||||
|
- `docs/architecture.md` — ADRs explaining key technology choices
|
||||||
|
- `docs/cross-cutting.md` — API versioning, pagination, security, audit trail
|
||||||
|
|
||||||
|
## Git Conventions
|
||||||
|
|
||||||
|
Branches: `feature/MESH-001-description`, `fix/MESH-042-description`, `chore/description`
|
||||||
|
|
||||||
|
Commits follow Conventional Commits: `feat(products): add barcode lookup`, `fix(pantry): correct freshness calc`
|
||||||
|
|
@ -115,7 +115,8 @@ Services throw `AppError` subclasses (`NotFoundError`, `ConflictError`, `Forbidd
|
||||||
9. **Never pipe or redirect `npm run` commands** — run `npm run <script>` exactly as written; never append `2>&1`, `|`, `Select-Object`, `Select-String`, or any other shell constructs to it.
|
9. **Never pipe or redirect `npm run` commands** — run `npm run <script>` exactly as written; never append `2>&1`, `|`, `Select-Object`, `Select-String`, or any other shell constructs to it.
|
||||||
10. **Never use shell commands to read files** — always use `read_file` tool. Commands like `Get-Content`, `cat`, `head`, `tail` will be denied.
|
10. **Never use shell commands to read files** — always use `read_file` tool. Commands like `Get-Content`, `cat`, `head`, `tail` will be denied.
|
||||||
11. **Never use shell commands to search** — always use `grep_search` or `file_search`. Commands like `Select-String`, `grep`, `rg`, `find` will be denied.
|
11. **Never use shell commands to search** — always use `grep_search` or `file_search`. Commands like `Select-String`, `grep`, `rg`, `find` will be denied.
|
||||||
12. **Every implementation task must end with `npm run build`, `npm run test`, and `npm run lint` all passing.**
|
12. **Never call tools in parallel** — always wait for one tool call to complete before calling the next. This applies to all tools: file reads, searches, and terminal commands.
|
||||||
|
13. **Every implementation task must end with `npm run build`, `npm run test:cov`, and `npm run lint` all passing.** Always use `test:cov` (not `test`) to enforce coverage thresholds. If coverage fails, write additional tests before considering the task done.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
|
||||||
139
docs/PLAN.md
139
docs/PLAN.md
|
|
@ -26,33 +26,97 @@ MeshiTrack helps households manage medicines and food. Medicine tracking comes f
|
||||||
| **Monorepo** | Turborepo 2 |
|
| **Monorepo** | Turborepo 2 |
|
||||||
| **Runtime** | Node.js 22+ (ESM-only) |
|
| **Runtime** | Node.js 22+ (ESM-only) |
|
||||||
|
|
||||||
|
## Phase Completion Criteria (Mandatory)
|
||||||
|
|
||||||
|
Every phase MUST deliver ALL of the following before being considered complete. No phase may be marked done if any layer is missing.
|
||||||
|
|
||||||
|
### 1. Shared Package (`packages/shared`)
|
||||||
|
|
||||||
|
- [ ] Types defined in `src/types/`
|
||||||
|
- [ ] Enums defined in `src/enums/` with unit tests
|
||||||
|
- [ ] Zod validation schemas in `src/validation/` with unit tests
|
||||||
|
- [ ] Barrel exports updated in `index.ts`
|
||||||
|
- [ ] 100% line/function/statement coverage, 90% branch coverage
|
||||||
|
|
||||||
|
### 2. API Package (`packages/api`)
|
||||||
|
|
||||||
|
- [ ] Mongoose schema in `src/schemas/`
|
||||||
|
- [ ] Repository with `.lean().exec()` on all reads
|
||||||
|
- [ ] Service with business logic
|
||||||
|
- [ ] Route plugin registered in `main.ts`
|
||||||
|
- [ ] Unit tests for repository, service, and routes (100% coverage)
|
||||||
|
- [ ] All queries scoped to `householdId`
|
||||||
|
|
||||||
|
### 3. Web Package (`packages/web`)
|
||||||
|
|
||||||
|
- [ ] API client service in `src/services/` with unit tests
|
||||||
|
- [ ] Page(s) in `src/app/(dashboard)/` implementing the UI spec
|
||||||
|
- [ ] Component tests (React Testing Library) for every page and interactive component
|
||||||
|
- [ ] Server Components by default; `'use client'` only where interactivity is needed
|
||||||
|
|
||||||
|
### 4. CI Gate (must pass before phase sign-off)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # All packages compile
|
||||||
|
npm run test:cov # Coverage thresholds enforced (100/100/100/90)
|
||||||
|
npm run lint # Zero lint errors
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Documentation
|
||||||
|
|
||||||
|
- [ ] Phase spec updated with `[x]` on completed tasks
|
||||||
|
- [ ] Acceptance criteria verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase Overview
|
## Phase Overview
|
||||||
|
|
||||||
### Medicine Tracking (Phases 1-4)
|
### Medicine Tracking (Phases 1-4)
|
||||||
|
|
||||||
| Phase | Name | Description | Doc |
|
| Phase | Name | Description | Status | Doc |
|
||||||
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
| ----- | ------------------------------ | ---------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- |
|
||||||
| 0 | Foundation & Infrastructure | Repo scaffolding, Docker, auth | [phase-0-foundation.md](phases/phase-0-foundation.md) |
|
| 0 | Foundation & Infrastructure | Repo scaffolding, Docker, auth | DONE | [phase-0-foundation.md](phases/phase-0-foundation.md) |
|
||||||
| 1 | Medicine Library | Searchable medicine catalog with dosage/form info | [phase-1-medicine-library.md](phases/phase-1-medicine-library.md) |
|
| 1 | Medicine Library | Searchable medicine catalog with dosage/form info | DONE | [phase-1-medicine-library.md](phases/phase-1-medicine-library.md) |
|
||||||
| 2 | Medicine Cabinet | Inventory tracking with quantity, expiry, low-stock alerts | [phase-2-medicine-cabinet.md](phases/phase-2-medicine-cabinet.md) |
|
| 2 | Medicine Cabinet | Inventory tracking with quantity, expiry, low-stock alerts | DONE | [phase-2-medicine-cabinet.md](phases/phase-2-medicine-cabinet.md) |
|
||||||
| 3 | Regimens & Pill Organizer | Daily medication schedules, batch-dispense, burn rate | [phase-3-regimens-pill-organizer.md](phases/phase-3-regimens-pill-organizer.md) |
|
| 3 | Regimens & Pill Organizer | Daily medication schedules, batch-dispense, burn rate | DONE | [phase-3-regimens-pill-organizer.md](phases/phase-3-regimens-pill-organizer.md) |
|
||||||
| 4 | Pharmacies, Prices & Refills | Shared store infrastructure, price tracking, refill alerts | [phase-4-pharmacies-prices-refills.md](phases/phase-4-pharmacies-prices-refills.md) |
|
| 4 | Pharmacies, Prices & Refills | Shared store infrastructure, price tracking, refill alerts | DONE | [phase-4-pharmacies-prices-refills.md](phases/phase-4-pharmacies-prices-refills.md) |
|
||||||
|
|
||||||
### Food Tracking (Phases 5-9)
|
### Food Tracking (Phases 5-9)
|
||||||
|
|
||||||
| Phase | Name | Description | Doc |
|
| Phase | Name | Description | Status | Doc |
|
||||||
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
| ----- | ------------------------------ | ---------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- |
|
||||||
| 5 | Product Library | Searchable food product catalog with nutrition data | [phase-5-product-library.md](phases/phase-5-product-library.md) |
|
| 5 | Product Library | Searchable food product catalog with nutrition data | DONE | [phase-5-product-library.md](phases/phase-5-product-library.md) |
|
||||||
| 6 | Recipe Management | Recipe CRUD, nutrition auto-calculation, import | [phase-6-recipes.md](phases/phase-6-recipes.md) |
|
| 6 | Recipe Management | Recipe CRUD, nutrition auto-calculation, import | DONE | [phase-6-recipes.md](phases/phase-6-recipes.md) |
|
||||||
| 7 | Pantry & Fridge Tracking | Track item lifecycle, freshness, spoilage estimation | [phase-7-pantry.md](phases/phase-7-pantry.md) |
|
| 7 | Pantry & Fridge Tracking | Track item lifecycle, freshness, spoilage estimation | DONE | [phase-7-pantry.md](phases/phase-7-pantry.md) |
|
||||||
| 8 | Meal Planning & Waste Reduction| Suggest meals from pantry, nutrition targets, weekly planning | [phase-8-meal-planning.md](phases/phase-8-meal-planning.md) |
|
| 8 | Meal Planning & Waste Reduction| Suggest meals from pantry, nutrition targets, weekly planning | NOT STARTED | [phase-8-meal-planning.md](phases/phase-8-meal-planning.md) |
|
||||||
| 9 | Grocery & Price Tracking | Shopping lists, price analytics, store comparison (reuses Phase 4 stores) | [phase-9-grocery.md](phases/phase-9-grocery.md) |
|
| 9 | Grocery & Price Tracking | Shopping lists, price analytics, store comparison (reuses Phase 4 stores) | NOT STARTED | [phase-9-grocery.md](phases/phase-9-grocery.md) |
|
||||||
|
|
||||||
### Shared (Phase 10)
|
### Shared (Phase 10)
|
||||||
|
|
||||||
| Phase | Name | Description | Doc |
|
| Phase | Name | Description | Status | Doc |
|
||||||
| ----- | ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
| ----- | ------------------------------ | ---------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- |
|
||||||
| 10 | LLM Integration & Smart Features | Wire up LLM providers, enable smart features across both domains | [phase-10-llm.md](phases/phase-10-llm.md) |
|
| 10 | LLM Integration & Smart Features | Wire up LLM providers, enable smart features across both domains | PARTIAL | [phase-10-llm.md](phases/phase-10-llm.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status & Gaps
|
||||||
|
|
||||||
|
### Phase 8 — Meal Planning (Next Priority)
|
||||||
|
|
||||||
|
The foundation for food tracking (Phases 5-7) is fully implemented and integrated. The next major milestone is Phase 8, which introduces the meal planning calendar and the algorithmic suggestion engine.
|
||||||
|
|
||||||
|
**Immediate Goals:**
|
||||||
|
- Implement `MealPlan` and `NutritionTarget` schemas.
|
||||||
|
- Build the `SuggestionEngineService` for pantry-based recipe ranking.
|
||||||
|
- Create the weekly calendar UI with drag-and-drop support.
|
||||||
|
|
||||||
|
### Phase 10 — LLM (PARTIAL)
|
||||||
|
|
||||||
|
The `ILlmProvider` interface and `NoOpLlmProvider` are implemented. Actual provider implementations and smart features are deferred.
|
||||||
|
|
||||||
|
### Lessons Learned
|
||||||
|
|
||||||
|
Previous phases (6 & 7) were completed without finishing Phase 5's full stack (API routes + web UI). This created a gap where the Product Library has no user-facing interface despite being consumed internally. **Going forward, every phase must deliver its complete vertical slice (shared -> API -> web -> tests) before the next phase starts.**
|
||||||
|
|
||||||
## Cross-Cutting Concerns
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
|
@ -121,8 +185,39 @@ MeshiTrack/
|
||||||
|
|
||||||
## Verification Strategy
|
## Verification Strategy
|
||||||
|
|
||||||
- **Per-phase**: each phase ends with a working `docker compose up` that demos the new feature
|
### Per-Phase Gate (Blocking)
|
||||||
- **Integration**: Postman/Bruno collection maintained alongside API development
|
|
||||||
- **E2E smoke test (medicine)**: automated script that creates a user, adds medicines, stocks cabinet, creates regimen, fills organizer, checks refill alerts
|
Every phase must pass these checks before sign-off:
|
||||||
- **E2E smoke test (food)**: automated script that adds products, creates a recipe, stocks the pantry, generates a meal plan, and creates a shopping list
|
|
||||||
- **Performance**: MongoDB indexes reviewed per phase; query profiling before phase sign-off
|
1. **Build**: `npm run build` succeeds (all packages compile)
|
||||||
|
2. **Coverage**: `npm run test:cov` passes with thresholds:
|
||||||
|
- `packages/api`: 100% lines/functions/statements, 90% branches
|
||||||
|
- `packages/shared`: 100% lines/functions/statements, 90% branches
|
||||||
|
- `packages/web`: all component tests pass
|
||||||
|
3. **Lint**: `npm run lint` reports zero errors
|
||||||
|
4. **Web UI Exists**: every phase that adds API endpoints also adds corresponding web pages with component tests
|
||||||
|
5. **Services Tested**: every `*.service.ts` in web has a corresponding `*.test.ts`
|
||||||
|
|
||||||
|
### Per-Phase Deliverable Checklist
|
||||||
|
|
||||||
|
For each phase, the task breakdown must follow this template order:
|
||||||
|
|
||||||
|
```
|
||||||
|
X.1 — Shared Types & Validation (+ tests)
|
||||||
|
X.2 — Mongoose Schema & Repository (+ tests)
|
||||||
|
X.3 — Service (+ tests)
|
||||||
|
X.4 — Routes (+ tests)
|
||||||
|
X.5 — Web: API Client Service (+ tests)
|
||||||
|
X.6 — Web: Pages & Components (+ component tests)
|
||||||
|
X.7 — CI Verification (build + test:cov + lint)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
- Postman/Bruno collection maintained alongside API development
|
||||||
|
- E2E smoke test (medicine): creates user, adds medicines, stocks cabinet, creates regimen, fills organizer, checks refill alerts
|
||||||
|
- E2E smoke test (food): adds products, creates recipe, stocks pantry, generates meal plan, creates shopping list
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- MongoDB indexes reviewed per phase; query profiling before phase sign-off
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,19 @@ Separate each group with a blank line.
|
||||||
| React hook | camelCase | `useProducts`, `usePantryItems` |
|
| React hook | camelCase | `useProducts`, `usePantryItems` |
|
||||||
| CSS class (Tailwind) | kebab-case | via Tailwind utilities |
|
| CSS class (Tailwind) | kebab-case | via Tailwind utilities |
|
||||||
|
|
||||||
|
## Implementation Workflow (Vertical Slice)
|
||||||
|
|
||||||
|
Every feature or phase implementation must follow this order to ensure consistency and type safety across the monorepo:
|
||||||
|
|
||||||
|
1. **Shared Layer (`packages/shared`)**: Define types, enums, and Zod schemas. Add unit tests.
|
||||||
|
2. **Database Layer (`packages/api`)**: Create Mongoose schema and Repository. Use `.lean().exec()` on all reads. Add integration tests.
|
||||||
|
3. **Service Layer (`packages/api`)**: Implement business logic using Awilix DI. Add unit tests.
|
||||||
|
4. **Route Layer (`packages/api`)**: Create Fastify route plugin. Add route tests.
|
||||||
|
5. **Web API Client (`packages/web`)**: Implement frontend service. Add unit tests.
|
||||||
|
6. **Web UI (`packages/web`)**: Create Next.js pages/components (Server Components by default). Add component tests.
|
||||||
|
|
||||||
|
**Mandatory Verification**: Every task must end with `npm run build`, `npm run test:cov`, and `npm run lint` all passing with the defined coverage thresholds.
|
||||||
|
|
||||||
## Code Organization Rules
|
## Code Organization Rules
|
||||||
|
|
||||||
### API (NestJS)
|
### API (NestJS)
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,8 @@ interface PaginatedResponse<T> {
|
||||||
- `ImportProductsSchema` — array of `CreateProductSchema` (for JSON import)
|
- `ImportProductsSchema` — array of `CreateProductSchema` (for JSON import)
|
||||||
- All schemas imported from `'zod/v4'`; enums use `z.enum(Object.values(...))`.
|
- All schemas imported from `'zod/v4'`; enums use `z.enum(Object.values(...))`.
|
||||||
|
|
||||||
|
**Status**: DONE (types, enums, validation schemas all exist with tests)
|
||||||
|
|
||||||
### 5.2 — Mongoose Schema & Repository
|
### 5.2 — Mongoose Schema & Repository
|
||||||
|
|
||||||
- `packages/api/src/modules/products/schemas/product.schema.ts` — Mongoose schema with `timestamps: true`, `deletedAt` index, partial unique index on `(householdId, barcode)`.
|
- `packages/api/src/modules/products/schemas/product.schema.ts` — Mongoose schema with `timestamps: true`, `deletedAt` index, partial unique index on `(householdId, barcode)`.
|
||||||
|
|
@ -195,7 +197,35 @@ interface PaginatedResponse<T> {
|
||||||
- `bulkCreate(householdId, items[])` — uses `insertMany` with `ordered: false`
|
- `bulkCreate(householdId, items[])` — uses `insertMany` with `ordered: false`
|
||||||
- All read queries use `.lean().exec()`.
|
- All read queries use `.lean().exec()`.
|
||||||
|
|
||||||
### 5.3 — Barcode Lookup Service
|
**Status**: DONE (repository exists with tests)
|
||||||
|
|
||||||
|
### 5.3 — Products Service
|
||||||
|
|
||||||
|
- `ProductsService` with business logic:
|
||||||
|
- Dedup check on create (same name + brand within household)
|
||||||
|
- Barcode collision check (409 ConflictError)
|
||||||
|
- Soft-delete with `deletedAt` timestamp
|
||||||
|
- Coordinate barcode lookup (call BarcodeService)
|
||||||
|
- Coordinate import (validate + bulkCreate)
|
||||||
|
- Smart-add (call LLM provider)
|
||||||
|
|
||||||
|
**Status**: NOT STARTED
|
||||||
|
|
||||||
|
### 5.4 — Products Routes
|
||||||
|
|
||||||
|
- Route plugin registered via `fp()`:
|
||||||
|
- `GET /products` — list/search (paginated)
|
||||||
|
- `GET /products/:id` — get single
|
||||||
|
- `POST /products` — create
|
||||||
|
- `PATCH /products/:id` — update
|
||||||
|
- `DELETE /products/:id` — soft-delete (admin)
|
||||||
|
- `GET /products/barcode/:code` — barcode lookup
|
||||||
|
- `POST /products/import` — bulk import
|
||||||
|
- `POST /products/smart-add` — LLM placeholder
|
||||||
|
|
||||||
|
**Status**: NOT STARTED
|
||||||
|
|
||||||
|
### 5.5 — Barcode Lookup Service
|
||||||
|
|
||||||
- `BarcodeService`:
|
- `BarcodeService`:
|
||||||
- First check local DB for matching barcode (per household)
|
- First check local DB for matching barcode (per household)
|
||||||
|
|
@ -208,7 +238,9 @@ interface PaginatedResponse<T> {
|
||||||
- Failures (network, 404, malformed): return `{ found: false }`; do not throw
|
- Failures (network, 404, malformed): return `{ found: false }`; do not throw
|
||||||
- Outbound HTTP via `undici` with a 5s timeout and a configurable User-Agent (`MeshiTrack/<version> (+self-hosted)`)
|
- Outbound HTTP via `undici` with a 5s timeout and a configurable User-Agent (`MeshiTrack/<version> (+self-hosted)`)
|
||||||
|
|
||||||
### 5.4 — LLM Provider Interface
|
**Status**: NOT STARTED
|
||||||
|
|
||||||
|
### 5.6 — LLM Provider Interface
|
||||||
|
|
||||||
- `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`:
|
- `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`:
|
||||||
|
|
||||||
|
|
@ -231,14 +263,18 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
- `NoOpLlmProvider`: implements interface, returns `null` for all methods, logs a warning
|
- `NoOpLlmProvider`: implements interface, returns `null` for all methods, logs a warning
|
||||||
- `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE`
|
- `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE`
|
||||||
|
|
||||||
### 5.5 — Smart Add Endpoint
|
**Status**: DONE (interface + NoOp provider exist with tests)
|
||||||
|
|
||||||
|
### 5.7 — Smart Add Endpoint
|
||||||
|
|
||||||
- `POST /products/smart-add` accepts `{ text?: string, image?: file }`
|
- `POST /products/smart-add` accepts `{ text?: string, image?: file }`
|
||||||
- Calls `ILlmProvider.extractNutrition()`
|
- Calls `ILlmProvider.extractNutrition()`
|
||||||
- If LLM returns data, pre-fill a product and return to client for review (not auto-saved)
|
- If LLM returns data, pre-fill a product and return to client for review (not auto-saved)
|
||||||
- If LLM unavailable (`NoOpLlmProvider`), return `{ available: false, message: 'LLM not configured' }`
|
- If LLM unavailable (`NoOpLlmProvider`), return `{ available: false, message: 'LLM not configured' }`
|
||||||
|
|
||||||
### 5.6 — Import Endpoint
|
**Status**: NOT STARTED (blocked on 5.3/5.4)
|
||||||
|
|
||||||
|
### 5.8 — Import Endpoint
|
||||||
|
|
||||||
- `POST /products/import` accepts multipart CSV or JSON file (max 5 MB, 5000 rows)
|
- `POST /products/import` accepts multipart CSV or JSON file (max 5 MB, 5000 rows)
|
||||||
- Validate each row against `CreateProductSchema`; reject rows with imperial `servingUnit` values with a clear error message
|
- Validate each row against `CreateProductSchema`; reject rows with imperial `servingUnit` values with a clear error message
|
||||||
|
|
@ -248,7 +284,16 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
- `tags` is a `;`-separated list
|
- `tags` is a `;`-separated list
|
||||||
- `servingUnit` ∈ `{g, ml, piece, slice}`
|
- `servingUnit` ∈ `{g, ml, piece, slice}`
|
||||||
|
|
||||||
### 5.7 — Web UI: Product Library
|
**Status**: NOT STARTED (blocked on 5.3/5.4)
|
||||||
|
|
||||||
|
### 5.9 — Web: API Client Service
|
||||||
|
|
||||||
|
- `packages/web/src/services/products.ts` — fetch functions for all product endpoints
|
||||||
|
- Unit tests in `packages/web/src/services/__tests__/products.test.ts`
|
||||||
|
|
||||||
|
**Status**: NOT STARTED
|
||||||
|
|
||||||
|
### 5.10 — Web UI: Product Library
|
||||||
|
|
||||||
- `/products` page (Server Component for initial fetch; client island for filters):
|
- `/products` page (Server Component for initial fetch; client island for filters):
|
||||||
- Search bar with debounced full-text search (300ms)
|
- Search bar with debounced full-text search (300ms)
|
||||||
|
|
@ -263,6 +308,15 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
- Barcode field with "Lookup" button (calls `/products/barcode/:code`)
|
- Barcode field with "Lookup" button (calls `/products/barcode/:code`)
|
||||||
- "Smart Add" tab (text input or image upload)
|
- "Smart Add" tab (text input or image upload)
|
||||||
- Import dialog: file upload with preview, row count, and error display
|
- Import dialog: file upload with preview, row count, and error display
|
||||||
|
- Component tests for page and interactive components
|
||||||
|
|
||||||
|
**Status**: NOT STARTED
|
||||||
|
|
||||||
|
### 5.11 — CI Verification
|
||||||
|
|
||||||
|
- `npm run build` passes
|
||||||
|
- `npm run test:cov` passes (100% lines/functions/statements, 90% branches)
|
||||||
|
- `npm run lint` passes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -276,10 +330,12 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
- [ ] Barcode collisions within a household return 409
|
- [ ] Barcode collisions within a household return 409
|
||||||
- [ ] Bulk import processes a CSV with 100+ products and reports per-row errors
|
- [ ] Bulk import processes a CSV with 100+ products and reports per-row errors
|
||||||
- [ ] Web UI allows searching, filtering, adding, and editing products
|
- [ ] Web UI allows searching, filtering, adding, and editing products
|
||||||
|
- [ ] Web UI has component tests for all pages and interactive components
|
||||||
- [ ] `ILlmProvider` interface is defined and injectable
|
- [ ] `ILlmProvider` interface is defined and injectable
|
||||||
- [ ] Smart Add endpoint returns graceful `{ available: false }` with the NoOp provider
|
- [ ] Smart Add endpoint returns graceful `{ available: false }` with the NoOp provider
|
||||||
- [ ] All product queries are scoped to `householdId`
|
- [ ] All product queries are scoped to `householdId`
|
||||||
- [ ] Unit + integration tests meet coverage targets (100% lines/functions/statements, 90% branches)
|
- [ ] Unit + integration tests meet coverage targets (100% lines/functions/statements, 90% branches)
|
||||||
|
- [ ] `npm run build`, `npm run test:cov`, and `npm run lint` all pass
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,264 +6,99 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deliverables
|
## Implementation Workflow (Vertical Slice)
|
||||||
|
|
||||||
1. `MealPlan` and `NutritionTarget` MongoDB schemas
|
Following the project's standard workflow, Phase 8 will be implemented in the following order:
|
||||||
2. Meal plan CRUD API with daily/weekly views
|
|
||||||
3. Recipe suggestion engine (algorithmic, not LLM-dependent)
|
### 8.1 — Shared Layer: Types & Validation
|
||||||
4. Nutrition targets per user with daily tracking
|
- **Types**: Define `MealPlan` and `NutritionTarget` in `packages/shared/src/types/`.
|
||||||
5. Shopping gap analysis (what's needed beyond pantry)
|
- **Enums**: Define `MealType` and `MealPlanStatus` in `packages/shared/src/enums/`.
|
||||||
6. Meal plan web UI with weekly calendar and suggestion panel
|
- **Validation**: Create Zod schemas in `packages/shared/src/validation/`:
|
||||||
|
- `CreateMealPlanSchema`: Ensures 7 days, valid `weekStartDate`.
|
||||||
|
- `UpdateMealPlanSchema`: Supports granular updates (add/move/remove meal).
|
||||||
|
- `NutritionTargetSchema`: Validates daily macros and calorie goals.
|
||||||
|
- **Unit Tests**: Test Zod schemas for all edge cases (e.g., negative calories, overlapping weeks).
|
||||||
|
|
||||||
|
### 8.2 — Database Layer: Repositories
|
||||||
|
- **MealPlanRepository**:
|
||||||
|
- `findByHousehold(householdId, weekStartDate)`: Unique constraint per household per week.
|
||||||
|
- `findCurrent(householdId)`: Returns the plan for the current calendar week.
|
||||||
|
- `updateMeal(id, dayIndex, mealId, data)`: Granular update for a specific meal slot.
|
||||||
|
- **NutritionTargetRepository**:
|
||||||
|
- `findByUser(userId, householdId)`: Get active targets for a specific user.
|
||||||
|
- `upsert(userId, householdId, data)`: Update or create targets.
|
||||||
|
- **Integration Tests**: Verify MongoDB unique indexes and household scoping.
|
||||||
|
|
||||||
|
### 8.3 — Service Layer: Suggestion Engine & Planning
|
||||||
|
- **MealPlanService**:
|
||||||
|
- Manage CRUD operations and status transitions (Draft -> Active).
|
||||||
|
- Compute `dailyNutritionTotal` dynamically using the `NutritionCalculatorService` from Phase 6.
|
||||||
|
- **NutritionTargetService**:
|
||||||
|
- Manage user-specific nutritional goals and presets (Maintenance, Loss, Gain).
|
||||||
|
- **SuggestionEngineService** (Core Algorithm):
|
||||||
|
- **Scoring Algorithm**:
|
||||||
|
- `IngredientCoverage` (40%): Matches recipe ingredients vs current pantry items.
|
||||||
|
- `FreshnessUrgency` (30%): Bonus for using items with `urgency: urgent` or `expiringSoon`.
|
||||||
|
- `NutritionBalance` (20%): Complements existing meals in the day vs user targets.
|
||||||
|
- `Variety` (10%): Penalizes recipes used in the last 14 days.
|
||||||
|
- Uses `RecipesService` to fetch catalog and `PantryService` to fetch current inventory.
|
||||||
|
- **ShoppingGapService**:
|
||||||
|
- Analyze meal plans vs pantry to identify missing ingredients for Phase 9 integration.
|
||||||
|
- **Unit Tests**: Test scoring weights and ingredient matching logic (100% coverage required).
|
||||||
|
|
||||||
|
### 8.4 — Route Layer: API Endpoints
|
||||||
|
- **MealPlanRoutes**:
|
||||||
|
- `GET /api/v1/households/:householdId/meal-plans`: List plans.
|
||||||
|
- `GET /api/v1/households/:householdId/meal-plans/current`: Get current week.
|
||||||
|
- `POST /api/v1/households/:householdId/meal-plans`: Create plan.
|
||||||
|
- `PATCH /api/v1/households/:householdId/meal-plans/:id`: Update structure.
|
||||||
|
- `GET /api/v1/households/:householdId/meal-plans/suggestions`: Get algorithmic suggestions.
|
||||||
|
- **NutritionTargetRoutes**:
|
||||||
|
- `GET /api/v1/households/:householdId/nutrition-targets`: Get user targets.
|
||||||
|
- `POST /api/v1/households/:householdId/nutrition-targets`: Set targets.
|
||||||
|
- **Integration Tests**: Verify all endpoints return correctly shaped responses (using `toProductResponse` style mappers).
|
||||||
|
|
||||||
|
### 8.5 — Web Layer: API Client & Components
|
||||||
|
- **API Client**: Implement `meal-plans.ts` and `nutrition-targets.ts` in `packages/web/src/services/`.
|
||||||
|
- **Hooks**: Create `useMealPlan` and `useSuggestions` hooks for state management.
|
||||||
|
- **Components**:
|
||||||
|
- `CalendarGrid`: Weekly view using CSS Grid and drag-and-drop.
|
||||||
|
- `NutritionProgress`: Daily progress bars vs targets.
|
||||||
|
- `SuggestionPanel`: Sidebar showing ranked recipes with "reasoning" tooltips.
|
||||||
|
- `ShoppingGapView`: Breakdown of missing items.
|
||||||
|
- **Component Tests**: Use React Testing Library to verify drag-and-drop and progress bar calculations.
|
||||||
|
|
||||||
|
### 8.6 — Verification Gate
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run test:cov` (100% API/Shared, 90% Web thresholds)
|
||||||
|
- `npm run lint`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Model
|
## Technical Details
|
||||||
|
|
||||||
### MealPlan Schema
|
|
||||||
|
|
||||||
|
### Scoring Algorithm Constants
|
||||||
```typescript
|
```typescript
|
||||||
// packages/shared/src/types/meal-plan.ts
|
const WEIGHTS = {
|
||||||
export interface MealPlan {
|
COVERAGE: 0.4, // Do I have the items?
|
||||||
id: string;
|
URGENCY: 0.3, // Should I use these items now?
|
||||||
householdId: string;
|
NUTRITION: 0.2, // Does it fit my macros?
|
||||||
weekStartDate: Date; // Monday of the planning week
|
VARIETY: 0.1 // Have I eaten this too much?
|
||||||
days: MealPlanDay[];
|
|
||||||
status: MealPlanStatus;
|
|
||||||
shoppingListId?: string; // Auto-generated shopping list (Phase 9 link)
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MealPlanDay {
|
|
||||||
date: Date;
|
|
||||||
meals: PlannedMeal[];
|
|
||||||
dailyNutritionTotal: NutritionInfo; // Computed
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlannedMeal {
|
|
||||||
id: string; // UUID for drag-and-drop reference
|
|
||||||
type: MealType;
|
|
||||||
recipeId?: string; // Linked recipe
|
|
||||||
recipeName: string; // Denormalized
|
|
||||||
servings: number;
|
|
||||||
customName?: string; // For non-recipe meals
|
|
||||||
customNutrition?: NutritionInfo; // Manual override for non-recipe meals
|
|
||||||
perServingNutrition: NutritionInfo; // From recipe or custom
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum MealType {
|
|
||||||
BREAKFAST = 'breakfast',
|
|
||||||
LUNCH = 'lunch',
|
|
||||||
DINNER = 'dinner',
|
|
||||||
SNACK = 'snack',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum MealPlanStatus {
|
|
||||||
DRAFT = 'draft',
|
|
||||||
ACTIVE = 'active',
|
|
||||||
COMPLETED = 'completed',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### NutritionTarget Schema
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// packages/shared/src/types/nutrition-target.ts
|
|
||||||
export interface NutritionTarget {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
householdId: string;
|
|
||||||
dailyCalories: number;
|
|
||||||
proteinG: number;
|
|
||||||
carbsG: number;
|
|
||||||
fatG: number;
|
|
||||||
fiberG?: number;
|
|
||||||
sodiumMg?: number;
|
|
||||||
sugarG?: number;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### MongoDB Indexes
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{ householdId: 1, weekStartDate: 1 } // unique per household per week
|
|
||||||
{ householdId: 1, status: 1 }
|
|
||||||
{ householdId: 1, 'days.meals.recipeId': 1 } // Find plans using a recipe
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### MealPlanModule
|
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
|
||||||
| ------ | ------------------------------ | ---------------------------------------------- | ------ |
|
|
||||||
| GET | `/meal-plans` | List meal plans (paginated) | member |
|
|
||||||
| GET | `/meal-plans/current` | Get current week's plan | member |
|
|
||||||
| GET | `/meal-plans/:id` | Get specific plan | member |
|
|
||||||
| POST | `/meal-plans` | Create new week plan | member |
|
|
||||||
| PATCH | `/meal-plans/:id` | Update plan (add/move/remove meals) | member |
|
|
||||||
| DELETE | `/meal-plans/:id` | Delete plan (draft only) | admin |
|
|
||||||
| POST | `/meal-plans/:id/activate` | Set plan as active | member |
|
|
||||||
| GET | `/meal-plans/:id/shopping-gap` | What's needed beyond current pantry | member |
|
|
||||||
| GET | `/meal-plans/suggestions` | Get recipe suggestions for current pantry | member |
|
|
||||||
| POST | `/meal-plans/suggest-with-llm` | LLM-powered meal plan generation (placeholder) | member |
|
|
||||||
|
|
||||||
### NutritionTargetModule
|
|
||||||
|
|
||||||
| Method | Path | Description | Auth |
|
|
||||||
| ------ | ------------------------ | -------------------------------- | ------ |
|
|
||||||
| GET | `/nutrition-targets` | Get current user's active target | member |
|
|
||||||
| POST | `/nutrition-targets` | Set nutrition targets | member |
|
|
||||||
| PATCH | `/nutrition-targets/:id` | Update targets | member |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### 8.1 — Shared Types & Validation
|
|
||||||
|
|
||||||
- Add all types above to `packages/shared`
|
|
||||||
- Zod schemas for create/update operations
|
|
||||||
- Meal plan day validation (7 days per plan, valid dates)
|
|
||||||
|
|
||||||
### 8.2 — Meal Plan CRUD
|
|
||||||
|
|
||||||
- Standard CRUD with computed `dailyNutritionTotal` per day:
|
|
||||||
- Sum `perServingNutrition * servings` for all meals in each day
|
|
||||||
- On meal plan creation: default to 7 empty days (Monday–Sunday)
|
|
||||||
- On update: support granular operations:
|
|
||||||
- `addMeal(dayIndex, meal)`
|
|
||||||
- `removeMeal(dayIndex, mealId)`
|
|
||||||
- `moveMeal(fromDay, toDay, mealId)` — for drag-and-drop
|
|
||||||
- `updateMeal(dayIndex, mealId, updates)`
|
|
||||||
|
|
||||||
### 8.3 — Recipe Suggestion Engine (Core Algorithm)
|
|
||||||
|
|
||||||
This is the **key differentiating feature** — algorithmic, no LLM required.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class RecipeSuggestionService {
|
|
||||||
/**
|
|
||||||
* Score and rank recipes based on pantry state and user preferences.
|
|
||||||
*
|
|
||||||
* Input:
|
|
||||||
* - Current pantry items (with freshness urgency)
|
|
||||||
* - Recipe catalog for the household
|
|
||||||
* - User's nutrition targets (optional)
|
|
||||||
* - Already planned meals this week (to avoid repetition)
|
|
||||||
*
|
|
||||||
* Scoring per recipe:
|
|
||||||
* ingredientCoverageScore (0-40 pts): % of ingredients available in pantry
|
|
||||||
* freshnessUrgencyScore (0-30 pts): bonus for using urgent/use-soon items
|
|
||||||
* nutritionBalanceScore (0-20 pts): how well it complements the day's existing meals vs targets
|
|
||||||
* varietyScore (0-10 pts): penalty for recently planned recipes
|
|
||||||
*
|
|
||||||
* Output per suggestion:
|
|
||||||
* - recipe (id, name, perServingNutrition)
|
|
||||||
* - score (total)
|
|
||||||
* - availableIngredients[]: items from pantry that match
|
|
||||||
* - missingIngredients[]: items not in pantry (with estimated cost from Phase 9 if available)
|
|
||||||
* - urgentIngredients[]: pantry items with urgency=urgent that this recipe would use
|
|
||||||
* - reasoning: human-readable explanation of why this recipe was suggested
|
|
||||||
*/
|
|
||||||
suggestRecipes(context: SuggestionContext): Promise<RecipeSuggestion[]>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ingredient matching logic**:
|
|
||||||
|
|
||||||
- Match recipe ingredient's `productId` against pantry items with `status: sealed|opened`
|
|
||||||
- Check quantity: is there enough? (approximate — compare units, flag if unclear)
|
|
||||||
- Prefer items with higher freshness urgency
|
|
||||||
|
|
||||||
**Scoring weights** (configurable per household):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const DEFAULT_WEIGHTS = {
|
|
||||||
ingredientCoverage: 40,
|
|
||||||
freshnessUrgency: 30,
|
|
||||||
nutritionBalance: 20,
|
|
||||||
variety: 10,
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.4 — Shopping Gap Analysis
|
### Integration Points
|
||||||
|
- **PantryService**: Fetch items where `status` is `sealed` or `opened`.
|
||||||
- `GET /meal-plans/:id/shopping-gap`:
|
- **RecipesService**: Fetch recipes and their computed nutrition/ingredients.
|
||||||
- For each recipe in the meal plan, list required ingredients
|
- **NutritionCalculator**: Reuse for summing daily plan totals.
|
||||||
- Cross-reference with current pantry (available quantity vs needed quantity)
|
|
||||||
- Return:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ShoppingGap {
|
|
||||||
coveredByPantry: ShoppingGapItem[]; // Already have enough
|
|
||||||
needToBuy: ShoppingGapItem[]; // Partially or fully missing
|
|
||||||
pantryItemsUsed: PantryItemUsage[]; // Which pantry items will be consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShoppingGapItem {
|
|
||||||
productId: string;
|
|
||||||
productName: string;
|
|
||||||
totalNeeded: { quantity: number; unit: ServingUnit };
|
|
||||||
availableInPantry: { quantity: number; unit: ServingUnit };
|
|
||||||
shortfall: { quantity: number; unit: ServingUnit };
|
|
||||||
usedInRecipes: string[]; // Recipe names
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- This output feeds directly into Phase 9's auto-generated shopping lists
|
|
||||||
|
|
||||||
### 8.5 — LLM Suggestion Placeholder
|
|
||||||
|
|
||||||
- `POST /meal-plans/suggest-with-llm`:
|
|
||||||
- Builds a context object: pantry summary, nutrition targets, dietary preferences
|
|
||||||
- Calls `ILlmProvider.suggestMealPlan(context)`
|
|
||||||
- With `NoOpLlmProvider`: returns `{ available: false }`
|
|
||||||
- When wired (Phase 10): returns a full week meal plan draft
|
|
||||||
|
|
||||||
### 8.6 — Web UI: Meal Planning
|
|
||||||
|
|
||||||
- `/meal-plans` page:
|
|
||||||
- **Weekly calendar grid**: 7 columns (Mon–Sun) × 4 rows (Breakfast, Lunch, Dinner, Snack)
|
|
||||||
- Each cell: drop zone for recipes, shows meal name + calorie badge
|
|
||||||
- **Drag-and-drop**: drag recipes from suggestion panel or between cells
|
|
||||||
- **Daily nutrition summary row** at bottom: calories, protein, carbs, fat bars
|
|
||||||
- Color-coded vs user's nutrition targets (under = blue, on-target = green, over = red)
|
|
||||||
- **Week navigation**: previous/next week arrows
|
|
||||||
- **Suggestion panel** (sidebar or drawer):
|
|
||||||
- "Suggestions based on your pantry" — ranked list from suggestion engine
|
|
||||||
- Each suggestion shows: recipe name, match score, "Uses: [urgent items]", "Need to buy: [missing items]"
|
|
||||||
- Click to expand: full ingredient match breakdown
|
|
||||||
- "Add to plan" button → pick day + meal type
|
|
||||||
- **Shopping gap tab**: shows what's needed beyond pantry, "Generate shopping list" button (Phase 9 integration)
|
|
||||||
- `/nutrition-targets` settings:
|
|
||||||
- Daily macro targets form (calories, protein, carbs, fat)
|
|
||||||
- Preset templates: "Maintenance", "Weight loss", "Muscle gain", "Custom"
|
|
||||||
- Visual preview: donut chart of macro ratios
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
- [ ] Weekly meal plans can be created and managed via a calendar UI.
|
||||||
- [ ] Can create a weekly meal plan and add meals to specific days/slots
|
- [ ] Drag-and-drop allows moving recipes between days and meal types.
|
||||||
- [ ] Daily nutrition totals are computed and displayed
|
- [ ] The suggestion engine prioritizes recipes using urgent pantry items.
|
||||||
- [ ] Suggestion engine returns ranked recipes based on pantry state
|
- [ ] Daily nutrition bars reflect progress against user-defined macro targets.
|
||||||
- [ ] Suggestions prioritize recipes using soon-to-expire pantry items
|
- [ ] Shopping gap analysis identifies missing ingredients for the planned week.
|
||||||
- [ ] Shopping gap analysis correctly identifies missing ingredients
|
- [ ] Full unit and integration test coverage as per project standards.
|
||||||
- [ ] Drag-and-drop works in the weekly calendar UI
|
- [ ] Build, Lint, and Coverage checks all pass.
|
||||||
- [ ] Nutrition targets can be set per user
|
|
||||||
- [ ] Daily nutrition bars show progress vs targets
|
|
||||||
- [ ] Variety scoring penalizes recently used recipes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estimated Effort
|
|
||||||
|
|
||||||
Large. The suggestion engine scoring algorithm, shopping gap analysis, and calendar UI with drag-and-drop are all significant features.
|
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,14 @@ interface StoreComparisonResult {
|
||||||
- Match items to products (fuzzy), match store to stores
|
- Match items to products (fuzzy), match store to stores
|
||||||
- With `NoOpLlmProvider`: returns `{ available: false }`
|
- With `NoOpLlmProvider`: returns `{ available: false }`
|
||||||
|
|
||||||
### 9.10 — Web UI: Grocery Management
|
### 9.10 — Web: API Client Services
|
||||||
|
|
||||||
|
- `packages/web/src/services/shopping-lists.ts` — fetch functions for shopping list endpoints
|
||||||
|
- `packages/web/src/services/prices.ts` — fetch functions for price record endpoints
|
||||||
|
- Unit tests in `packages/web/src/services/__tests__/shopping-lists.test.ts`
|
||||||
|
- Unit tests in `packages/web/src/services/__tests__/prices.test.ts`
|
||||||
|
|
||||||
|
### 9.11 — Web UI: Grocery Management
|
||||||
|
|
||||||
- `/shopping-lists` page:
|
- `/shopping-lists` page:
|
||||||
- Active lists at top, completed/archived below
|
- Active lists at top, completed/archived below
|
||||||
|
|
@ -326,16 +333,23 @@ interface StoreComparisonResult {
|
||||||
- Each item: checkbox, name, quantity, estimated price
|
- Each item: checkbox, name, quantity, estimated price
|
||||||
- Check off: expand to enter actual price (optional)
|
- Check off: expand to enter actual price (optional)
|
||||||
- Real-time sync indicator ("2 members shopping")
|
- Real-time sync indicator ("2 members shopping")
|
||||||
- "Done Shopping" button → prompts "Add items to pantry?"
|
- "Done Shopping" button — prompts "Add items to pantry?"
|
||||||
- `/stores` page:
|
- `/stores` page:
|
||||||
- Store list with CRUD
|
- Store list with CRUD (already exists from Phase 4, extend if needed)
|
||||||
- Per-store: total spent, last visit, product count
|
- Per-store: total spent, last visit, product count
|
||||||
- `/prices` page (analytics):
|
- `/prices` page (analytics):
|
||||||
- Product search → price history line chart (per store, color-coded)
|
- Product search — price history line chart (per store, color-coded)
|
||||||
- Store comparison table
|
- Store comparison table
|
||||||
- Spending over time bar chart
|
- Spending over time bar chart
|
||||||
- Price alerts panel
|
- Price alerts panel
|
||||||
- **Shopping list widget on dashboard**: shows active lists with quick-check functionality
|
- **Shopping list widget on dashboard**: shows active lists with quick-check functionality
|
||||||
|
- Component tests for all pages and interactive components
|
||||||
|
|
||||||
|
### 9.12 — CI Verification
|
||||||
|
|
||||||
|
- `npm run build` passes
|
||||||
|
- `npm run test:cov` passes (100% lines/functions/statements, 90% branches)
|
||||||
|
- `npm run lint` passes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -349,6 +363,8 @@ interface StoreComparisonResult {
|
||||||
- [ ] Price analytics show spending trends and alerts
|
- [ ] Price analytics show spending trends and alerts
|
||||||
- [ ] Auto-generated lists from meal plans correctly reflect shopping gap
|
- [ ] Auto-generated lists from meal plans correctly reflect shopping gap
|
||||||
- [ ] Receipt parsing endpoint delegates to LLM provider
|
- [ ] Receipt parsing endpoint delegates to LLM provider
|
||||||
|
- [ ] Web UI has component tests for all pages and interactive components
|
||||||
|
- [ ] `npm run build`, `npm run test:cov`, and `npm run lint` all pass
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
392
package-lock.json
generated
392
package-lock.json
generated
|
|
@ -354,21 +354,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.1",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.0",
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.1",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -376,9 +376,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -1073,6 +1073,12 @@
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/busboy": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/compress": {
|
"node_modules/@fastify/compress": {
|
||||||
"version": "8.3.1",
|
"version": "8.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-8.3.1.tgz",
|
||||||
|
|
@ -1139,6 +1145,22 @@
|
||||||
"toad-cache": "^3.7.0"
|
"toad-cache": "^3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/deepmerge": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/error": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
|
|
@ -1229,6 +1251,29 @@
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/multipart": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^3.0.0",
|
||||||
|
"@fastify/deepmerge": "^3.0.0",
|
||||||
|
"@fastify/error": "^4.0.0",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"secure-json-parse": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/proxy-addr": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
|
|
@ -1294,9 +1339,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/static": {
|
"node_modules/@fastify/static": {
|
||||||
"version": "9.0.0",
|
"version": "9.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
|
||||||
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
|
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2068,26 +2113,28 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "^1.7.1",
|
|
||||||
"@emnapi/runtime": "^1.7.1",
|
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
||||||
"integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
|
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
|
|
@ -2101,9 +2148,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
||||||
"integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
|
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2117,9 +2164,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
||||||
"integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
|
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2133,12 +2180,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
||||||
"integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
|
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2149,12 +2199,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
||||||
"integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
|
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2165,12 +2218,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
||||||
"integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
|
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2181,12 +2237,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
||||||
"integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
|
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2197,9 +2256,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
||||||
"integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
|
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2213,9 +2272,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
||||||
"integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
|
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2289,9 +2348,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.122.0",
|
"version": "0.128.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
|
||||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2327,9 +2386,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
|
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2344,9 +2403,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
|
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2361,9 +2420,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
|
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2378,9 +2437,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
|
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2395,9 +2454,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
|
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -2412,13 +2471,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
|
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2429,13 +2491,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
|
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2446,13 +2511,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
|
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2463,13 +2531,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
|
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2480,13 +2551,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
|
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2497,13 +2571,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
|
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2514,9 +2591,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
|
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2531,9 +2608,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
|
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
|
@ -2541,16 +2618,18 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
"@emnapi/core": "1.10.0",
|
||||||
|
"@emnapi/runtime": "1.10.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
|
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -2565,9 +2644,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
|
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2582,9 +2661,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
|
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -4075,9 +4154,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -5445,9 +5524,9 @@
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.8.4",
|
"version": "5.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
|
||||||
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -7575,12 +7654,12 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
||||||
"integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
|
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.1",
|
"@next/env": "16.2.6",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
|
|
@ -7594,14 +7673,14 @@
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.1",
|
"@next/swc-darwin-arm64": "16.2.6",
|
||||||
"@next/swc-darwin-x64": "16.2.1",
|
"@next/swc-darwin-x64": "16.2.6",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.1",
|
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.1",
|
"@next/swc-linux-arm64-musl": "16.2.6",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.1",
|
"@next/swc-linux-x64-gnu": "16.2.6",
|
||||||
"@next/swc-linux-x64-musl": "16.2.1",
|
"@next/swc-linux-x64-musl": "16.2.6",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.1",
|
"@next/swc-win32-arm64-msvc": "16.2.6",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.1",
|
"@next/swc-win32-x64-msvc": "16.2.6",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -8144,9 +8223,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -8617,14 +8696,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.11",
|
"version": "1.0.0-rc.18",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
|
||||||
"integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
|
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.122.0",
|
"@oxc-project/types": "=0.128.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.11"
|
"@rolldown/pluginutils": "1.0.0-rc.18"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
|
|
@ -8633,21 +8712,21 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.11",
|
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
|
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.11",
|
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
|
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
|
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
|
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
|
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
|
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
|
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
|
|
@ -9499,14 +9578,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3"
|
"picomatch": "^4.0.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
|
|
@ -9825,7 +9904,6 @@
|
||||||
"version": "7.24.6",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
||||||
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
|
|
@ -9874,9 +9952,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|
@ -9887,17 +9965,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||||
"integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
|
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.14",
|
||||||
"rolldown": "1.0.0-rc.11",
|
"rolldown": "1.0.0-rc.18",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.16"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|
@ -9913,8 +9991,8 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.1.18",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
|
|
@ -10368,6 +10446,7 @@
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
|
|
@ -10378,6 +10457,7 @@
|
||||||
"fastify-type-provider-zod": "^6.1.0",
|
"fastify-type-provider-zod": "^6.1.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"mongoose": "^9.3.3",
|
"mongoose": "^9.3.3",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"fastify-type-provider-zod": "^6.1.0",
|
"fastify-type-provider-zod": "^6.1.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"mongoose": "^9.3.3",
|
"mongoose": "^9.3.3",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import storesRoutes from './modules/stores/stores.routes.js';
|
||||||
import medicinePricesRoutes from './modules/medicine-prices/medicine-prices.routes.js';
|
import medicinePricesRoutes from './modules/medicine-prices/medicine-prices.routes.js';
|
||||||
import refillsRoutes from './modules/refills/refills.routes.js';
|
import refillsRoutes from './modules/refills/refills.routes.js';
|
||||||
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
||||||
|
import recipesRoutes from './modules/recipes/recipes.routes.js';
|
||||||
|
import pantryRoutes from './modules/pantry/pantry.routes.js';
|
||||||
|
import freshnessRulesRoutes from './modules/freshness-rules/freshness-rules.routes.js';
|
||||||
|
import productsRoutes from './modules/products/products.routes.js';
|
||||||
|
|
||||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -117,6 +121,10 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||||
await app.register(medicinePricesRoutes);
|
await app.register(medicinePricesRoutes);
|
||||||
await app.register(refillsRoutes);
|
await app.register(refillsRoutes);
|
||||||
await app.register(purchasesRoutes);
|
await app.register(purchasesRoutes);
|
||||||
|
await app.register(productsRoutes);
|
||||||
|
await app.register(recipesRoutes);
|
||||||
|
await app.register(pantryRoutes);
|
||||||
|
await app.register(freshnessRulesRoutes);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockFind,
|
||||||
|
mockFindOne,
|
||||||
|
mockFindOneAndUpdate,
|
||||||
|
mockFindOneAndDelete,
|
||||||
|
mockSave,
|
||||||
|
mockFindById,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockFindOneAndDelete: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/freshness-rule.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findOneChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOne,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findByIdChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOneAndUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteChain = () => ({
|
||||||
|
exec: mockFindOneAndDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
save() {
|
||||||
|
mockSave(this.data);
|
||||||
|
return Promise.resolve({ toObject: () => this.data });
|
||||||
|
}
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findById = vi.fn(() => findByIdChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
static findOneAndDelete = vi.fn(() => deleteChain());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { FreshnessRuleModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||||
|
|
||||||
|
describe(FreshnessRulesRepository.name, () => {
|
||||||
|
let repo: FreshnessRulesRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new FreshnessRulesRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated results', async () => {
|
||||||
|
mockFind.mockResolvedValue([{ _id: { toString: () => 'id1' } }]);
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 50 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies category filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { category: 'dairy', limit: 50 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies storageLocation filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { storageLocation: 'fridge', limit: 50 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cursor', async () => {
|
||||||
|
const cursor = Buffer.from('abc').toString('base64');
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { cursor, limit: 50 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns rule', async () => {
|
||||||
|
mockFindById.mockResolvedValue({ _id: 'id1' });
|
||||||
|
const result = await repo.findById('id1');
|
||||||
|
expect(result).toEqual({ _id: 'id1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findApplicableRule', () => {
|
||||||
|
it('returns household rule when available', async () => {
|
||||||
|
const rule = { _id: 'r1', householdId: 'hh1' };
|
||||||
|
mockFindOne.mockResolvedValueOnce(rule);
|
||||||
|
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||||
|
expect(result).toEqual(rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to system rule', async () => {
|
||||||
|
const systemRule = { _id: 'r2', householdId: null };
|
||||||
|
mockFindOne.mockResolvedValueOnce(null).mockResolvedValueOnce(systemRule);
|
||||||
|
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||||
|
expect(result).toEqual(systemRule);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no rule found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
const result = await repo.findApplicableRule('hh1', 'dairy', 'fridge');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns rule', async () => {
|
||||||
|
const data = {
|
||||||
|
category: 'dairy',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
};
|
||||||
|
const result = await repo.create(data);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns rule', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'id1' });
|
||||||
|
const result = await repo.update('id1', 'hh1', { shelfLifeDays: 10 });
|
||||||
|
expect(result).toEqual({ _id: 'id1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('deletes rule', async () => {
|
||||||
|
mockFindOneAndDelete.mockResolvedValue({ _id: 'id1' });
|
||||||
|
await repo.delete('id1', 'hh1');
|
||||||
|
expect(mockFindOneAndDelete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { FreshnessRuleModel } from '../../schemas/freshness-rule.schema.js';
|
||||||
|
|
||||||
|
interface FindQuery {
|
||||||
|
category?: string;
|
||||||
|
storageLocation?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FreshnessRulesRepository {
|
||||||
|
public async findByHousehold(householdId: string, query: FindQuery) {
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
$or: [{ householdId }, { householdId: { $exists: false } }, { householdId: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.category) filter['category'] = query.category;
|
||||||
|
if (query.storageLocation) filter['storageLocation'] = query.storageLocation;
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await FreshnessRuleModel.find(filter)
|
||||||
|
.sort({ category: 1, storageLocation: 1, _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string) {
|
||||||
|
return FreshnessRuleModel.findById(id).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findApplicableRule(householdId: string, category: string, storageLocation: string) {
|
||||||
|
// Household override takes priority
|
||||||
|
const householdRule = await FreshnessRuleModel.findOne({
|
||||||
|
householdId,
|
||||||
|
category,
|
||||||
|
storageLocation,
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (householdRule) return householdRule;
|
||||||
|
|
||||||
|
// Fall back to system default
|
||||||
|
return FreshnessRuleModel.findOne({
|
||||||
|
$or: [{ householdId: null }, { householdId: { $exists: false } }],
|
||||||
|
category,
|
||||||
|
storageLocation,
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: Record<string, unknown>) {
|
||||||
|
const doc = new FreshnessRuleModel(data);
|
||||||
|
const saved = await doc.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||||
|
return FreshnessRuleModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId },
|
||||||
|
{ $set: data },
|
||||||
|
{ new: true },
|
||||||
|
)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
return FreshnessRuleModel.findOneAndDelete({ _id: id, householdId }).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockFindByHousehold, mockFindById, mockCreate, mockUpdate, mockDelete } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
mockFindByHousehold: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockDelete: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('./freshness-rules.repository.js', () => ({
|
||||||
|
FreshnessRulesRepository: class {
|
||||||
|
findByHousehold = mockFindByHousehold;
|
||||||
|
findById = mockFindById;
|
||||||
|
findApplicableRule = vi.fn();
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
delete = mockDelete;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import freshnessRulesRoutes from './freshness-rules.routes.js';
|
||||||
|
|
||||||
|
function makeRule(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'rule-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
category: 'dairy',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
spoilageSignsToCheck: ['smell'],
|
||||||
|
source: 'household',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('freshness-rules.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(freshnessRulesRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /freshness-rules', () => {
|
||||||
|
it('returns paginated list', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [makeRule()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /freshness-rules', () => {
|
||||||
|
it('creates a rule', async () => {
|
||||||
|
mockCreate.mockResolvedValue(makeRule());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
category: 'dairy',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid category', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
category: 'invalid',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /freshness-rules/:id', () => {
|
||||||
|
it('updates a rule', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRule());
|
||||||
|
mockUpdate.mockResolvedValue(makeRule({ shelfLifeDays: 10 }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shelfLifeDays: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /freshness-rules/:id with optional fields', () => {
|
||||||
|
it('returns rule with all optional fields', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRule());
|
||||||
|
mockUpdate.mockResolvedValue(makeRule({ freezerLifeDays: 90, tips: 'Keep sealed' }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ freezerLifeDays: 90, tips: 'Keep sealed' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.freezerLifeDays).toBe(90);
|
||||||
|
expect(body.tips).toBe('Keep sealed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /freshness-rules/:id', () => {
|
||||||
|
it('deletes a rule', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRule());
|
||||||
|
mockDelete.mockResolvedValue(makeRule());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/freshness-rules/rule-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreateFreshnessRuleSchema,
|
||||||
|
UpdateFreshnessRuleSchema,
|
||||||
|
FreshnessRuleQuerySchema,
|
||||||
|
FreshnessRuleResponseSchema,
|
||||||
|
FreshnessRuleListResponseSchema,
|
||||||
|
type FreshnessRuleSource,
|
||||||
|
type StorageLocation,
|
||||||
|
type ProductCategory,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||||
|
import { FreshnessRulesService } from './freshness-rules.service.js';
|
||||||
|
|
||||||
|
const HouseholdParams = z.object({ householdId: z.string() });
|
||||||
|
const RuleParams = z.object({ householdId: z.string(), id: z.string() });
|
||||||
|
|
||||||
|
type AnyRuleDoc = {
|
||||||
|
_id: string | { toString(): string };
|
||||||
|
householdId?: string | null;
|
||||||
|
category: string;
|
||||||
|
storageLocation: string;
|
||||||
|
shelfLifeDays: number;
|
||||||
|
openedLifeDays: number;
|
||||||
|
freezerLifeDays?: number | null;
|
||||||
|
spoilageSignsToCheck: string[];
|
||||||
|
tips?: string | null;
|
||||||
|
source: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStr(v: string | { toString: () => string }): string {
|
||||||
|
return typeof v === 'string' ? v : v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(v: string | Date): string {
|
||||||
|
return typeof v === 'string' ? v : v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRuleResponse(doc: AnyRuleDoc): z.infer<typeof FreshnessRuleResponseSchema> {
|
||||||
|
return {
|
||||||
|
_id: toStr(doc._id),
|
||||||
|
...(doc.householdId ? { householdId: doc.householdId } : {}),
|
||||||
|
category: doc.category as ProductCategory,
|
||||||
|
storageLocation: doc.storageLocation as StorageLocation,
|
||||||
|
shelfLifeDays: doc.shelfLifeDays,
|
||||||
|
openedLifeDays: doc.openedLifeDays,
|
||||||
|
...(doc.freezerLifeDays != null ? { freezerLifeDays: doc.freezerLifeDays } : {}),
|
||||||
|
spoilageSignsToCheck: doc.spoilageSignsToCheck,
|
||||||
|
...(doc.tips ? { tips: doc.tips } : {}),
|
||||||
|
source: doc.source as FreshnessRuleSource,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
freshnessRulesRepository: FreshnessRulesRepository;
|
||||||
|
freshnessRulesService: FreshnessRulesService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
if (!fastify.diContainer.hasRegistration('freshnessRulesRepository')) {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
freshnessRulesRepository: asClass(FreshnessRulesRepository, {
|
||||||
|
lifetime: Lifetime.SINGLETON,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fastify.diContainer.register({
|
||||||
|
freshnessRulesService: asClass(FreshnessRulesService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
|
||||||
|
// GET /freshness-rules
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/freshness-rules',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
querystring: FreshnessRuleQuerySchema,
|
||||||
|
response: { 200: FreshnessRuleListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
const mapped = {
|
||||||
|
data: result.data.map((d) => toRuleResponse(d as unknown as AnyRuleDoc)),
|
||||||
|
pagination: result.pagination,
|
||||||
|
};
|
||||||
|
return reply.send(mapped);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /freshness-rules
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/freshness-rules',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
body: CreateFreshnessRuleSchema,
|
||||||
|
response: { 201: FreshnessRuleResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||||
|
const rule = await service.create(request.body, request.params.householdId);
|
||||||
|
return reply.status(201).send(toRuleResponse(rule as unknown as AnyRuleDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /freshness-rules/:id
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/freshness-rules/:id',
|
||||||
|
schema: {
|
||||||
|
params: RuleParams,
|
||||||
|
body: UpdateFreshnessRuleSchema,
|
||||||
|
response: { 200: FreshnessRuleResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||||
|
const rule = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toRuleResponse(rule as unknown as AnyRuleDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /freshness-rules/:id
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/freshness-rules/:id',
|
||||||
|
schema: {
|
||||||
|
params: RuleParams,
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<FreshnessRulesService>('freshnessRulesService');
|
||||||
|
await service.delete(request.params.id, request.params.householdId);
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: 'freshness-rules-routes' },
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { FreshnessRulesService } from './freshness-rules.service.js';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
import { FreshnessRuleSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const mockRepo = {
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findApplicableRule: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(FreshnessRulesService.name, () => {
|
||||||
|
let service: FreshnessRulesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new FreshnessRulesService({
|
||||||
|
freshnessRulesRepository: mockRepo as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRepo.findByHousehold.mockResolvedValue(expected);
|
||||||
|
const result = await service.list('hh1', { limit: 50 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates household rule', async () => {
|
||||||
|
const data = {
|
||||||
|
category: 'dairy' as never,
|
||||||
|
storageLocation: 'fridge' as never,
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
spoilageSignsToCheck: [],
|
||||||
|
};
|
||||||
|
mockRepo.create.mockResolvedValue({ ...data, _id: 'r1', householdId: 'hh1' });
|
||||||
|
|
||||||
|
const result = await service.create(data, 'hh1');
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith({
|
||||||
|
...data,
|
||||||
|
householdId: 'hh1',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates household rule', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
mockRepo.update.mockResolvedValue({ _id: 'r1', shelfLifeDays: 10 });
|
||||||
|
|
||||||
|
const result = await service.update('r1', 'hh1', { shelfLifeDays: 10 });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when rule not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.update('missing', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestError for system rules', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: null,
|
||||||
|
source: FreshnessRuleSource.SYSTEM,
|
||||||
|
});
|
||||||
|
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(BadRequestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError for another household rule', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'other-hh',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
mockRepo.update.mockResolvedValue(null);
|
||||||
|
await expect(service.update('r1', 'hh1', {})).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('deletes household rule', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
mockRepo.delete.mockResolvedValue({ _id: 'r1' });
|
||||||
|
await service.delete('r1', 'hh1');
|
||||||
|
expect(mockRepo.delete).toHaveBeenCalledWith('r1', 'hh1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when rule not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestError for system rules', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: null,
|
||||||
|
source: FreshnessRuleSource.SYSTEM,
|
||||||
|
});
|
||||||
|
await expect(service.delete('r1', 'hh1')).rejects.toThrow(BadRequestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError for another household rule', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue({
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'other-hh',
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
await expect(service.delete('r1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { FreshnessRulesRepository } from './freshness-rules.repository.js';
|
||||||
|
import { FreshnessRuleSource } from '@meshitrack/shared';
|
||||||
|
import type {
|
||||||
|
CreateFreshnessRuleInput,
|
||||||
|
UpdateFreshnessRuleInput,
|
||||||
|
FreshnessRuleQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
freshnessRulesRepository: FreshnessRulesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FreshnessRulesService {
|
||||||
|
private readonly freshnessRulesRepository: FreshnessRulesRepository;
|
||||||
|
|
||||||
|
public constructor({ freshnessRulesRepository }: Deps) {
|
||||||
|
this.freshnessRulesRepository = freshnessRulesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: FreshnessRuleQueryInput) {
|
||||||
|
return this.freshnessRulesRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateFreshnessRuleInput, householdId: string) {
|
||||||
|
return this.freshnessRulesRepository.create({
|
||||||
|
...data,
|
||||||
|
householdId,
|
||||||
|
source: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateFreshnessRuleInput) {
|
||||||
|
const existing = await this.freshnessRulesRepository.findById(id);
|
||||||
|
if (!existing) throw new NotFoundError('Freshness rule not found');
|
||||||
|
|
||||||
|
const rec = existing as Record<string, unknown>;
|
||||||
|
if (rec.source === FreshnessRuleSource.SYSTEM && rec.householdId == null) {
|
||||||
|
throw new BadRequestError('Cannot modify system rules. Create a household override instead.');
|
||||||
|
}
|
||||||
|
if (rec.householdId && rec.householdId !== householdId) {
|
||||||
|
throw new NotFoundError('Freshness rule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.freshnessRulesRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Freshness rule not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
const existing = await this.freshnessRulesRepository.findById(id);
|
||||||
|
if (!existing) throw new NotFoundError('Freshness rule not found');
|
||||||
|
|
||||||
|
const rec = existing as Record<string, unknown>;
|
||||||
|
if (rec.source === FreshnessRuleSource.SYSTEM && rec.householdId == null) {
|
||||||
|
throw new BadRequestError('Cannot delete system rules');
|
||||||
|
}
|
||||||
|
if (rec.householdId && rec.householdId !== householdId) {
|
||||||
|
throw new NotFoundError('Freshness rule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.freshnessRulesRepository.delete(id, householdId);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/api/src/modules/llm/llm-provider.interface.ts
Normal file
62
packages/api/src/modules/llm/llm-provider.interface.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { NutritionInfo } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface NutritionExtractionResult {
|
||||||
|
name?: string;
|
||||||
|
brand?: string;
|
||||||
|
servingSize?: number;
|
||||||
|
servingUnit?: string;
|
||||||
|
nutrition: Partial<NutritionInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedIngredient {
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRecipe {
|
||||||
|
name: string;
|
||||||
|
servings: number;
|
||||||
|
ingredients: ParsedIngredient[];
|
||||||
|
steps: string[];
|
||||||
|
prepTime?: number;
|
||||||
|
cookTime?: number;
|
||||||
|
cuisine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedReceipt {
|
||||||
|
items: Array<{ name: string; quantity?: number; price?: number }>;
|
||||||
|
total?: number;
|
||||||
|
storeName?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlanContext {
|
||||||
|
householdId: string;
|
||||||
|
targetCalories?: number;
|
||||||
|
preferences?: string[];
|
||||||
|
avoidances?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlanSuggestion {
|
||||||
|
meals: Array<{ name: string; recipeId?: string; calories: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredAction {
|
||||||
|
intent: string;
|
||||||
|
entities: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILlmProvider {
|
||||||
|
extractNutrition(input: {
|
||||||
|
text?: string;
|
||||||
|
image?: Buffer;
|
||||||
|
}): Promise<NutritionExtractionResult | null>;
|
||||||
|
parseRecipe(text: string): Promise<ParsedRecipe | null>;
|
||||||
|
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
|
||||||
|
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
|
||||||
|
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
|
||||||
|
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
||||||
59
packages/api/src/modules/llm/no-op-llm.provider.test.ts
Normal file
59
packages/api/src/modules/llm/no-op-llm.provider.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { NoOpLlmProvider } from './no-op-llm.provider.js';
|
||||||
|
import { LLM_PROVIDER } from './llm-provider.interface.js';
|
||||||
|
|
||||||
|
describe(NoOpLlmProvider.name, () => {
|
||||||
|
const provider = new NoOpLlmProvider();
|
||||||
|
|
||||||
|
it('exports LLM_PROVIDER symbol', () => {
|
||||||
|
expect(typeof LLM_PROVIDER).toBe('symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extractNutrition returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.extractNutrition({ text: 'apple' });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('extractNutrition'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseRecipe returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.parseRecipe('pasta recipe');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipe'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseRecipeFromUrl returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.parseRecipeFromUrl('https://example.com');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseRecipeFromUrl'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseReceipt returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.parseReceipt(Buffer.from('fake-image'));
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseReceipt'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggestMealPlan returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.suggestMealPlan({ householdId: 'hh1' });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('suggestMealPlan'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseNaturalLanguage returns null and warns', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const result = await provider.parseNaturalLanguage('add milk');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('parseNaturalLanguage'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/api/src/modules/llm/no-op-llm.provider.ts
Normal file
48
packages/api/src/modules/llm/no-op-llm.provider.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type {
|
||||||
|
ILlmProvider,
|
||||||
|
NutritionExtractionResult,
|
||||||
|
ParsedRecipe,
|
||||||
|
ParsedReceipt,
|
||||||
|
MealPlanContext,
|
||||||
|
MealPlanSuggestion,
|
||||||
|
StructuredAction,
|
||||||
|
} from './llm-provider.interface.js';
|
||||||
|
|
||||||
|
export class NoOpLlmProvider implements ILlmProvider {
|
||||||
|
private warn(method: string): void {
|
||||||
|
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async extractNutrition(_input: {
|
||||||
|
text?: string;
|
||||||
|
image?: Buffer;
|
||||||
|
}): Promise<NutritionExtractionResult | null> {
|
||||||
|
this.warn('extractNutrition');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseRecipe(_text: string): Promise<ParsedRecipe | null> {
|
||||||
|
this.warn('parseRecipe');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseRecipeFromUrl(_url: string): Promise<ParsedRecipe | null> {
|
||||||
|
this.warn('parseRecipeFromUrl');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseReceipt(_image: Buffer): Promise<ParsedReceipt | null> {
|
||||||
|
this.warn('parseReceipt');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async suggestMealPlan(_context: MealPlanContext): Promise<MealPlanSuggestion | null> {
|
||||||
|
this.warn('suggestMealPlan');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseNaturalLanguage(_text: string): Promise<StructuredAction | null> {
|
||||||
|
this.warn('parseNaturalLanguage');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { FreshnessCalculatorService } from './freshness-calculator.service.js';
|
||||||
|
import { ItemStatus, FreshnessUrgency, FreshnessSource, StorageLocation } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
describe(FreshnessCalculatorService.name, () => {
|
||||||
|
const service = new FreshnessCalculatorService();
|
||||||
|
|
||||||
|
const baseItem = {
|
||||||
|
status: ItemStatus.SEALED,
|
||||||
|
storageLocation: StorageLocation.FRIDGE,
|
||||||
|
purchaseDate: new Date('2024-01-01'),
|
||||||
|
expirationDate: undefined,
|
||||||
|
openedDate: undefined,
|
||||||
|
preparedDate: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
freezerLifeDays: 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('calculate', () => {
|
||||||
|
it('uses packaging expiration date when present', () => {
|
||||||
|
const item = { ...baseItem, expirationDate: new Date('2099-12-31') };
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
expect(result.source).toBe(FreshnessSource.PACKAGING);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(new Date('2099-12-31'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 7-day default when no rule provided', () => {
|
||||||
|
const result = service.calculate(baseItem, null);
|
||||||
|
expect(result.source).toBe(FreshnessSource.RULE);
|
||||||
|
const expected = new Date('2024-01-01');
|
||||||
|
expected.setDate(expected.getDate() + 7);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses freezerLifeDays for freezer storage', () => {
|
||||||
|
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
expect(result.source).toBe(FreshnessSource.RULE);
|
||||||
|
const expected = new Date('2024-01-01');
|
||||||
|
expected.setDate(expected.getDate() + 90);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses shelfLifeDays for sealed items in freezer without freezerLifeDays', () => {
|
||||||
|
const item = { ...baseItem, storageLocation: StorageLocation.FREEZER };
|
||||||
|
const ruleNoFreezer = { shelfLifeDays: 14, openedLifeDays: 7 };
|
||||||
|
const result = service.calculate(item, ruleNoFreezer);
|
||||||
|
const expected = new Date('2024-01-01');
|
||||||
|
expected.setDate(expected.getDate() + 14);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses openedLifeDays for opened items', () => {
|
||||||
|
const openedDate = new Date('2024-01-05');
|
||||||
|
const item = {
|
||||||
|
...baseItem,
|
||||||
|
status: ItemStatus.OPENED,
|
||||||
|
openedDate,
|
||||||
|
};
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
const expected = new Date('2024-01-05');
|
||||||
|
expected.setDate(expected.getDate() + 7);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses openedLifeDays for prepared items with openedDate', () => {
|
||||||
|
const openedDate = new Date('2024-01-05');
|
||||||
|
const item = {
|
||||||
|
...baseItem,
|
||||||
|
status: ItemStatus.PREPARED,
|
||||||
|
openedDate,
|
||||||
|
};
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
const expected = new Date('2024-01-05');
|
||||||
|
expected.setDate(expected.getDate() + 7);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses shelfLifeDays for opened item without openedDate', () => {
|
||||||
|
const item = { ...baseItem, status: ItemStatus.OPENED };
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
const expected = new Date('2024-01-01');
|
||||||
|
expected.setDate(expected.getDate() + 14);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses shelfLifeDays for sealed items', () => {
|
||||||
|
const result = service.calculate(baseItem, rule);
|
||||||
|
const expected = new Date('2024-01-01');
|
||||||
|
expected.setDate(expected.getDate() + 14);
|
||||||
|
expect(result.estimatedExpiryDate).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes daysRemaining and urgency', () => {
|
||||||
|
const future = new Date();
|
||||||
|
future.setDate(future.getDate() + 10);
|
||||||
|
const item = { ...baseItem, expirationDate: future };
|
||||||
|
const result = service.calculate(item, rule);
|
||||||
|
expect(result.daysRemaining).toBeGreaterThan(5);
|
||||||
|
expect(result.urgency).toBe(FreshnessUrgency.FRESH);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isActive', () => {
|
||||||
|
it('returns true for sealed', () => {
|
||||||
|
expect(service.isActive('sealed')).toBe(true);
|
||||||
|
});
|
||||||
|
it('returns true for opened', () => {
|
||||||
|
expect(service.isActive('opened')).toBe(true);
|
||||||
|
});
|
||||||
|
it('returns true for prepared', () => {
|
||||||
|
expect(service.isActive('prepared')).toBe(true);
|
||||||
|
});
|
||||||
|
it('returns false for consumed', () => {
|
||||||
|
expect(service.isActive('consumed')).toBe(false);
|
||||||
|
});
|
||||||
|
it('returns false for discarded', () => {
|
||||||
|
expect(service.isActive('discarded')).toBe(false);
|
||||||
|
});
|
||||||
|
it('returns false for expired', () => {
|
||||||
|
expect(service.isActive('expired')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapUrgency', () => {
|
||||||
|
it('returns FRESH for > 5 days', () => {
|
||||||
|
expect(service.mapUrgency(6)).toBe(FreshnessUrgency.FRESH);
|
||||||
|
});
|
||||||
|
it('returns USE_SOON for 2-5 days', () => {
|
||||||
|
expect(service.mapUrgency(3)).toBe(FreshnessUrgency.USE_SOON);
|
||||||
|
});
|
||||||
|
it('returns URGENT for 0-1 days', () => {
|
||||||
|
expect(service.mapUrgency(1)).toBe(FreshnessUrgency.URGENT);
|
||||||
|
});
|
||||||
|
it('returns CHECK for -1 to -3 days', () => {
|
||||||
|
expect(service.mapUrgency(-1)).toBe(FreshnessUrgency.CHECK);
|
||||||
|
});
|
||||||
|
it('returns EXPIRED for < -3 days', () => {
|
||||||
|
expect(service.mapUrgency(-4)).toBe(FreshnessUrgency.EXPIRED);
|
||||||
|
});
|
||||||
|
it('returns USE_SOON for exactly 2', () => {
|
||||||
|
expect(service.mapUrgency(2)).toBe(FreshnessUrgency.USE_SOON);
|
||||||
|
});
|
||||||
|
it('returns USE_SOON for exactly 5', () => {
|
||||||
|
expect(service.mapUrgency(5)).toBe(FreshnessUrgency.USE_SOON);
|
||||||
|
});
|
||||||
|
it('returns URGENT for exactly 0', () => {
|
||||||
|
expect(service.mapUrgency(0)).toBe(FreshnessUrgency.URGENT);
|
||||||
|
});
|
||||||
|
it('returns CHECK for exactly -3', () => {
|
||||||
|
expect(service.mapUrgency(-3)).toBe(FreshnessUrgency.CHECK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { FreshnessUrgency, FreshnessSource, ItemStatus } from '@meshitrack/shared';
|
||||||
|
import type { StorageLocation } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface FreshnessInput {
|
||||||
|
status: ItemStatus;
|
||||||
|
storageLocation: StorageLocation;
|
||||||
|
purchaseDate: Date;
|
||||||
|
expirationDate?: Date | null;
|
||||||
|
openedDate?: Date | null;
|
||||||
|
preparedDate?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreshnessRuleLike {
|
||||||
|
shelfLifeDays: number;
|
||||||
|
openedLifeDays: number;
|
||||||
|
freezerLifeDays?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreshnessResult {
|
||||||
|
estimatedExpiryDate: Date;
|
||||||
|
daysRemaining: number;
|
||||||
|
urgency: FreshnessUrgency;
|
||||||
|
source: FreshnessSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set<string>([
|
||||||
|
ItemStatus.SEALED,
|
||||||
|
ItemStatus.OPENED,
|
||||||
|
ItemStatus.PREPARED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class FreshnessCalculatorService {
|
||||||
|
public calculate(item: FreshnessInput, rule: FreshnessRuleLike | null): FreshnessResult {
|
||||||
|
// If packaging expiration date exists, use it
|
||||||
|
if (item.expirationDate) {
|
||||||
|
return this.buildResult(new Date(item.expirationDate), FreshnessSource.PACKAGING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no rule, default to 7 days from purchase
|
||||||
|
if (!rule) {
|
||||||
|
const fallback = new Date(item.purchaseDate);
|
||||||
|
fallback.setDate(fallback.getDate() + 7);
|
||||||
|
return this.buildResult(fallback, FreshnessSource.RULE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freezer uses freezerLifeDays from purchase date
|
||||||
|
if (item.storageLocation === 'freezer' && rule.freezerLifeDays) {
|
||||||
|
const freezerExpiry = new Date(item.purchaseDate);
|
||||||
|
freezerExpiry.setDate(freezerExpiry.getDate() + rule.freezerLifeDays);
|
||||||
|
return this.buildResult(freezerExpiry, FreshnessSource.RULE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened/prepared items use openedLifeDays from openedDate
|
||||||
|
if (
|
||||||
|
(item.status === ItemStatus.OPENED || item.status === ItemStatus.PREPARED) &&
|
||||||
|
item.openedDate
|
||||||
|
) {
|
||||||
|
const openedExpiry = new Date(item.openedDate);
|
||||||
|
openedExpiry.setDate(openedExpiry.getDate() + rule.openedLifeDays);
|
||||||
|
return this.buildResult(openedExpiry, FreshnessSource.RULE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sealed: use shelfLifeDays from purchase date
|
||||||
|
const sealedExpiry = new Date(item.purchaseDate);
|
||||||
|
sealedExpiry.setDate(sealedExpiry.getDate() + rule.shelfLifeDays);
|
||||||
|
return this.buildResult(sealedExpiry, FreshnessSource.RULE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isActive(status: string): boolean {
|
||||||
|
return ACTIVE_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapUrgency(daysRemaining: number): FreshnessUrgency {
|
||||||
|
if (daysRemaining > 5) return FreshnessUrgency.FRESH;
|
||||||
|
if (daysRemaining >= 2) return FreshnessUrgency.USE_SOON;
|
||||||
|
if (daysRemaining >= 0) return FreshnessUrgency.URGENT;
|
||||||
|
if (daysRemaining >= -3) return FreshnessUrgency.CHECK;
|
||||||
|
return FreshnessUrgency.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildResult(estimatedExpiryDate: Date, source: FreshnessSource): FreshnessResult {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = estimatedExpiryDate.getTime() - now.getTime();
|
||||||
|
const daysRemaining = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const urgency = this.mapUrgency(daysRemaining);
|
||||||
|
|
||||||
|
return { estimatedExpiryDate, daysRemaining, urgency, source };
|
||||||
|
}
|
||||||
|
}
|
||||||
254
packages/api/src/modules/pantry/pantry.repository.test.ts
Normal file
254
packages/api/src/modules/pantry/pantry.repository.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockFind,
|
||||||
|
mockFindOne,
|
||||||
|
mockFindOneAndUpdate,
|
||||||
|
mockFindOneAndDelete,
|
||||||
|
mockSave,
|
||||||
|
mockAggregate,
|
||||||
|
mockUpdateMany,
|
||||||
|
mockFindByIdAndUpdate,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockFindOneAndDelete: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
mockAggregate: vi.fn(),
|
||||||
|
mockUpdateMany: vi.fn(),
|
||||||
|
mockFindByIdAndUpdate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/pantry-item.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findOneChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOne,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOneAndUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteChain = () => ({
|
||||||
|
exec: mockFindOneAndDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateByIdChain = () => ({
|
||||||
|
exec: mockFindByIdAndUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateManyChain = () => ({
|
||||||
|
exec: mockUpdateMany,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggChain = () => ({
|
||||||
|
exec: mockAggregate,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
save() {
|
||||||
|
mockSave(this.data);
|
||||||
|
return Promise.resolve({ toObject: () => this.data });
|
||||||
|
}
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
static findOneAndDelete = vi.fn(() => deleteChain());
|
||||||
|
static findByIdAndUpdate = vi.fn(() => updateByIdChain());
|
||||||
|
static updateMany = vi.fn(() => updateManyChain());
|
||||||
|
static aggregate = vi.fn(() => aggChain());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { PantryItemModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PantryRepository } from './pantry.repository.js';
|
||||||
|
|
||||||
|
describe(PantryRepository.name, () => {
|
||||||
|
let repo: PantryRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new PantryRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated results', async () => {
|
||||||
|
const items = [{ _id: { toString: () => 'id1' } }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles hasMore', async () => {
|
||||||
|
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
_id: { toString: () => `id${i}` },
|
||||||
|
}));
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies storageLocation filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { storageLocation: 'fridge', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies status filter with single value', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { status: 'sealed', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies status filter with multiple values', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { status: 'sealed,opened', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies urgency filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { urgency: 'urgent,check', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies single urgency filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { urgency: 'urgent', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies productId filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { productId: 'p1', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cursor', async () => {
|
||||||
|
const cursor = Buffer.from('abc').toString('base64');
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns item', async () => {
|
||||||
|
mockFindOne.mockResolvedValue({ _id: 'id1' });
|
||||||
|
const result = await repo.findById('id1', 'hh1');
|
||||||
|
expect(result).toEqual({ _id: 'id1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findExpiringSoon', () => {
|
||||||
|
it('returns items expiring within days', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const result = await repo.findExpiringSoon('hh1', 7);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports cursor', async () => {
|
||||||
|
const cursor = Buffer.from('abc').toString('base64');
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const result = await repo.findExpiringSoon('hh1', 7, cursor, 20);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findActiveByHousehold', () => {
|
||||||
|
it('returns active items', async () => {
|
||||||
|
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||||
|
const result = await repo.findActiveByHousehold('hh1');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns item', async () => {
|
||||||
|
const data = { name: 'test' };
|
||||||
|
const result = await repo.create(data);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns item', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'id1' });
|
||||||
|
const result = await repo.update('id1', 'hh1', { quantity: 3 });
|
||||||
|
expect(result).toEqual({ _id: 'id1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateFreshness', () => {
|
||||||
|
it('updates freshness estimate', async () => {
|
||||||
|
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||||
|
await repo.updateFreshness('id1', { urgency: 'fresh' });
|
||||||
|
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates freshness and status', async () => {
|
||||||
|
mockFindByIdAndUpdate.mockResolvedValue(undefined);
|
||||||
|
await repo.updateFreshness('id1', { urgency: 'expired' }, 'expired');
|
||||||
|
expect(mockFindByIdAndUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('deletes item', async () => {
|
||||||
|
mockFindOneAndDelete.mockResolvedValue({ _id: 'id1' });
|
||||||
|
await repo.delete('id1', 'hh1');
|
||||||
|
expect(mockFindOneAndDelete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWasteStats', () => {
|
||||||
|
it('returns aggregation result', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||||
|
const result = await repo.getWasteStats('hh1', new Date(), new Date());
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTopWastedProducts', () => {
|
||||||
|
it('returns top wasted products', async () => {
|
||||||
|
mockAggregate.mockResolvedValue([{ productId: 'p1', productName: 'Milk', count: 3 }]);
|
||||||
|
const result = await repo.getTopWastedProducts('hh1', new Date(), new Date());
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByIds', () => {
|
||||||
|
it('returns items by ids', async () => {
|
||||||
|
mockFind.mockResolvedValue([{ _id: 'id1' }]);
|
||||||
|
const result = await repo.findByIds(['id1'], 'hh1');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkUpdateStatus', () => {
|
||||||
|
it('returns modified count', async () => {
|
||||||
|
mockUpdateMany.mockResolvedValue({ modifiedCount: 2 });
|
||||||
|
const result = await repo.bulkUpdateStatus(['id1', 'id2'], 'hh1', 'consumed' as never);
|
||||||
|
expect(result).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
198
packages/api/src/modules/pantry/pantry.repository.ts
Normal file
198
packages/api/src/modules/pantry/pantry.repository.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { PantryItemModel } from '../../schemas/pantry-item.schema.js';
|
||||||
|
import type { ItemStatus } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
interface FindByHouseholdQuery {
|
||||||
|
storageLocation?: string;
|
||||||
|
status?: string;
|
||||||
|
urgency?: string;
|
||||||
|
productId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PantryRepository {
|
||||||
|
public async findByHousehold(householdId: string, query: FindByHouseholdQuery) {
|
||||||
|
const filter: Record<string, unknown> = { householdId };
|
||||||
|
|
||||||
|
if (query.storageLocation) filter['storageLocation'] = query.storageLocation;
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
const statuses = query.status.split(',').filter(Boolean);
|
||||||
|
filter['status'] = statuses.length === 1 ? statuses[0] : { $in: statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.urgency) {
|
||||||
|
const urgencies = query.urgency.split(',').filter(Boolean);
|
||||||
|
filter['freshnessEstimate.urgency'] =
|
||||||
|
urgencies.length === 1 ? urgencies[0] : { $in: urgencies };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.productId) filter['productId'] = query.productId;
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await PantryItemModel.find(filter)
|
||||||
|
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
return PantryItemModel.findOne({ _id: id, householdId }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findExpiringSoon(householdId: string, days: number, cursor?: string, limit = 20) {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() + days);
|
||||||
|
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
householdId,
|
||||||
|
status: { $in: ['sealed', 'opened', 'prepared'] },
|
||||||
|
'freshnessEstimate.estimatedExpiryDate': { $lte: cutoff },
|
||||||
|
'freshnessEstimate.daysRemaining': { $gte: -3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
const id = Buffer.from(cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await PantryItemModel.find(filter)
|
||||||
|
.sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursorVal =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursorVal : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findActiveByHousehold(householdId: string) {
|
||||||
|
return PantryItemModel.find({
|
||||||
|
householdId,
|
||||||
|
status: { $in: ['sealed', 'opened', 'prepared'] },
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: Record<string, unknown>) {
|
||||||
|
const doc = new PantryItemModel(data);
|
||||||
|
const saved = await doc.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||||
|
return PantryItemModel.findOneAndUpdate({ _id: id, householdId }, { $set: data }, { new: true })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateFreshness(
|
||||||
|
id: string,
|
||||||
|
freshnessEstimate: Record<string, unknown>,
|
||||||
|
status?: string,
|
||||||
|
) {
|
||||||
|
const update: Record<string, unknown> = { freshnessEstimate };
|
||||||
|
if (status) update['status'] = status;
|
||||||
|
return PantryItemModel.findByIdAndUpdate(id, { $set: update }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
return PantryItemModel.findOneAndDelete({ _id: id, householdId }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWasteStats(householdId: string, start: Date, end: Date) {
|
||||||
|
return PantryItemModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
householdId,
|
||||||
|
status: { $in: ['consumed', 'discarded'] },
|
||||||
|
updatedAt: { $gte: start, $lte: end },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
totalConsumed: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'consumed'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
totalDiscarded: {
|
||||||
|
$sum: { $cond: [{ $eq: ['$status', 'discarded'] }, 1, 0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTopWastedProducts(householdId: string, start: Date, end: Date, limit = 5) {
|
||||||
|
return PantryItemModel.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
householdId,
|
||||||
|
status: 'discarded',
|
||||||
|
updatedAt: { $gte: start, $lte: end },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$productId',
|
||||||
|
productName: { $first: '$productName' },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $sort: { count: -1 } },
|
||||||
|
{ $limit: limit },
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
productId: '$_id',
|
||||||
|
productName: 1,
|
||||||
|
count: 1,
|
||||||
|
_id: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByIds(ids: string[], householdId: string) {
|
||||||
|
return PantryItemModel.find({
|
||||||
|
_id: { $in: ids },
|
||||||
|
householdId,
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
householdId: string,
|
||||||
|
status: ItemStatus,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const result = await PantryItemModel.updateMany(
|
||||||
|
{ _id: { $in: ids }, householdId },
|
||||||
|
{ $set: { status, ...extra } },
|
||||||
|
).exec();
|
||||||
|
return result.modifiedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
393
packages/api/src/modules/pantry/pantry.routes.test.ts
Normal file
393
packages/api/src/modules/pantry/pantry.routes.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockFindByHousehold,
|
||||||
|
mockFindById,
|
||||||
|
mockFindExpiringSoon,
|
||||||
|
mockCreate,
|
||||||
|
mockUpdate,
|
||||||
|
mockDelete,
|
||||||
|
mockGetWasteStats,
|
||||||
|
mockGetTopWastedProducts,
|
||||||
|
mockFindByIds,
|
||||||
|
mockBulkUpdateStatus,
|
||||||
|
mockFindActiveByHousehold,
|
||||||
|
mockUpdateFreshness,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockFindByHousehold: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
mockFindExpiringSoon: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockDelete: vi.fn(),
|
||||||
|
mockGetWasteStats: vi.fn(),
|
||||||
|
mockGetTopWastedProducts: vi.fn(),
|
||||||
|
mockFindByIds: vi.fn(),
|
||||||
|
mockBulkUpdateStatus: vi.fn(),
|
||||||
|
mockFindActiveByHousehold: vi.fn(),
|
||||||
|
mockUpdateFreshness: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockProductFindById } = vi.hoisted(() => ({
|
||||||
|
mockProductFindById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockFindApplicableRule } = vi.hoisted(() => ({
|
||||||
|
mockFindApplicableRule: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pantry.repository.js', () => ({
|
||||||
|
PantryRepository: class {
|
||||||
|
findByHousehold = mockFindByHousehold;
|
||||||
|
findById = mockFindById;
|
||||||
|
findExpiringSoon = mockFindExpiringSoon;
|
||||||
|
findActiveByHousehold = mockFindActiveByHousehold;
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
updateFreshness = mockUpdateFreshness;
|
||||||
|
delete = mockDelete;
|
||||||
|
getWasteStats = mockGetWasteStats;
|
||||||
|
getTopWastedProducts = mockGetTopWastedProducts;
|
||||||
|
findByIds = mockFindByIds;
|
||||||
|
bulkUpdateStatus = mockBulkUpdateStatus;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../products/products.repository.js', () => ({
|
||||||
|
ProductsRepository: class {
|
||||||
|
findById = mockProductFindById;
|
||||||
|
findByIds = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../freshness-rules/freshness-rules.repository.js', () => ({
|
||||||
|
FreshnessRulesRepository: class {
|
||||||
|
findApplicableRule = mockFindApplicableRule;
|
||||||
|
findByHousehold = vi.fn();
|
||||||
|
findById = vi.fn();
|
||||||
|
create = vi.fn();
|
||||||
|
update = vi.fn();
|
||||||
|
delete = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import pantryRoutes from './pantry.routes.js';
|
||||||
|
|
||||||
|
const freshness = {
|
||||||
|
estimatedExpiryDate: new Date('2024-02-01').toISOString(),
|
||||||
|
daysRemaining: 14,
|
||||||
|
urgency: 'fresh',
|
||||||
|
source: 'rule',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'item-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Milk',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece',
|
||||||
|
purchaseDate: new Date('2024-01-01').toISOString(),
|
||||||
|
status: 'sealed',
|
||||||
|
freshnessEstimate: freshness,
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pantry.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(pantryRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /pantry', () => {
|
||||||
|
it('returns paginated list', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [makeItem()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty list', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /pantry/expiring-soon', () => {
|
||||||
|
it('returns expiring items', async () => {
|
||||||
|
mockFindExpiringSoon.mockResolvedValue({
|
||||||
|
data: [makeItem()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry/expiring-soon',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /pantry/stats', () => {
|
||||||
|
it('returns waste stats', async () => {
|
||||||
|
mockGetWasteStats.mockResolvedValue([{ totalConsumed: 5, totalDiscarded: 2 }]);
|
||||||
|
mockGetTopWastedProducts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry/stats',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.totalItemsConsumed).toBe(5);
|
||||||
|
expect(body.wastePercentage).toBeCloseTo(28.57, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /pantry/:id', () => {
|
||||||
|
it('returns item', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeItem());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry/item-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().productName).toBe('Milk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns item with all optional fields', async () => {
|
||||||
|
mockFindById.mockResolvedValue(
|
||||||
|
makeItem({
|
||||||
|
expirationDate: new Date('2024-02-01').toISOString(),
|
||||||
|
openedDate: new Date('2024-01-05').toISOString(),
|
||||||
|
preparedDate: new Date('2024-01-06').toISOString(),
|
||||||
|
notes: 'Organic',
|
||||||
|
purchasePrice: 4.99,
|
||||||
|
storeId: 's1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry/item-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.expirationDate).toBeDefined();
|
||||||
|
expect(body.openedDate).toBeDefined();
|
||||||
|
expect(body.preparedDate).toBeDefined();
|
||||||
|
expect(body.notes).toBe('Organic');
|
||||||
|
expect(body.purchasePrice).toBe(4.99);
|
||||||
|
expect(body.storeId).toBe('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/pantry/missing',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /pantry', () => {
|
||||||
|
it('creates a pantry item', async () => {
|
||||||
|
mockProductFindById.mockResolvedValue({
|
||||||
|
_id: 'p1',
|
||||||
|
name: 'Milk',
|
||||||
|
category: 'dairy',
|
||||||
|
});
|
||||||
|
mockFindApplicableRule.mockResolvedValue({ shelfLifeDays: 14, openedLifeDays: 7 });
|
||||||
|
mockCreate.mockResolvedValue(makeItem());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/pantry',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId: 'p1',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing productId', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/pantry',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /pantry/:id', () => {
|
||||||
|
it('updates a pantry item', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeItem());
|
||||||
|
mockUpdate.mockResolvedValue(makeItem({ quantity: 3 }));
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/pantry/item-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ quantity: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /pantry/:id/transition', () => {
|
||||||
|
it('transitions item status', async () => {
|
||||||
|
const item = makeItem({ status: 'sealed' });
|
||||||
|
mockFindById.mockResolvedValue(item);
|
||||||
|
mockUpdate.mockResolvedValue({ ...item, status: 'consumed' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/pantry/item-1/transition',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'consumed' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /pantry/batch-transition', () => {
|
||||||
|
it('batch transitions items', async () => {
|
||||||
|
mockFindByIds.mockResolvedValue([makeItem()]);
|
||||||
|
mockBulkUpdateStatus.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/pantry/batch-transition',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemIds: ['item-1'],
|
||||||
|
status: 'consumed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().transitioned).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /pantry/:id', () => {
|
||||||
|
it('deletes a pantry item', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeItem());
|
||||||
|
mockDelete.mockResolvedValue(makeItem());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/pantry/item-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
285
packages/api/src/modules/pantry/pantry.routes.ts
Normal file
285
packages/api/src/modules/pantry/pantry.routes.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreatePantryItemSchema,
|
||||||
|
UpdatePantryItemSchema,
|
||||||
|
TransitionPantryItemSchema,
|
||||||
|
BatchTransitionSchema,
|
||||||
|
PantryQuerySchema,
|
||||||
|
ExpiringQuerySchema,
|
||||||
|
WasteStatsQuerySchema,
|
||||||
|
PantryItemResponseSchema,
|
||||||
|
PantryItemListResponseSchema,
|
||||||
|
WasteStatsResponseSchema,
|
||||||
|
BatchTransitionResponseSchema,
|
||||||
|
type ItemStatus,
|
||||||
|
type FreshnessUrgency,
|
||||||
|
type FreshnessSource,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import type { StorageLocation } from '@meshitrack/shared';
|
||||||
|
import { PantryRepository } from './pantry.repository.js';
|
||||||
|
import { PantryService } from './pantry.service.js';
|
||||||
|
import { ProductsRepository } from '../products/products.repository.js';
|
||||||
|
import { FreshnessRulesRepository } from '../freshness-rules/freshness-rules.repository.js';
|
||||||
|
|
||||||
|
const HouseholdParams = z.object({ householdId: z.string() });
|
||||||
|
const ItemParams = z.object({ householdId: z.string(), id: z.string() });
|
||||||
|
|
||||||
|
type AnyPantryDoc = {
|
||||||
|
_id: string | { toString(): string };
|
||||||
|
householdId: string;
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
storageLocation: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
purchaseDate: string | Date;
|
||||||
|
expirationDate?: string | Date | null;
|
||||||
|
openedDate?: string | Date | null;
|
||||||
|
preparedDate?: string | Date | null;
|
||||||
|
status: string;
|
||||||
|
freshnessEstimate: {
|
||||||
|
estimatedExpiryDate: string | Date;
|
||||||
|
daysRemaining: number;
|
||||||
|
urgency: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
notes?: string | null;
|
||||||
|
purchasePrice?: number | null;
|
||||||
|
storeId?: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStr(v: string | { toString: () => string }): string {
|
||||||
|
return typeof v === 'string' ? v : v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(v: string | Date): string {
|
||||||
|
return typeof v === 'string' ? v : v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPantryResponse(doc: AnyPantryDoc): z.infer<typeof PantryItemResponseSchema> {
|
||||||
|
return {
|
||||||
|
_id: toStr(doc._id),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
productId: doc.productId,
|
||||||
|
productName: doc.productName,
|
||||||
|
storageLocation: doc.storageLocation as StorageLocation,
|
||||||
|
quantity: doc.quantity,
|
||||||
|
unit: doc.unit,
|
||||||
|
purchaseDate: toIso(doc.purchaseDate),
|
||||||
|
...(doc.expirationDate ? { expirationDate: toIso(doc.expirationDate) } : {}),
|
||||||
|
...(doc.openedDate ? { openedDate: toIso(doc.openedDate) } : {}),
|
||||||
|
...(doc.preparedDate ? { preparedDate: toIso(doc.preparedDate) } : {}),
|
||||||
|
status: doc.status as ItemStatus,
|
||||||
|
freshnessEstimate: {
|
||||||
|
estimatedExpiryDate: toIso(doc.freshnessEstimate.estimatedExpiryDate),
|
||||||
|
daysRemaining: doc.freshnessEstimate.daysRemaining,
|
||||||
|
urgency: doc.freshnessEstimate.urgency as FreshnessUrgency,
|
||||||
|
source: doc.freshnessEstimate.source as FreshnessSource,
|
||||||
|
},
|
||||||
|
...(doc.notes ? { notes: doc.notes } : {}),
|
||||||
|
...(doc.purchasePrice != null ? { purchasePrice: doc.purchasePrice } : {}),
|
||||||
|
...(doc.storeId ? { storeId: doc.storeId } : {}),
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
pantryRepository: PantryRepository;
|
||||||
|
pantryService: PantryService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
// Only register if not already registered (products repo may be registered by recipes)
|
||||||
|
if (!fastify.diContainer.hasRegistration('productsRepository')) {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!fastify.diContainer.hasRegistration('freshnessRulesRepository')) {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
freshnessRulesRepository: asClass(FreshnessRulesRepository, {
|
||||||
|
lifetime: Lifetime.SINGLETON,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fastify.diContainer.register({
|
||||||
|
pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
pantryService: asClass(PantryService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
|
||||||
|
// GET /pantry - list pantry items
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/pantry',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
querystring: PantryQuerySchema,
|
||||||
|
response: { 200: PantryItemListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
const mapped = {
|
||||||
|
data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)),
|
||||||
|
pagination: result.pagination,
|
||||||
|
};
|
||||||
|
return reply.send(mapped);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /pantry/expiring-soon
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/expiring-soon',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
querystring: ExpiringQuerySchema,
|
||||||
|
response: { 200: PantryItemListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const result = await service.getExpiringSoon(request.params.householdId, request.query);
|
||||||
|
const mapped = {
|
||||||
|
data: result.data.map((d) => toPantryResponse(d as unknown as AnyPantryDoc)),
|
||||||
|
pagination: result.pagination,
|
||||||
|
};
|
||||||
|
return reply.send(mapped);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /pantry/stats
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/stats',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
querystring: WasteStatsQuerySchema,
|
||||||
|
response: { 200: WasteStatsResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const result = await service.getWasteStats(request.params.householdId, request.query);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /pantry/:id
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/:id',
|
||||||
|
schema: {
|
||||||
|
params: ItemParams,
|
||||||
|
response: { 200: PantryItemResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const item = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /pantry
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/pantry',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
body: CreatePantryItemSchema,
|
||||||
|
response: { 201: PantryItemResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const item = await service.create(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toPantryResponse(item as unknown as AnyPantryDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /pantry/:id
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/:id',
|
||||||
|
schema: {
|
||||||
|
params: ItemParams,
|
||||||
|
body: UpdatePantryItemSchema,
|
||||||
|
response: { 200: PantryItemResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const item = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /pantry/:id/transition
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/:id/transition',
|
||||||
|
schema: {
|
||||||
|
params: ItemParams,
|
||||||
|
body: TransitionPantryItemSchema,
|
||||||
|
response: { 200: PantryItemResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const item = await service.transition(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toPantryResponse(item as unknown as AnyPantryDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /pantry/batch-transition
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/batch-transition',
|
||||||
|
schema: {
|
||||||
|
params: HouseholdParams,
|
||||||
|
body: BatchTransitionSchema,
|
||||||
|
response: { 200: BatchTransitionResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
const result = await service.batchTransition(request.params.householdId, request.body);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /pantry/:id
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/pantry/:id',
|
||||||
|
schema: {
|
||||||
|
params: ItemParams,
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = request.diScope.resolve<PantryService>('pantryService');
|
||||||
|
await service.delete(request.params.id, request.params.householdId);
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: 'pantry-routes' },
|
||||||
|
);
|
||||||
434
packages/api/src/modules/pantry/pantry.service.test.ts
Normal file
434
packages/api/src/modules/pantry/pantry.service.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { PantryService } from './pantry.service.js';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
import { ItemStatus } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const mockPantryRepo = {
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findExpiringSoon: vi.fn(),
|
||||||
|
findActiveByHousehold: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
updateFreshness: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
getWasteStats: vi.fn(),
|
||||||
|
getTopWastedProducts: vi.fn(),
|
||||||
|
findByIds: vi.fn(),
|
||||||
|
bulkUpdateStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFreshnessRulesRepo = {
|
||||||
|
findApplicableRule: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProductsRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByIds: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeItem(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: { toString: () => 'item-1' },
|
||||||
|
householdId: 'hh1',
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Milk',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece',
|
||||||
|
purchaseDate: new Date('2024-01-01').toISOString(),
|
||||||
|
status: ItemStatus.SEALED,
|
||||||
|
freshnessEstimate: {
|
||||||
|
estimatedExpiryDate: new Date('2024-01-15').toISOString(),
|
||||||
|
daysRemaining: 14,
|
||||||
|
urgency: 'fresh',
|
||||||
|
source: 'rule',
|
||||||
|
},
|
||||||
|
createdBy: 'user-1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProduct() {
|
||||||
|
return {
|
||||||
|
_id: 'p1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Milk',
|
||||||
|
category: 'dairy',
|
||||||
|
servingSize: 250,
|
||||||
|
servingUnit: 'ml',
|
||||||
|
nutrition: { calories: 60, protein: 3, carbs: 5, fat: 3 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(PantryService.name, () => {
|
||||||
|
let service: PantryService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new PantryService({
|
||||||
|
pantryRepository: mockPantryRepo as never,
|
||||||
|
freshnessRulesRepository: mockFreshnessRulesRepo as never,
|
||||||
|
productsRepository: mockProductsRepo as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockPantryRepo.findByHousehold.mockResolvedValue(expected);
|
||||||
|
const result = await service.list('hh1', { limit: 20 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns item when found', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
const result = await service.getById('item-1', 'hh1');
|
||||||
|
expect(result).toEqual(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates a pantry item', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(product);
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
});
|
||||||
|
mockPantryRepo.create.mockResolvedValue(makeItem());
|
||||||
|
|
||||||
|
const result = await service.create(
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
storageLocation: 'fridge' as never,
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece' as never,
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPantryRepo.create).toHaveBeenCalled();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates item with all optional fields', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||||
|
mockPantryRepo.create.mockResolvedValue(makeItem());
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
storageLocation: 'fridge' as never,
|
||||||
|
quantity: 2,
|
||||||
|
unit: 'piece' as never,
|
||||||
|
purchaseDate: '2024-01-01T00:00:00Z',
|
||||||
|
expirationDate: '2024-02-01T00:00:00Z',
|
||||||
|
notes: 'Organic',
|
||||||
|
purchasePrice: 4.99,
|
||||||
|
storeId: 's1',
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPantryRepo.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when product not found', async () => {
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
productId: 'missing',
|
||||||
|
storageLocation: 'fridge' as never,
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'piece' as never,
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns item', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, quantity: 3 });
|
||||||
|
|
||||||
|
const result = await service.update('item-1', 'hh1', { quantity: 3 });
|
||||||
|
expect((result as Record<string, unknown>).quantity).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(makeItem());
|
||||||
|
mockPantryRepo.update.mockResolvedValue(null);
|
||||||
|
await expect(service.update('item-1', 'hh1', { quantity: 3 })).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transition', () => {
|
||||||
|
it('transitions from sealed to opened', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
});
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
|
||||||
|
|
||||||
|
const result = await service.transition('item-1', 'hh1', { status: 'opened' as never });
|
||||||
|
expect((result as Record<string, unknown>).status).toBe(ItemStatus.OPENED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions from sealed to consumed', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
|
||||||
|
|
||||||
|
const result = await service.transition('item-1', 'hh1', { status: 'consumed' as never });
|
||||||
|
expect((result as Record<string, unknown>).status).toBe(ItemStatus.CONSUMED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions from sealed to discarded', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.DISCARDED });
|
||||||
|
|
||||||
|
const result = await service.transition('item-1', 'hh1', { status: 'discarded' as never });
|
||||||
|
expect((result as Record<string, unknown>).status).toBe(ItemStatus.DISCARDED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions from opened to prepared', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.OPENED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.PREPARED });
|
||||||
|
|
||||||
|
const result = await service.transition('item-1', 'hh1', { status: 'prepared' as never });
|
||||||
|
expect((result as Record<string, unknown>).status).toBe(ItemStatus.PREPARED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid transition', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.CONSUMED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
await expect(
|
||||||
|
service.transition('item-1', 'hh1', { status: 'opened' as never }),
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes notes and date in transition', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });
|
||||||
|
|
||||||
|
await service.transition('item-1', 'hh1', {
|
||||||
|
status: 'consumed' as never,
|
||||||
|
date: '2024-01-10T12:00:00Z',
|
||||||
|
notes: 'Used in cooking',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPantryRepo.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockPantryRepo.update.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.transition('item-1', 'hh1', { status: 'consumed' as never }),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates freshness when opening and product not found', async () => {
|
||||||
|
const item = makeItem({ status: ItemStatus.SEALED });
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(item);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||||
|
mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });
|
||||||
|
|
||||||
|
await service.transition('item-1', 'hh1', { status: 'opened' as never });
|
||||||
|
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'other',
|
||||||
|
'fridge',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchTransition', () => {
|
||||||
|
it('transitions valid items', async () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED }),
|
||||||
|
makeItem({ _id: { toString: () => 'id2' }, status: ItemStatus.OPENED }),
|
||||||
|
];
|
||||||
|
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||||
|
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const result = await service.batchTransition('hh1', {
|
||||||
|
itemIds: ['id1', 'id2'],
|
||||||
|
status: 'consumed' as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.transitioned).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips items with invalid transitions', async () => {
|
||||||
|
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.CONSUMED })];
|
||||||
|
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await service.batchTransition('hh1', {
|
||||||
|
itemIds: ['id1'],
|
||||||
|
status: 'consumed' as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.transitioned).toBe(0);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes date and notes as extra', async () => {
|
||||||
|
const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED })];
|
||||||
|
mockPantryRepo.findByIds.mockResolvedValue(items);
|
||||||
|
mockPantryRepo.bulkUpdateStatus.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await service.batchTransition('hh1', {
|
||||||
|
itemIds: ['id1'],
|
||||||
|
status: 'discarded' as never,
|
||||||
|
date: '2024-01-10T00:00:00Z',
|
||||||
|
notes: 'Expired',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPantryRepo.bulkUpdateStatus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExpiringSoon', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockPantryRepo.findExpiringSoon.mockResolvedValue(expected);
|
||||||
|
const result = await service.getExpiringSoon('hh1', { days: 7, limit: 20 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWasteStats', () => {
|
||||||
|
it('computes waste stats for a period', async () => {
|
||||||
|
mockPantryRepo.getWasteStats.mockResolvedValue([{ totalConsumed: 8, totalDiscarded: 2 }]);
|
||||||
|
mockPantryRepo.getTopWastedProducts.mockResolvedValue([
|
||||||
|
{ productId: 'p1', productName: 'Milk', count: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getWasteStats('hh1', { period: 'month' });
|
||||||
|
|
||||||
|
expect(result.totalItemsConsumed).toBe(8);
|
||||||
|
expect(result.totalItemsDiscarded).toBe(2);
|
||||||
|
expect(result.wastePercentage).toBe(20);
|
||||||
|
expect(result.topWastedProducts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles no data', async () => {
|
||||||
|
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||||
|
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.getWasteStats('hh1', { period: 'week' });
|
||||||
|
|
||||||
|
expect(result.totalItemsConsumed).toBe(0);
|
||||||
|
expect(result.totalItemsDiscarded).toBe(0);
|
||||||
|
expect(result.wastePercentage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quarter period', async () => {
|
||||||
|
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||||
|
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||||
|
const result = await service.getWasteStats('hh1', { period: 'quarter' });
|
||||||
|
expect(result.period.start).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles year period', async () => {
|
||||||
|
mockPantryRepo.getWasteStats.mockResolvedValue([]);
|
||||||
|
mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
|
||||||
|
const result = await service.getWasteStats('hh1', { period: 'year' });
|
||||||
|
expect(result.period.start).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshAllFreshness', () => {
|
||||||
|
it('refreshes all active items', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
});
|
||||||
|
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.refreshAllFreshness('hh1');
|
||||||
|
|
||||||
|
expect(mockPantryRepo.updateFreshness).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks items as expired when urgency is expired', async () => {
|
||||||
|
const item = makeItem({
|
||||||
|
purchaseDate: new Date('2020-01-01').toISOString(),
|
||||||
|
});
|
||||||
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
|
||||||
|
shelfLifeDays: 1,
|
||||||
|
openedLifeDays: 1,
|
||||||
|
});
|
||||||
|
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.refreshAllFreshness('hh1');
|
||||||
|
|
||||||
|
const updateCall = mockPantryRepo.updateFreshness.mock.calls[0];
|
||||||
|
expect(updateCall?.[2]).toBe(ItemStatus.EXPIRED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing product gracefully', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
|
||||||
|
mockProductsRepo.findById.mockResolvedValue(null);
|
||||||
|
mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
|
||||||
|
mockPantryRepo.updateFreshness.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.refreshAllFreshness('hh1');
|
||||||
|
|
||||||
|
expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'other',
|
||||||
|
'fridge',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('deletes item', async () => {
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(makeItem());
|
||||||
|
mockPantryRepo.delete.mockResolvedValue(makeItem());
|
||||||
|
const result = await service.delete('item-1', 'hh1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockPantryRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
295
packages/api/src/modules/pantry/pantry.service.ts
Normal file
295
packages/api/src/modules/pantry/pantry.service.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import type { PantryRepository } from './pantry.repository.js';
|
||||||
|
import type { FreshnessRulesRepository } from '../freshness-rules/freshness-rules.repository.js';
|
||||||
|
import type { ProductsRepository } from '../products/products.repository.js';
|
||||||
|
import { FreshnessCalculatorService } from './freshness-calculator.service.js';
|
||||||
|
import { ItemStatus } from '@meshitrack/shared';
|
||||||
|
import type {
|
||||||
|
CreatePantryItemInput,
|
||||||
|
UpdatePantryItemInput,
|
||||||
|
TransitionPantryItemInput,
|
||||||
|
BatchTransitionInput,
|
||||||
|
PantryQueryInput,
|
||||||
|
ExpiringQueryInput,
|
||||||
|
WasteStatsQueryInput,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
[ItemStatus.SEALED]: [ItemStatus.OPENED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
[ItemStatus.OPENED]: [ItemStatus.PREPARED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
[ItemStatus.PREPARED]: [ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
pantryRepository: PantryRepository;
|
||||||
|
freshnessRulesRepository: FreshnessRulesRepository;
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PantryService {
|
||||||
|
private readonly pantryRepository: PantryRepository;
|
||||||
|
private readonly freshnessRulesRepository: FreshnessRulesRepository;
|
||||||
|
private readonly productsRepository: ProductsRepository;
|
||||||
|
private readonly freshnessCalculator = new FreshnessCalculatorService();
|
||||||
|
|
||||||
|
public constructor({ pantryRepository, freshnessRulesRepository, productsRepository }: Deps) {
|
||||||
|
this.pantryRepository = pantryRepository;
|
||||||
|
this.freshnessRulesRepository = freshnessRulesRepository;
|
||||||
|
this.productsRepository = productsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: PantryQueryInput) {
|
||||||
|
return this.pantryRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const item = await this.pantryRepository.findById(id, householdId);
|
||||||
|
if (!item) throw new NotFoundError('Pantry item not found');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreatePantryItemInput, householdId: string, createdBy: string) {
|
||||||
|
const product = await this.productsRepository.findById(data.productId, householdId);
|
||||||
|
if (!product) throw new NotFoundError('Product not found');
|
||||||
|
|
||||||
|
const purchaseDate = data.purchaseDate ? new Date(data.purchaseDate) : new Date();
|
||||||
|
const expirationDate = data.expirationDate ? new Date(data.expirationDate) : undefined;
|
||||||
|
|
||||||
|
const rule = await this.freshnessRulesRepository.findApplicableRule(
|
||||||
|
householdId,
|
||||||
|
(product as Record<string, unknown>).category as string,
|
||||||
|
data.storageLocation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const freshnessEstimate = this.freshnessCalculator.calculate(
|
||||||
|
{
|
||||||
|
status: ItemStatus.SEALED,
|
||||||
|
storageLocation: data.storageLocation,
|
||||||
|
purchaseDate,
|
||||||
|
expirationDate,
|
||||||
|
},
|
||||||
|
rule,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.pantryRepository.create({
|
||||||
|
householdId,
|
||||||
|
productId: data.productId,
|
||||||
|
productName: (product as Record<string, unknown>).name as string,
|
||||||
|
storageLocation: data.storageLocation,
|
||||||
|
quantity: data.quantity,
|
||||||
|
unit: data.unit,
|
||||||
|
purchaseDate,
|
||||||
|
...(expirationDate ? { expirationDate } : {}),
|
||||||
|
status: ItemStatus.SEALED,
|
||||||
|
freshnessEstimate,
|
||||||
|
...(data.notes ? { notes: data.notes } : {}),
|
||||||
|
...(data.purchasePrice != null ? { purchasePrice: data.purchasePrice } : {}),
|
||||||
|
...(data.storeId ? { storeId: data.storeId } : {}),
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdatePantryItemInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const updated = await this.pantryRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Pantry item not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async transition(id: string, householdId: string, data: TransitionPantryItemInput) {
|
||||||
|
const item = await this.getById(id, householdId);
|
||||||
|
const currentStatus = (item as Record<string, unknown>).status as string;
|
||||||
|
const allowed = VALID_TRANSITIONS[currentStatus];
|
||||||
|
|
||||||
|
if (!allowed || !allowed.includes(data.status)) {
|
||||||
|
throw new BadRequestError(`Cannot transition from '${currentStatus}' to '${data.status}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = data.date ? new Date(data.date) : new Date();
|
||||||
|
const updates: Record<string, unknown> = { status: data.status };
|
||||||
|
|
||||||
|
if (data.status === ItemStatus.OPENED) {
|
||||||
|
updates['openedDate'] = now;
|
||||||
|
} else if (data.status === ItemStatus.PREPARED) {
|
||||||
|
updates['preparedDate'] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.notes) updates['notes'] = data.notes;
|
||||||
|
|
||||||
|
// Recalculate freshness if transitioning to opened (shelf life changes)
|
||||||
|
if (data.status === ItemStatus.OPENED) {
|
||||||
|
const product = await this.productsRepository.findById(
|
||||||
|
(item as Record<string, unknown>).productId as string,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
const category = product
|
||||||
|
? ((product as Record<string, unknown>).category as string)
|
||||||
|
: 'other';
|
||||||
|
const storageLocation = (item as Record<string, unknown>).storageLocation as string;
|
||||||
|
|
||||||
|
const rule = await this.freshnessRulesRepository.findApplicableRule(
|
||||||
|
householdId,
|
||||||
|
category,
|
||||||
|
storageLocation,
|
||||||
|
);
|
||||||
|
|
||||||
|
const freshness = this.freshnessCalculator.calculate(
|
||||||
|
{
|
||||||
|
status: ItemStatus.OPENED,
|
||||||
|
storageLocation: storageLocation as never,
|
||||||
|
purchaseDate: new Date((item as Record<string, unknown>).purchaseDate as string),
|
||||||
|
expirationDate: (item as Record<string, unknown>).expirationDate as Date | undefined,
|
||||||
|
openedDate: now,
|
||||||
|
},
|
||||||
|
rule,
|
||||||
|
);
|
||||||
|
|
||||||
|
updates['freshnessEstimate'] = freshness;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.pantryRepository.update(id, householdId, updates);
|
||||||
|
if (!updated) throw new NotFoundError('Pantry item not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async batchTransition(householdId: string, data: BatchTransitionInput) {
|
||||||
|
const items = await this.pantryRepository.findByIds(data.itemIds, householdId);
|
||||||
|
|
||||||
|
const validIds: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const currentStatus = (item as Record<string, unknown>).status as string;
|
||||||
|
const allowed = VALID_TRANSITIONS[currentStatus];
|
||||||
|
if (allowed && allowed.includes(data.status)) {
|
||||||
|
validIds.push((item as Record<string, unknown>)._id!.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra: Record<string, unknown> = {};
|
||||||
|
if (data.date) extra['updatedAt'] = new Date(data.date);
|
||||||
|
if (data.notes) extra['notes'] = data.notes;
|
||||||
|
|
||||||
|
const transitioned =
|
||||||
|
validIds.length > 0
|
||||||
|
? await this.pantryRepository.bulkUpdateStatus(
|
||||||
|
validIds,
|
||||||
|
householdId,
|
||||||
|
data.status as ItemStatus,
|
||||||
|
extra,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transitioned,
|
||||||
|
failed: data.itemIds.length - transitioned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getExpiringSoon(householdId: string, query: ExpiringQueryInput) {
|
||||||
|
return this.pantryRepository.findExpiringSoon(
|
||||||
|
householdId,
|
||||||
|
query.days,
|
||||||
|
query.cursor,
|
||||||
|
query.limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWasteStats(householdId: string, query: WasteStatsQueryInput) {
|
||||||
|
const { start, end } = this.getPeriodDates(query.period);
|
||||||
|
|
||||||
|
const [stats] = (await this.pantryRepository.getWasteStats(householdId, start, end)) as Record<
|
||||||
|
string,
|
||||||
|
number
|
||||||
|
>[];
|
||||||
|
const totalConsumed = stats?.totalConsumed ?? 0;
|
||||||
|
const totalDiscarded = stats?.totalDiscarded ?? 0;
|
||||||
|
const total = totalConsumed + totalDiscarded;
|
||||||
|
const wastePercentage = total > 0 ? Math.round((totalDiscarded / total) * 10000) / 100 : 0;
|
||||||
|
|
||||||
|
const topWastedProducts = await this.pantryRepository.getTopWastedProducts(
|
||||||
|
householdId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { start: start.toISOString(), end: end.toISOString() },
|
||||||
|
totalItemsConsumed: totalConsumed,
|
||||||
|
totalItemsDiscarded: totalDiscarded,
|
||||||
|
wastePercentage,
|
||||||
|
topWastedCategories: [],
|
||||||
|
topWastedProducts: topWastedProducts as {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
count: number;
|
||||||
|
}[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshAllFreshness(householdId: string) {
|
||||||
|
const items = await this.pantryRepository.findActiveByHousehold(householdId);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const rec = item as Record<string, unknown>;
|
||||||
|
const product = await this.productsRepository.findById(rec.productId as string, householdId);
|
||||||
|
const category = product
|
||||||
|
? ((product as Record<string, unknown>).category as string)
|
||||||
|
: 'other';
|
||||||
|
|
||||||
|
const rule = await this.freshnessRulesRepository.findApplicableRule(
|
||||||
|
householdId,
|
||||||
|
category,
|
||||||
|
rec.storageLocation as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const freshness = this.freshnessCalculator.calculate(
|
||||||
|
{
|
||||||
|
status: rec.status as ItemStatus,
|
||||||
|
storageLocation: rec.storageLocation as never,
|
||||||
|
purchaseDate: new Date(rec.purchaseDate as string),
|
||||||
|
expirationDate: rec.expirationDate as Date | undefined,
|
||||||
|
openedDate: rec.openedDate as Date | undefined,
|
||||||
|
},
|
||||||
|
rule,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newStatus =
|
||||||
|
freshness.urgency === 'expired' && this.freshnessCalculator.isActive(rec.status as string)
|
||||||
|
? ItemStatus.EXPIRED
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await this.pantryRepository.updateFreshness(
|
||||||
|
(rec._id as { toString(): string }).toString(),
|
||||||
|
freshness as unknown as Record<string, unknown>,
|
||||||
|
newStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
const item = await this.getById(id, householdId);
|
||||||
|
await this.pantryRepository.delete(id, householdId);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPeriodDates(period: string): { start: Date; end: Date } {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'week':
|
||||||
|
start.setDate(end.getDate() - 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
start.setMonth(end.getMonth() - 1);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
start.setMonth(end.getMonth() - 3);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
start.setFullYear(end.getFullYear() - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
}
|
||||||
487
packages/api/src/modules/products/barcode.service.test.ts
Normal file
487
packages/api/src/modules/products/barcode.service.test.ts
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { BarcodeService } from './barcode.service.js';
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
request: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { request as undiciRequest } from 'undici';
|
||||||
|
|
||||||
|
const mockRequest = undiciRequest as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function makeMockRepo() {
|
||||||
|
return {
|
||||||
|
findByBarcode: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BarcodeService', () => {
|
||||||
|
let service: BarcodeService;
|
||||||
|
let mockRepo: ReturnType<typeof makeMockRepo>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRepo = makeMockRepo();
|
||||||
|
service = new BarcodeService({
|
||||||
|
productsRepository: mockRepo as unknown as ConstructorParameters<
|
||||||
|
typeof BarcodeService
|
||||||
|
>[0]['productsRepository'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached product from local DB', async () => {
|
||||||
|
const existing = { _id: 'p1', name: 'Test Product', barcode: '1234567890123' };
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(existing);
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '1234567890123', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
if (result.found) {
|
||||||
|
expect(result.cached).toBe(true);
|
||||||
|
expect(result.product).toEqual(existing);
|
||||||
|
}
|
||||||
|
expect(mockRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls Open Food Facts when not found locally and caches result', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
const savedProduct = { _id: 'p2', name: 'Nutella', barcode: '3017620422003' };
|
||||||
|
mockRepo.create.mockResolvedValue(savedProduct);
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Nutella',
|
||||||
|
brands: 'Ferrero',
|
||||||
|
categories_tags: ['en:snacks'],
|
||||||
|
serving_quantity: 15,
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_serving': 80,
|
||||||
|
proteins_serving: 0.9,
|
||||||
|
carbohydrates_serving: 8.5,
|
||||||
|
fat_serving: 4.7,
|
||||||
|
fiber_serving: 0.5,
|
||||||
|
sugars_serving: 8.2,
|
||||||
|
'saturated-fat_serving': 1.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '3017620422003', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
if (result.found) {
|
||||||
|
expect(result.cached).toBe(false);
|
||||||
|
}
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Nutella',
|
||||||
|
brand: 'Ferrero',
|
||||||
|
barcode: '3017620422003',
|
||||||
|
category: 'snacks',
|
||||||
|
servingSize: 15,
|
||||||
|
servingUnit: 'g',
|
||||||
|
source: 'barcode_lookup',
|
||||||
|
createdBy: 'u1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false when OFF returns 404', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 404,
|
||||||
|
body: { json: vi.fn().mockResolvedValue({}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false when OFF returns status 0', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({ status: 0, product: { product_name: 'X' } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false when OFF product has no product_name', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({ status: 1, product: {} }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false when network request throws', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRequest.mockRejectedValue(new Error('Connection timeout'));
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '0000000000000', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to per-100g nutrition when no serving data', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
const savedProduct = { _id: 'p3', name: 'Plain Rice', barcode: '1111111111111' };
|
||||||
|
mockRepo.create.mockResolvedValue(savedProduct);
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Plain Rice',
|
||||||
|
categories_tags: ['en:cereals'],
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 130,
|
||||||
|
proteins_100g: 2.7,
|
||||||
|
carbohydrates_100g: 28,
|
||||||
|
fat_100g: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '1111111111111', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
servingSize: 100,
|
||||||
|
nutrition: expect.objectContaining({
|
||||||
|
calories: 130,
|
||||||
|
protein: 2.7,
|
||||||
|
carbs: 28,
|
||||||
|
fat: 0.3,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps category from OFF categories_tags', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p4', name: 'Milk' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Milk',
|
||||||
|
categories_tags: ['en:dairies'],
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 60,
|
||||||
|
proteins_100g: 3.3,
|
||||||
|
carbohydrates_100g: 4.7,
|
||||||
|
fat_100g: 3.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '2222222222222', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'dairy' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses serving_size string when serving_quantity is absent', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p5', name: 'Yogurt' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Yogurt',
|
||||||
|
serving_size: '125 g',
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_serving': 110,
|
||||||
|
proteins_serving: 5,
|
||||||
|
carbohydrates_serving: 15,
|
||||||
|
fat_serving: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '3333333333333', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 125 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts sodium and cholesterol from grams to milligrams', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p6', name: 'Soup' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Soup',
|
||||||
|
serving_quantity: 250,
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_serving': 90,
|
||||||
|
proteins_serving: 4,
|
||||||
|
carbohydrates_serving: 12,
|
||||||
|
fat_serving: 2,
|
||||||
|
sodium_serving: 0.8,
|
||||||
|
cholesterol_serving: 0.015,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '4444444444444', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nutrition: expect.objectContaining({
|
||||||
|
sodium: 800,
|
||||||
|
cholesterol: 15,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles brand with multiple comma-separated values by taking first', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p7', name: 'Multi Brand' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Multi Brand',
|
||||||
|
brands: 'BrandA, BrandB, BrandC',
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 100,
|
||||||
|
proteins_100g: 5,
|
||||||
|
carbohydrates_100g: 20,
|
||||||
|
fat_100g: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '5555555555555', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ brand: 'BrandA' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns found:false when OFF product field is missing', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({ status: 1 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '6666666666666', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults category to OTHER when no matching tags', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p8', name: 'Unknown' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Unknown',
|
||||||
|
categories_tags: ['en:unknown-stuff'],
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 50,
|
||||||
|
proteins_100g: 1,
|
||||||
|
carbohydrates_100g: 10,
|
||||||
|
fat_100g: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '7777777777777', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'other' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles serving_size with no numeric value', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p9', name: 'Weird Serving' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Weird Serving',
|
||||||
|
serving_size: 'one portion',
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_serving': 100,
|
||||||
|
proteins_serving: 5,
|
||||||
|
carbohydrates_serving: 10,
|
||||||
|
fat_serving: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '8888888888888', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults nutrition to zeros when nutriments is undefined', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p10', name: 'No Nutrition' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'No Nutrition',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '9999999999999', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults serving size to 100 when serving_quantity is negative', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p11', name: 'Negative QTY' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Negative QTY',
|
||||||
|
serving_quantity: -1,
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 50,
|
||||||
|
proteins_100g: 2,
|
||||||
|
carbohydrates_100g: 8,
|
||||||
|
fat_100g: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '1010101010101', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses serving fallbacks when _serving nutriments are missing', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue({ _id: 'p12', name: 'Partial Nutrients' });
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
status: 1,
|
||||||
|
product: {
|
||||||
|
product_name: 'Partial Nutrients',
|
||||||
|
serving_quantity: 50,
|
||||||
|
nutriments: {
|
||||||
|
'energy-kcal_100g': 200,
|
||||||
|
proteins_100g: 10,
|
||||||
|
carbohydrates_100g: 30,
|
||||||
|
fat_100g: 5,
|
||||||
|
fiber_100g: 3,
|
||||||
|
sugars_100g: 12,
|
||||||
|
sodium_100g: 0.4,
|
||||||
|
'saturated-fat_100g': 1.5,
|
||||||
|
cholesterol_100g: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.lookup('hh1', '1212121212121', 'u1');
|
||||||
|
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
servingSize: 50,
|
||||||
|
nutrition: expect.objectContaining({
|
||||||
|
calories: 200,
|
||||||
|
protein: 10,
|
||||||
|
carbs: 30,
|
||||||
|
fat: 5,
|
||||||
|
fiber: 3,
|
||||||
|
sugar: 12,
|
||||||
|
sodium: 400,
|
||||||
|
saturatedFat: 1.5,
|
||||||
|
cholesterol: 20,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
packages/api/src/modules/products/barcode.service.ts
Normal file
210
packages/api/src/modules/products/barcode.service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { request } from 'undici';
|
||||||
|
import type { ProductsRepository } from './products.repository.js';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarcodeLookupSuccess {
|
||||||
|
found: true;
|
||||||
|
product: Record<string, unknown>;
|
||||||
|
cached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarcodeLookupNotFound {
|
||||||
|
found: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BarcodeLookupResult = BarcodeLookupSuccess | BarcodeLookupNotFound;
|
||||||
|
|
||||||
|
const OFF_BASE_URL = 'https://world.openfoodfacts.org/api/v2/product';
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
const USER_AGENT = 'MeshiTrack/0.1.0 (+self-hosted)';
|
||||||
|
|
||||||
|
const CATEGORY_MAP: Record<string, ProductCategory> = {
|
||||||
|
'en:dairies': ProductCategory.DAIRY,
|
||||||
|
'en:meats': ProductCategory.MEAT,
|
||||||
|
'en:poultry': ProductCategory.POULTRY,
|
||||||
|
'en:fishes': ProductCategory.SEAFOOD,
|
||||||
|
'en:seafood': ProductCategory.SEAFOOD,
|
||||||
|
'en:fruits': ProductCategory.FRUITS,
|
||||||
|
'en:vegetables': ProductCategory.VEGETABLES,
|
||||||
|
'en:cereals-and-potatoes': ProductCategory.GRAINS,
|
||||||
|
'en:cereals': ProductCategory.GRAINS,
|
||||||
|
'en:legumes': ProductCategory.LEGUMES,
|
||||||
|
'en:nuts': ProductCategory.NUTS_SEEDS,
|
||||||
|
'en:fats': ProductCategory.OILS_FATS,
|
||||||
|
'en:sauces': ProductCategory.CONDIMENTS,
|
||||||
|
'en:condiments': ProductCategory.CONDIMENTS,
|
||||||
|
'en:spices': ProductCategory.SPICES,
|
||||||
|
'en:beverages': ProductCategory.BEVERAGES,
|
||||||
|
'en:snacks': ProductCategory.SNACKS,
|
||||||
|
'en:frozen-foods': ProductCategory.FROZEN,
|
||||||
|
'en:canned-foods': ProductCategory.CANNED,
|
||||||
|
'en:breads': ProductCategory.BAKERY,
|
||||||
|
'en:supplements': ProductCategory.SUPPLEMENTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapCategory(tags: string[] | undefined): ProductCategory {
|
||||||
|
if (!tags || tags.length === 0) return ProductCategory.OTHER;
|
||||||
|
for (const tag of tags) {
|
||||||
|
const category = CATEGORY_MAP[tag];
|
||||||
|
if (category) return category;
|
||||||
|
}
|
||||||
|
return ProductCategory.OTHER;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OffNutriments {
|
||||||
|
'energy-kcal_serving'?: number;
|
||||||
|
'energy-kcal_100g'?: number;
|
||||||
|
proteins_serving?: number;
|
||||||
|
proteins_100g?: number;
|
||||||
|
carbohydrates_serving?: number;
|
||||||
|
carbohydrates_100g?: number;
|
||||||
|
fat_serving?: number;
|
||||||
|
fat_100g?: number;
|
||||||
|
fiber_serving?: number;
|
||||||
|
fiber_100g?: number;
|
||||||
|
sugars_serving?: number;
|
||||||
|
sugars_100g?: number;
|
||||||
|
sodium_serving?: number;
|
||||||
|
sodium_100g?: number;
|
||||||
|
'saturated-fat_serving'?: number;
|
||||||
|
'saturated-fat_100g'?: number;
|
||||||
|
cholesterol_serving?: number;
|
||||||
|
cholesterol_100g?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OffProduct {
|
||||||
|
product_name?: string;
|
||||||
|
brands?: string;
|
||||||
|
categories_tags?: string[];
|
||||||
|
serving_size?: string;
|
||||||
|
serving_quantity?: number;
|
||||||
|
nutriments?: OffNutriments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServingSize(servingSize?: string, servingQuantity?: number): number {
|
||||||
|
if (servingQuantity && servingQuantity > 0) return servingQuantity;
|
||||||
|
if (!servingSize) return 100;
|
||||||
|
const match = servingSize.match(/(\d+(?:\.\d+)?)/);
|
||||||
|
return match ? parseFloat(match[1]!) : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNutrition(nutriments: OffNutriments | undefined, hasServing: boolean) {
|
||||||
|
if (!nutriments) {
|
||||||
|
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
|
||||||
|
}
|
||||||
|
const suffix = hasServing ? '_serving' : '_100g';
|
||||||
|
const calories =
|
||||||
|
nutriments[`energy-kcal${suffix}` as keyof OffNutriments] ??
|
||||||
|
nutriments['energy-kcal_100g'] ??
|
||||||
|
0;
|
||||||
|
const protein =
|
||||||
|
nutriments[`proteins${suffix}` as keyof OffNutriments] ?? nutriments['proteins_100g'] ?? 0;
|
||||||
|
const carbs =
|
||||||
|
nutriments[`carbohydrates${suffix}` as keyof OffNutriments] ??
|
||||||
|
nutriments['carbohydrates_100g'] ??
|
||||||
|
0;
|
||||||
|
const fat = nutriments[`fat${suffix}` as keyof OffNutriments] ?? nutriments['fat_100g'] ?? 0;
|
||||||
|
|
||||||
|
const result: Record<string, number> = {
|
||||||
|
calories: Number(calories),
|
||||||
|
protein: Number(protein),
|
||||||
|
carbs: Number(carbs),
|
||||||
|
fat: Number(fat),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fiber = nutriments[`fiber${suffix}` as keyof OffNutriments] ?? nutriments['fiber_100g'];
|
||||||
|
if (fiber != null) result['fiber'] = Number(fiber);
|
||||||
|
|
||||||
|
const sugars = nutriments[`sugars${suffix}` as keyof OffNutriments] ?? nutriments['sugars_100g'];
|
||||||
|
if (sugars != null) result['sugar'] = Number(sugars);
|
||||||
|
|
||||||
|
const sodium = nutriments[`sodium${suffix}` as keyof OffNutriments] ?? nutriments['sodium_100g'];
|
||||||
|
if (sodium != null) result['sodium'] = Number(sodium) * 1000; // g → mg
|
||||||
|
|
||||||
|
const saturatedFat =
|
||||||
|
nutriments[`saturated-fat${suffix}` as keyof OffNutriments] ?? nutriments['saturated-fat_100g'];
|
||||||
|
if (saturatedFat != null) result['saturatedFat'] = Number(saturatedFat);
|
||||||
|
|
||||||
|
const cholesterol =
|
||||||
|
nutriments[`cholesterol${suffix}` as keyof OffNutriments] ?? nutriments['cholesterol_100g'];
|
||||||
|
if (cholesterol != null) result['cholesterol'] = Number(cholesterol) * 1000; // g → mg
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BarcodeService {
|
||||||
|
private readonly productsRepository: ProductsRepository;
|
||||||
|
|
||||||
|
public constructor({ productsRepository }: Deps) {
|
||||||
|
this.productsRepository = productsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async lookup(
|
||||||
|
householdId: string,
|
||||||
|
barcode: string,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<BarcodeLookupResult> {
|
||||||
|
// 1. Check local DB first
|
||||||
|
const existing = await this.productsRepository.findByBarcode(householdId, barcode);
|
||||||
|
if (existing) {
|
||||||
|
return { found: true, product: existing as unknown as Record<string, unknown>, cached: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Call Open Food Facts API
|
||||||
|
try {
|
||||||
|
const { statusCode, body } = await request(`${OFF_BASE_URL}/${barcode}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headersTimeout: TIMEOUT_MS,
|
||||||
|
bodyTimeout: TIMEOUT_MS,
|
||||||
|
headers: { 'User-Agent': USER_AGENT },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await body.json()) as { status?: number; product?: OffProduct };
|
||||||
|
if (!json.product || json.status === 0) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const offProduct = json.product;
|
||||||
|
if (!offProduct.product_name) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Map OFF response to Product shape
|
||||||
|
const hasServing = Boolean(offProduct.serving_quantity || offProduct.serving_size);
|
||||||
|
const servingSize = hasServing
|
||||||
|
? parseServingSize(offProduct.serving_size, offProduct.serving_quantity)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
const nutrition = mapNutrition(offProduct.nutriments, hasServing);
|
||||||
|
const brand = offProduct.brands?.split(',')[0]?.trim();
|
||||||
|
|
||||||
|
const productData = {
|
||||||
|
householdId,
|
||||||
|
name: offProduct.product_name,
|
||||||
|
...(brand ? { brand } : {}),
|
||||||
|
barcode,
|
||||||
|
category: mapCategory(offProduct.categories_tags),
|
||||||
|
servingSize,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition,
|
||||||
|
tags: [] as string[],
|
||||||
|
source: ProductSource.BARCODE_LOOKUP,
|
||||||
|
createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Cache in local DB
|
||||||
|
const saved = await this.productsRepository.create(productData);
|
||||||
|
return { found: true, product: saved as unknown as Record<string, unknown>, cached: false };
|
||||||
|
} catch {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
packages/api/src/modules/products/csv-parser.test.ts
Normal file
173
packages/api/src/modules/products/csv-parser.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from './csv-parser.js';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
describe('parseCsv', () => {
|
||||||
|
it('parses a valid CSV with all columns', () => {
|
||||||
|
const csv = [
|
||||||
|
'name,brand,barcode,category,servingSize,servingUnit,densityGPerMl,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol,tags',
|
||||||
|
'Chicken Breast,Tyson,1234567890123,meat,100,g,,165,31,0,3.6,0,0,74,1,85,protein;lean',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
brand: 'Tyson',
|
||||||
|
barcode: '1234567890123',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
tags: ['protein', 'lean'],
|
||||||
|
source: ProductSource.IMPORT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles minimal CSV with only name column', () => {
|
||||||
|
const csv = 'name\nRice\nBeans';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items[0]!.name).toBe('Rice');
|
||||||
|
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
|
||||||
|
expect(result.items[0]!.servingUnit).toBe(ServingUnit.GRAMS);
|
||||||
|
expect(result.items[0]!.servingSize).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for empty file', () => {
|
||||||
|
const result = parseCsv('');
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toBe('Empty file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when name column is missing', () => {
|
||||||
|
const csv = 'brand,category\nNikko,meat';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toContain('Missing required "name" column');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips rows with empty name', () => {
|
||||||
|
const csv = 'name,category\n,meat\nChicken,meat';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toContain('Missing required field: name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid servingUnit', () => {
|
||||||
|
const csv = 'name,servingUnit\nFlour,cup';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toContain('Invalid servingUnit');
|
||||||
|
expect(result.errors[0]!.message).toContain('cup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative servingSize', () => {
|
||||||
|
const csv = 'name,servingSize\nBad,-10';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quoted fields with commas', () => {
|
||||||
|
const csv = 'name,brand\n"Peanut Butter, Crunchy",Jif';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.name).toBe('Peanut Butter, Crunchy');
|
||||||
|
expect(result.items[0]!.brand).toBe('Jif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles escaped quotes in CSV', () => {
|
||||||
|
const csv = 'name,brand\n"8"" Pizza",DiGiorno';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.name).toBe('8" Pizza');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ml serving unit when specified', () => {
|
||||||
|
const csv = 'name,servingUnit\nMilk,ml';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.servingUnit).toBe(ServingUnit.MILLILITERS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes densityGPerMl when provided', () => {
|
||||||
|
const csv = 'name,densityGPerMl\nOlive Oil,0.92';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.densityGPerMl).toBe(0.92);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses optional nutrition fields', () => {
|
||||||
|
const csv =
|
||||||
|
'name,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol\nEgg,155,13,1.1,11,0,1.1,124,3.3,373';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.nutrition).toEqual({
|
||||||
|
calories: 155,
|
||||||
|
protein: 13,
|
||||||
|
carbs: 1.1,
|
||||||
|
fat: 11,
|
||||||
|
fiber: 0,
|
||||||
|
sugar: 1.1,
|
||||||
|
sodium: 124,
|
||||||
|
saturatedFat: 3.3,
|
||||||
|
cholesterol: 373,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Windows line endings (CRLF)', () => {
|
||||||
|
const csv = 'name,category\r\nApple,fruits\r\nBanana,fruits';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank lines', () => {
|
||||||
|
const csv = 'name\n\nApple\n\nBanana\n';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps valid category strings', () => {
|
||||||
|
const csv = 'name,category\nYogurt,dairy\nSalmon,seafood';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items[0]!.category).toBe(ProductCategory.DAIRY);
|
||||||
|
expect(result.items[1]!.category).toBe(ProductCategory.SEAFOOD);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults invalid category to OTHER', () => {
|
||||||
|
const csv = 'name,category\nMystery,invalid_cat';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports MAX_FILE_SIZE and MAX_ROWS constants', () => {
|
||||||
|
expect(MAX_FILE_SIZE).toBe(5 * 1024 * 1024);
|
||||||
|
expect(MAX_ROWS).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-numeric servingSize as error', () => {
|
||||||
|
const csv = 'name,servingSize\nBad,abc';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles case-insensitive headers', () => {
|
||||||
|
const csv = 'Name,Brand,Category,ServingSize,ServingUnit\nTest,Brand1,meat,50,g';
|
||||||
|
const result = parseCsv(csv);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]!.name).toBe('Test');
|
||||||
|
expect(result.items[0]!.brand).toBe('Brand1');
|
||||||
|
});
|
||||||
|
});
|
||||||
167
packages/api/src/modules/products/csv-parser.ts
Normal file
167
packages/api/src/modules/products/csv-parser.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import type { CreateProductInput } from '@meshitrack/shared';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = new Set(Object.values(ProductCategory));
|
||||||
|
const VALID_UNITS = new Set(Object.values(ServingUnit));
|
||||||
|
|
||||||
|
export interface CsvParseResult {
|
||||||
|
items: CreateProductInput[];
|
||||||
|
errors: { row: number; message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _EXPECTED_HEADERS = [
|
||||||
|
'name',
|
||||||
|
'brand',
|
||||||
|
'barcode',
|
||||||
|
'category',
|
||||||
|
'servingSize',
|
||||||
|
'servingUnit',
|
||||||
|
'densityGPerMl',
|
||||||
|
'calories',
|
||||||
|
'protein',
|
||||||
|
'carbs',
|
||||||
|
'fat',
|
||||||
|
'fiber',
|
||||||
|
'sugar',
|
||||||
|
'sodium',
|
||||||
|
'saturatedFat',
|
||||||
|
'cholesterol',
|
||||||
|
'tags',
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const fields: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i]!;
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
fields.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(current.trim());
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsv(content: string): CsvParseResult {
|
||||||
|
const lines = content.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return { items: [], errors: [{ row: 0, message: 'Empty file' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = parseCsvLine(lines[0]!).map((h) => h.toLowerCase().trim());
|
||||||
|
const nameIdx = headers.indexOf('name');
|
||||||
|
if (nameIdx === -1) {
|
||||||
|
return { items: [], errors: [{ row: 0, message: 'Missing required "name" column' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: CreateProductInput[] = [];
|
||||||
|
const errors: { row: number; message: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const row = i + 1;
|
||||||
|
const fields = parseCsvLine(lines[i]!);
|
||||||
|
try {
|
||||||
|
const get = (header: string) => {
|
||||||
|
const idx = headers.indexOf(header);
|
||||||
|
return idx >= 0 && idx < fields.length ? fields[idx]! : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = get('name');
|
||||||
|
if (!name) {
|
||||||
|
errors.push({ row, message: 'Missing required field: name' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryStr = get('category');
|
||||||
|
const category =
|
||||||
|
categoryStr && VALID_CATEGORIES.has(categoryStr as ProductCategory)
|
||||||
|
? (categoryStr as ProductCategory)
|
||||||
|
: ProductCategory.OTHER;
|
||||||
|
|
||||||
|
const unitStr = get('servingunit');
|
||||||
|
if (unitStr && !VALID_UNITS.has(unitStr as ServingUnit)) {
|
||||||
|
errors.push({
|
||||||
|
row,
|
||||||
|
message: `Invalid servingUnit: "${unitStr}". Must be g, ml, piece, or slice`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const servingUnit =
|
||||||
|
unitStr && VALID_UNITS.has(unitStr as ServingUnit)
|
||||||
|
? (unitStr as ServingUnit)
|
||||||
|
: ServingUnit.GRAMS;
|
||||||
|
|
||||||
|
const servingSizeStr = get('servingsize');
|
||||||
|
const servingSize = servingSizeStr ? parseFloat(servingSizeStr) : 100;
|
||||||
|
if (isNaN(servingSize) || servingSize <= 0) {
|
||||||
|
errors.push({ row, message: 'servingSize must be a positive number' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calories = parseFloat(get('calories') || '0') || 0;
|
||||||
|
const protein = parseFloat(get('protein') || '0') || 0;
|
||||||
|
const carbs = parseFloat(get('carbs') || '0') || 0;
|
||||||
|
const fat = parseFloat(get('fat') || '0') || 0;
|
||||||
|
|
||||||
|
const nutrition: Record<string, number> = { calories, protein, carbs, fat };
|
||||||
|
|
||||||
|
const fiber = get('fiber');
|
||||||
|
if (fiber) nutrition['fiber'] = parseFloat(fiber) || 0;
|
||||||
|
const sugar = get('sugar');
|
||||||
|
if (sugar) nutrition['sugar'] = parseFloat(sugar) || 0;
|
||||||
|
const sodium = get('sodium');
|
||||||
|
if (sodium) nutrition['sodium'] = parseFloat(sodium) || 0;
|
||||||
|
const saturatedFat = get('saturatedfat');
|
||||||
|
if (saturatedFat) nutrition['saturatedFat'] = parseFloat(saturatedFat) || 0;
|
||||||
|
const cholesterol = get('cholesterol');
|
||||||
|
if (cholesterol) nutrition['cholesterol'] = parseFloat(cholesterol) || 0;
|
||||||
|
|
||||||
|
const tagsStr = get('tags');
|
||||||
|
const tags = tagsStr
|
||||||
|
? tagsStr
|
||||||
|
.split(';')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const brand = get('brand') || undefined;
|
||||||
|
const barcode = get('barcode') || undefined;
|
||||||
|
const densityStr = get('densitygperml');
|
||||||
|
const densityGPerMl = densityStr ? parseFloat(densityStr) : undefined;
|
||||||
|
|
||||||
|
const item: CreateProductInput = {
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
servingSize,
|
||||||
|
servingUnit,
|
||||||
|
nutrition: nutrition as CreateProductInput['nutrition'],
|
||||||
|
tags,
|
||||||
|
source: ProductSource.IMPORT,
|
||||||
|
...(brand ? { brand } : {}),
|
||||||
|
...(barcode ? { barcode } : {}),
|
||||||
|
...(densityGPerMl && !isNaN(densityGPerMl) ? { densityGPerMl } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
} catch /* v8 ignore next */ {
|
||||||
|
errors.push({ row, message: 'Failed to parse row' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
export const MAX_ROWS = 5000;
|
||||||
298
packages/api/src/modules/products/products.repository.test.ts
Normal file
298
packages/api/src/modules/products/products.repository.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProductsRepository } from './products.repository.js';
|
||||||
|
|
||||||
|
const { mockSave, MockProductModel } = vi.hoisted(() => {
|
||||||
|
const mockSave = vi.fn();
|
||||||
|
function MockProductModel(this: { save: typeof mockSave }, data: unknown) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
this.save = mockSave;
|
||||||
|
}
|
||||||
|
Object.assign(MockProductModel, {
|
||||||
|
findOne: vi.fn(),
|
||||||
|
find: vi.fn(),
|
||||||
|
findOneAndUpdate: vi.fn(),
|
||||||
|
insertMany: vi.fn(),
|
||||||
|
});
|
||||||
|
return { mockSave, MockProductModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../schemas/product.schema.js', () => ({
|
||||||
|
ProductModel: MockProductModel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ProductModel } = await import('../../schemas/product.schema.js');
|
||||||
|
|
||||||
|
function makeChain(result: unknown = null) {
|
||||||
|
return {
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: vi.fn().mockResolvedValue(result),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(ProductsRepository.name, () => {
|
||||||
|
let repo: ProductsRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new ProductsRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('applies householdId and deletedAt filters', async () => {
|
||||||
|
const chain = makeChain([]);
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ householdId: 'hh1', deletedAt: { $exists: false } }),
|
||||||
|
);
|
||||||
|
expect(chain.sort).toHaveBeenCalledWith({ _id: 1 });
|
||||||
|
expect(chain.limit).toHaveBeenCalledWith(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies category filter', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, category: 'meat' as never });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(expect.objectContaining({ category: 'meat' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies barcode filter', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, barcode: '1234567890' });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ barcode: '1234567890' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies text search via q', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, q: 'chicken' });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: { $regex: 'chicken', $options: 'i' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies tags filter', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, tags: 'organic,fresh' });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ tags: { $all: ['organic', 'fresh'] } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty tags string', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, tags: '' });
|
||||||
|
|
||||||
|
const call = vi.mocked(ProductModel.find).mock.calls[0][0] as Record<string, unknown>;
|
||||||
|
expect(call).not.toHaveProperty('tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cursor filter', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
const cursor = Buffer.from('p1').toString('base64');
|
||||||
|
await repo.findByHousehold('hh1', { limit: 20, cursor });
|
||||||
|
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ _id: { $gt: 'p1' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasMore=true when extra item exists', async () => {
|
||||||
|
const items = Array.from({ length: 21 }, (_, i) => ({
|
||||||
|
_id: { toString: () => `p${i}` },
|
||||||
|
name: `Item ${i}`,
|
||||||
|
}));
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain(items) as never);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.data).toHaveLength(20);
|
||||||
|
expect(result.pagination.cursor).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasMore=false and null cursor when empty', async () => {
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
expect(result.pagination.cursor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('queries by id and householdId without deletedAt filter', async () => {
|
||||||
|
const mockProduct = { _id: 'p1', name: 'Apple', householdId: 'hh1' };
|
||||||
|
const chain = {
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: vi.fn().mockResolvedValue(mockProduct),
|
||||||
|
};
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
const result = await repo.findById('p1', 'hh1');
|
||||||
|
expect(ProductModel.findOne).toHaveBeenCalledWith({ _id: 'p1', householdId: 'hh1' });
|
||||||
|
expect(result).toEqual(mockProduct);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByIds', () => {
|
||||||
|
it('queries by multiple ids and householdId', async () => {
|
||||||
|
const products = [
|
||||||
|
{ _id: 'p1', name: 'Apple' },
|
||||||
|
{ _id: 'p2', name: 'Banana' },
|
||||||
|
];
|
||||||
|
vi.mocked(ProductModel.find).mockReturnValue(makeChain(products) as never);
|
||||||
|
|
||||||
|
const result = await repo.findByIds('hh1', ['p1', 'p2']);
|
||||||
|
expect(ProductModel.find).toHaveBeenCalledWith({
|
||||||
|
_id: { $in: ['p1', 'p2'] },
|
||||||
|
householdId: 'hh1',
|
||||||
|
});
|
||||||
|
expect(result).toEqual(products);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByBarcode', () => {
|
||||||
|
it('queries by householdId, barcode, and excludes deleted', async () => {
|
||||||
|
const mockProduct = { _id: 'p1', barcode: '1234567890' };
|
||||||
|
const chain = {
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: vi.fn().mockResolvedValue(mockProduct),
|
||||||
|
};
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
const result = await repo.findByBarcode('hh1', '1234567890');
|
||||||
|
expect(ProductModel.findOne).toHaveBeenCalledWith({
|
||||||
|
householdId: 'hh1',
|
||||||
|
barcode: '1234567890',
|
||||||
|
deletedAt: { $exists: false },
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockProduct);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDuplicate', () => {
|
||||||
|
it('queries by householdId and name', async () => {
|
||||||
|
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findDuplicate('hh1', 'Apple');
|
||||||
|
expect(ProductModel.findOne).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Apple',
|
||||||
|
deletedAt: { $exists: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes brand in filter when provided', async () => {
|
||||||
|
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findDuplicate('hh1', 'Apple', 'Dole');
|
||||||
|
expect(ProductModel.findOne).toHaveBeenCalledWith(expect.objectContaining({ brand: 'Dole' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the given id when excludeId provided', async () => {
|
||||||
|
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findDuplicate('hh1', 'Apple', undefined, 'p1');
|
||||||
|
expect(ProductModel.findOne).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ _id: { $ne: 'p1' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include _id filter when no excludeId', async () => {
|
||||||
|
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findDuplicate('hh1', 'Apple');
|
||||||
|
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
|
||||||
|
expect(call).not.toHaveProperty('_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include brand filter when brand is undefined', async () => {
|
||||||
|
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
|
||||||
|
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
|
||||||
|
|
||||||
|
await repo.findDuplicate('hh1', 'Apple', undefined);
|
||||||
|
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
|
||||||
|
expect(call).not.toHaveProperty('brand');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('calls findOneAndUpdate with correct filter and data', async () => {
|
||||||
|
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'p1' }) as never);
|
||||||
|
|
||||||
|
await repo.update('p1', 'hh1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
|
||||||
|
{ $set: { name: 'Updated' } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('softDelete', () => {
|
||||||
|
it('sets deletedAt on the document', async () => {
|
||||||
|
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(
|
||||||
|
makeChain({ _id: 'p1', deletedAt: new Date() }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repo.softDelete('p1', 'hh1');
|
||||||
|
|
||||||
|
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
|
||||||
|
{ $set: { deletedAt: expect.any(Date) } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns the new document as plain object', async () => {
|
||||||
|
const plainDoc = { _id: 'new-id', name: 'Apple' };
|
||||||
|
mockSave.mockResolvedValue({ toObject: () => plainDoc });
|
||||||
|
|
||||||
|
const result = await repo.create({ name: 'Apple' });
|
||||||
|
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(plainDoc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkCreate', () => {
|
||||||
|
it('calls insertMany with householdId merged into each item', async () => {
|
||||||
|
vi.mocked(ProductModel.insertMany).mockResolvedValue([] as never);
|
||||||
|
|
||||||
|
await repo.bulkCreate('hh1', [{ name: 'Apple' }, { name: 'Banana' }]);
|
||||||
|
|
||||||
|
expect(ProductModel.insertMany).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{ name: 'Apple', householdId: 'hh1' },
|
||||||
|
{ name: 'Banana', householdId: 'hh1' },
|
||||||
|
],
|
||||||
|
{ ordered: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
packages/api/src/modules/products/products.repository.ts
Normal file
98
packages/api/src/modules/products/products.repository.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { ProductModel } from '../../schemas/product.schema.js';
|
||||||
|
import type { ProductQueryInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export class ProductsRepository {
|
||||||
|
public async findByHousehold(householdId: string, query: ProductQueryInput) {
|
||||||
|
const filter: Record<string, unknown> = { householdId, deletedAt: { $exists: false } };
|
||||||
|
|
||||||
|
if (query.category) filter['category'] = query.category;
|
||||||
|
if (query.barcode) filter['barcode'] = query.barcode;
|
||||||
|
if (query.q) filter['name'] = { $regex: query.q, $options: 'i' };
|
||||||
|
if (query.tags) {
|
||||||
|
const tagList = query.tags.split(',').filter(Boolean);
|
||||||
|
if (tagList.length > 0) filter['tags'] = { $all: tagList };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await ProductModel.find(filter)
|
||||||
|
.sort({ _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0
|
||||||
|
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
// Returns the product regardless of soft-delete status so historical references
|
||||||
|
// in recipes, pantry, and grocery remain resolvable.
|
||||||
|
return ProductModel.findOne({ _id: id, householdId }).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByIds(householdId: string, ids: string[]) {
|
||||||
|
return ProductModel.find({ _id: { $in: ids }, householdId })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByBarcode(householdId: string, barcode: string) {
|
||||||
|
return ProductModel.findOne({ householdId, barcode, deletedAt: { $exists: false } })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findDuplicate(
|
||||||
|
householdId: string,
|
||||||
|
name: string,
|
||||||
|
brand?: string,
|
||||||
|
excludeId?: string,
|
||||||
|
) {
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
householdId,
|
||||||
|
name,
|
||||||
|
deletedAt: { $exists: false },
|
||||||
|
};
|
||||||
|
if (brand !== undefined) filter['brand'] = brand;
|
||||||
|
if (excludeId) filter['_id'] = { $ne: excludeId };
|
||||||
|
return ProductModel.findOne(filter).lean().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: Record<string, unknown>) {
|
||||||
|
const doc = new ProductModel(data);
|
||||||
|
const saved = await doc.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||||
|
return ProductModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||||
|
{ $set: data },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async softDelete(id: string, householdId: string) {
|
||||||
|
return ProductModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||||
|
{ $set: { deletedAt: new Date() } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async bulkCreate(householdId: string, items: Record<string, unknown>[]) {
|
||||||
|
const docs = items.map((item) => ({ ...item, householdId }));
|
||||||
|
return ProductModel.insertMany(docs, { ordered: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
579
packages/api/src/modules/products/products.routes.test.ts
Normal file
579
packages/api/src/modules/products/products.routes.test.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockFindByHousehold,
|
||||||
|
mockFindById,
|
||||||
|
mockFindByBarcode,
|
||||||
|
mockFindDuplicate,
|
||||||
|
mockCreate,
|
||||||
|
mockUpdate,
|
||||||
|
mockSoftDelete,
|
||||||
|
mockBulkCreate,
|
||||||
|
mockBarcodeLookup,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockFindByHousehold: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
mockFindByBarcode: vi.fn(),
|
||||||
|
mockFindDuplicate: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockSoftDelete: vi.fn(),
|
||||||
|
mockBulkCreate: vi.fn(),
|
||||||
|
mockBarcodeLookup: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./products.repository.js', () => ({
|
||||||
|
ProductsRepository: class {
|
||||||
|
findByHousehold = mockFindByHousehold;
|
||||||
|
findById = mockFindById;
|
||||||
|
findByIds = vi.fn();
|
||||||
|
findByBarcode = mockFindByBarcode;
|
||||||
|
findDuplicate = mockFindDuplicate;
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
softDelete = mockSoftDelete;
|
||||||
|
bulkCreate = mockBulkCreate;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./barcode.service.js', () => ({
|
||||||
|
BarcodeService: class {
|
||||||
|
lookup = mockBarcodeLookup;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import productsRoutes from './products.routes.js';
|
||||||
|
|
||||||
|
function makeFakeProduct(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'p1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
tags: [],
|
||||||
|
source: ProductSource.MANUAL,
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('products.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(productsRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/products', () => {
|
||||||
|
it('returns paginated list', async () => {
|
||||||
|
const product = makeFakeProduct();
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [product],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].name).toBe('Chicken Breast');
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes query params to service', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products?q=chicken&category=meat&limit=10',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFindByHousehold).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ q: 'chicken', category: ProductCategory.MEAT, limit: 10 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ObjectId and Date objects in response', async () => {
|
||||||
|
const product = makeFakeProduct({
|
||||||
|
_id: { toString: () => 'pid-obj' },
|
||||||
|
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
|
||||||
|
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
|
||||||
|
brand: 'Tyson',
|
||||||
|
densityGPerMl: 1.05,
|
||||||
|
});
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [product],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data[0]._id).toBe('pid-obj');
|
||||||
|
expect(body.data[0].brand).toBe('Tyson');
|
||||||
|
expect(body.data[0].densityGPerMl).toBe(1.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/products/barcode/:code', () => {
|
||||||
|
it('returns product when found by barcode', async () => {
|
||||||
|
mockBarcodeLookup.mockResolvedValue({
|
||||||
|
found: true,
|
||||||
|
product: makeFakeProduct({ barcode: '1234567890' }),
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products/barcode/1234567890',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Chicken Breast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when barcode not found', async () => {
|
||||||
|
mockBarcodeLookup.mockResolvedValue({ found: false });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products/barcode/9999999999',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json().found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/products/:id', () => {
|
||||||
|
it('returns a product', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeFakeProduct());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products/p1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Chicken Breast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when product not found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products/missing',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/products', () => {
|
||||||
|
it('creates a product and returns 201', async () => {
|
||||||
|
mockFindByBarcode.mockResolvedValue(null);
|
||||||
|
mockFindDuplicate.mockResolvedValue(null);
|
||||||
|
mockCreate.mockResolvedValue(makeFakeProduct());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: {
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().name).toBe('Chicken Breast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 on barcode conflict', async () => {
|
||||||
|
mockFindByBarcode.mockResolvedValue(makeFakeProduct());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: {
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
barcode: '1234567890',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on validation failure', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: { name: '' }, // missing required fields
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/products/:id', () => {
|
||||||
|
it('updates product', async () => {
|
||||||
|
const product = makeFakeProduct();
|
||||||
|
mockFindById.mockResolvedValue(product);
|
||||||
|
mockFindByBarcode.mockResolvedValue(null);
|
||||||
|
mockFindDuplicate.mockResolvedValue(null);
|
||||||
|
mockUpdate.mockResolvedValue({ ...product, name: 'Updated' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/products/p1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: { name: 'Updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown product', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/products/missing',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: { name: 'X' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/households/:householdId/products/:id', () => {
|
||||||
|
it('returns 204 on successful delete', async () => {
|
||||||
|
const product = makeFakeProduct();
|
||||||
|
mockFindById.mockResolvedValue(product);
|
||||||
|
mockSoftDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/products/p1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown product', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/products/missing',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/products/smart-add', () => {
|
||||||
|
it('returns available:false with NoOp provider', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/smart-add',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
payload: { text: 'chicken breast 100g' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ available: false, message: 'LLM not configured' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/products/import', () => {
|
||||||
|
it('imports products from CSV file', async () => {
|
||||||
|
mockFindByBarcode.mockResolvedValue(null);
|
||||||
|
mockFindDuplicate.mockResolvedValue(null);
|
||||||
|
mockBulkCreate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const csv =
|
||||||
|
'name,category,servingSize,servingUnit,calories,protein,carbs,fat\nRice,grains,100,g,130,2.7,28,0.3';
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="products.csv"',
|
||||||
|
'Content-Type: text/csv',
|
||||||
|
'',
|
||||||
|
csv,
|
||||||
|
`--${boundary}--`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const json = res.json();
|
||||||
|
expect(json.imported).toBe(1);
|
||||||
|
expect(json.skipped).toBe(0);
|
||||||
|
expect(json.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports products from JSON file', async () => {
|
||||||
|
mockFindByBarcode.mockResolvedValue(null);
|
||||||
|
mockFindDuplicate.mockResolvedValue(null);
|
||||||
|
mockBulkCreate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const jsonData = JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'Beans',
|
||||||
|
category: 'legumes',
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: 'g',
|
||||||
|
nutrition: { calories: 120, protein: 8, carbs: 20, fat: 0.5 },
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="products.json"',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'',
|
||||||
|
jsonData,
|
||||||
|
`--${boundary}--`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().imported).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when no file uploaded', async () => {
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = `--${boundary}--`;
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid JSON', async () => {
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="bad.json"',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'',
|
||||||
|
'{not valid json',
|
||||||
|
`--${boundary}--`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toBe('Invalid JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when JSON is not an array', async () => {
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="obj.json"',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'',
|
||||||
|
'{"name": "not an array"}',
|
||||||
|
`--${boundary}--`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toBe('JSON must be an array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when JSON exceeds max rows', async () => {
|
||||||
|
const items = Array.from({ length: 5001 }, (_, i) => ({
|
||||||
|
name: `Item ${i}`,
|
||||||
|
category: 'other',
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: 'g',
|
||||||
|
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||||
|
tags: [],
|
||||||
|
}));
|
||||||
|
const boundary = '----FormBoundary';
|
||||||
|
const body = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="big.json"',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'',
|
||||||
|
JSON.stringify(items),
|
||||||
|
`--${boundary}--`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/products/import',
|
||||||
|
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain('5000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toProductResponse optional fields', () => {
|
||||||
|
it('includes optional nutrition fields and imageUrl when present', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeFakeProduct({
|
||||||
|
densityGPerMl: 1.1,
|
||||||
|
imageUrl: 'https://example.com/img.jpg',
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
nutrition: {
|
||||||
|
calories: 100,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 10,
|
||||||
|
fat: 2,
|
||||||
|
fiber: 3,
|
||||||
|
sugar: 1,
|
||||||
|
sodium: 50,
|
||||||
|
saturatedFat: 0.5,
|
||||||
|
cholesterol: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/products',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const product = res.json().data[0];
|
||||||
|
expect(product.densityGPerMl).toBe(1.1);
|
||||||
|
expect(product.imageUrl).toBe('https://example.com/img.jpg');
|
||||||
|
expect(product.deletedAt).toBeDefined();
|
||||||
|
expect(product.nutrition.fiber).toBe(3);
|
||||||
|
expect(product.nutrition.sugar).toBe(1);
|
||||||
|
expect(product.nutrition.sodium).toBe(50);
|
||||||
|
expect(product.nutrition.saturatedFat).toBe(0.5);
|
||||||
|
expect(product.nutrition.cholesterol).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
308
packages/api/src/modules/products/products.routes.ts
Normal file
308
packages/api/src/modules/products/products.routes.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import {
|
||||||
|
CreateProductSchema,
|
||||||
|
UpdateProductSchema,
|
||||||
|
ProductQuerySchema,
|
||||||
|
ProductResponseSchema,
|
||||||
|
ProductListResponseSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { ProductsRepository } from './products.repository.js';
|
||||||
|
import { ProductsService } from './products.service.js';
|
||||||
|
import { BarcodeService } from './barcode.service.js';
|
||||||
|
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from './csv-parser.js';
|
||||||
|
|
||||||
|
type AnyProductDoc = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
brand?: string | null;
|
||||||
|
barcode?: string | null;
|
||||||
|
category: string;
|
||||||
|
servingSize: number;
|
||||||
|
servingUnit: string;
|
||||||
|
densityGPerMl?: number | null;
|
||||||
|
nutrition: {
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fat: number;
|
||||||
|
fiber?: number | null;
|
||||||
|
sugar?: number | null;
|
||||||
|
sodium?: number | null;
|
||||||
|
saturatedFat?: number | null;
|
||||||
|
cholesterol?: number | null;
|
||||||
|
};
|
||||||
|
tags: string[];
|
||||||
|
imageUrl?: string | null;
|
||||||
|
source: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string | { toISOString: () => string };
|
||||||
|
updatedAt: string | { toISOString: () => string };
|
||||||
|
deletedAt?: string | { toISOString: () => string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 toProductResponse(doc: AnyProductDoc): z.infer<typeof ProductResponseSchema> {
|
||||||
|
return {
|
||||||
|
_id: toStr(doc._id),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
name: doc.name,
|
||||||
|
...(doc.brand ? { brand: doc.brand } : {}),
|
||||||
|
...(doc.barcode ? { barcode: doc.barcode } : {}),
|
||||||
|
category: doc.category,
|
||||||
|
servingSize: doc.servingSize,
|
||||||
|
servingUnit: doc.servingUnit,
|
||||||
|
...(doc.densityGPerMl != null ? { densityGPerMl: doc.densityGPerMl } : {}),
|
||||||
|
nutrition: {
|
||||||
|
calories: doc.nutrition.calories,
|
||||||
|
protein: doc.nutrition.protein,
|
||||||
|
carbs: doc.nutrition.carbs,
|
||||||
|
fat: doc.nutrition.fat,
|
||||||
|
...(doc.nutrition.fiber != null ? { fiber: doc.nutrition.fiber } : {}),
|
||||||
|
...(doc.nutrition.sugar != null ? { sugar: doc.nutrition.sugar } : {}),
|
||||||
|
...(doc.nutrition.sodium != null ? { sodium: doc.nutrition.sodium } : {}),
|
||||||
|
...(doc.nutrition.saturatedFat != null ? { saturatedFat: doc.nutrition.saturatedFat } : {}),
|
||||||
|
...(doc.nutrition.cholesterol != null ? { cholesterol: doc.nutrition.cholesterol } : {}),
|
||||||
|
},
|
||||||
|
tags: doc.tags,
|
||||||
|
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
|
||||||
|
source: doc.source,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
...(doc.deletedAt ? { deletedAt: toIso(doc.deletedAt) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
productsService: ProductsService;
|
||||||
|
barcodeService: BarcodeService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
barcodeService: asClass(BarcodeService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/products — list/search
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/products',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: ProductQuerySchema,
|
||||||
|
response: { 200: ProductListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toProductResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/products/barcode/:code — barcode lookup
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/products/barcode/:code',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ code: z.string() }),
|
||||||
|
response: {
|
||||||
|
200: ProductResponseSchema,
|
||||||
|
404: z.object({ found: z.literal(false) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { householdId, code } = request.params;
|
||||||
|
const service = fastify.diContainer.resolve('barcodeService');
|
||||||
|
const result = await service.lookup(householdId, code, request.user.keycloakId);
|
||||||
|
if (!result.found) {
|
||||||
|
return reply.status(404).send({ found: false });
|
||||||
|
}
|
||||||
|
return reply.send(toProductResponse(result.product as AnyProductDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/products/:id — get by id
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/products/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: ProductResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
const product = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toProductResponse(product));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/products — create
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/products',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreateProductSchema,
|
||||||
|
response: { 201: ProductResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
const product = await service.create(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toProductResponse(product));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/v1/households/:householdId/products/:id — update
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/products/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: UpdateProductSchema,
|
||||||
|
response: { 200: ProductResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
const product = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toProductResponse(product));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/households/:householdId/products/:id — soft delete
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/products/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 204: z.undefined() },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
await service.delete(request.params.id, request.params.householdId);
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/products/smart-add — LLM placeholder
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/products/smart-add',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: z.object({ text: z.string().optional() }),
|
||||||
|
response: {
|
||||||
|
200: z.object({ available: z.literal(false), message: z.string() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (_request, reply) => {
|
||||||
|
return reply.send({
|
||||||
|
available: false,
|
||||||
|
message: 'LLM not configured',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/products/import — bulk CSV/JSON import
|
||||||
|
await fastify.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
|
||||||
|
|
||||||
|
const ImportResponseSchema = z.object({
|
||||||
|
imported: z.number(),
|
||||||
|
skipped: z.number(),
|
||||||
|
errors: z.array(z.object({ row: z.number(), message: z.string() })),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/products/import',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
response: {
|
||||||
|
200: ImportResponseSchema,
|
||||||
|
400: z.object({ error: z.string() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const file = await request.file();
|
||||||
|
if (!file) {
|
||||||
|
return reply.status(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
const content = buffer.toString('utf-8');
|
||||||
|
const contentType = file.mimetype;
|
||||||
|
|
||||||
|
let items: Parameters<ProductsService['importProducts']>[2];
|
||||||
|
let parseErrors: { row: number; message: string }[] = [];
|
||||||
|
|
||||||
|
if (contentType === 'application/json' || file.filename.endsWith('.json')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return reply.status(400).send({ error: 'JSON must be an array' });
|
||||||
|
}
|
||||||
|
items = parsed;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: 'Invalid JSON' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = parseCsv(content);
|
||||||
|
items = result.items;
|
||||||
|
parseErrors = result.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > MAX_ROWS) {
|
||||||
|
return reply.status(400).send({ error: `Maximum ${MAX_ROWS} rows allowed` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = fastify.diContainer.resolve('productsService');
|
||||||
|
const result = await service.importProducts(
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
items,
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
imported: result.imported,
|
||||||
|
skipped: result.skipped,
|
||||||
|
errors: [...parseErrors, ...result.errors],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
297
packages/api/src/modules/products/products.service.test.ts
Normal file
297
packages/api/src/modules/products/products.service.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProductsService } from './products.service.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../common/errors.js';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const mockRepo = {
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByIds: vi.fn(),
|
||||||
|
findByBarcode: vi.fn(),
|
||||||
|
findDuplicate: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
softDelete: vi.fn(),
|
||||||
|
bulkCreate: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeProduct(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'p1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
tags: [],
|
||||||
|
source: ProductSource.MANUAL,
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData = {
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
tags: [],
|
||||||
|
source: ProductSource.MANUAL,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(ProductsService.name, () => {
|
||||||
|
let service: ProductsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new ProductsService({ productsRepository: mockRepo as never });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRepo.findByHousehold.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await service.list('hh1', { limit: 20 });
|
||||||
|
|
||||||
|
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns product when found', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
|
||||||
|
const result = await service.getById('p1', 'hh1');
|
||||||
|
expect(result).toEqual(product);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when product not found', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('creates product when no barcode conflict and no duplicate', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue(makeProduct());
|
||||||
|
|
||||||
|
const result = await service.create(createData, 'hh1', 'u1');
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'Chicken Breast', householdId: 'hh1', createdBy: 'u1' }),
|
||||||
|
);
|
||||||
|
expect(result._id).toBe('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not check barcode when none provided', async () => {
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue(makeProduct());
|
||||||
|
|
||||||
|
await service.create(createData, 'hh1', 'u1');
|
||||||
|
|
||||||
|
expect(mockRepo.findByBarcode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictError when barcode already exists', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ barcode: '1234567890' }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...createData, barcode: '1234567890' }, 'hh1', 'u1'),
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictError when duplicate name+brand exists', async () => {
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
|
||||||
|
|
||||||
|
await expect(service.create(createData, 'hh1', 'u1')).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ProductSource.MANUAL as default source', async () => {
|
||||||
|
const dataWithoutSource = { ...createData };
|
||||||
|
delete (dataWithoutSource as Partial<typeof createData>).source;
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.create.mockResolvedValue(makeProduct());
|
||||||
|
|
||||||
|
await service.create(dataWithoutSource, 'hh1', 'u1');
|
||||||
|
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ source: ProductSource.MANUAL }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates product successfully', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.update.mockResolvedValue({ ...product, name: 'Updated' });
|
||||||
|
|
||||||
|
const result = await service.update('p1', 'hh1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(result.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when product does not exist', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictError when barcode belongs to another product', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ _id: 'p2' }));
|
||||||
|
|
||||||
|
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).rejects.toThrow(
|
||||||
|
ConflictError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw barcode conflict when barcode belongs to same product', async () => {
|
||||||
|
const product = makeProduct({ barcode: '1234567890' });
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(product);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.update.mockResolvedValue(product);
|
||||||
|
|
||||||
|
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictError when name+brand already taken by another', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(makeProduct({ _id: 'p2' }));
|
||||||
|
|
||||||
|
await expect(service.update('p1', 'hh1', { name: 'Chicken Breast' })).rejects.toThrow(
|
||||||
|
ConflictError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('p1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses current brand when data.brand is not provided in dedup check', async () => {
|
||||||
|
const product = makeProduct({ brand: 'BrandA' });
|
||||||
|
mockRepo.findById
|
||||||
|
.mockResolvedValueOnce(product) // getById call
|
||||||
|
.mockResolvedValueOnce(product); // second findById call in update
|
||||||
|
mockRepo.findByBarcode.mockResolvedValue(null);
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.update.mockResolvedValue(product);
|
||||||
|
|
||||||
|
await service.update('p1', 'hh1', { name: 'New Name' });
|
||||||
|
|
||||||
|
expect(mockRepo.findDuplicate).toHaveBeenCalledWith('hh1', 'New Name', 'BrandA', 'p1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('soft-deletes the product', async () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
mockRepo.findById.mockResolvedValue(product);
|
||||||
|
mockRepo.softDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
|
||||||
|
|
||||||
|
await service.delete('p1', 'hh1');
|
||||||
|
|
||||||
|
expect(mockRepo.softDelete).toHaveBeenCalledWith('p1', 'hh1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when product does not exist', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when softDelete returns null', async () => {
|
||||||
|
mockRepo.findById.mockResolvedValue(makeProduct());
|
||||||
|
mockRepo.softDelete.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importProducts', () => {
|
||||||
|
it('imports products skipping duplicates', async () => {
|
||||||
|
const { source: _s, ...importData } = createData;
|
||||||
|
const items = [
|
||||||
|
{ ...importData, name: 'Item A' },
|
||||||
|
{ ...importData, name: 'Item B', barcode: '111' },
|
||||||
|
{ ...importData, name: 'Item C' },
|
||||||
|
];
|
||||||
|
// Item B has a barcode collision
|
||||||
|
mockRepo.findByBarcode.mockResolvedValueOnce(makeProduct()); // Item B barcode exists
|
||||||
|
mockRepo.findDuplicate
|
||||||
|
.mockResolvedValueOnce(null) // Item A ok
|
||||||
|
.mockResolvedValueOnce(makeProduct()); // Item C duplicate
|
||||||
|
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.importProducts('hh1', 'u1', items as never);
|
||||||
|
|
||||||
|
expect(result.imported).toBe(1); // Item A
|
||||||
|
expect(result.skipped).toBe(2); // Item B (barcode), Item C (duplicate)
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'Item A', source: ProductSource.IMPORT }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call bulkCreate when all items are skipped', async () => {
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
|
||||||
|
|
||||||
|
const result = await service.importProducts('hh1', 'u1', [createData]);
|
||||||
|
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(mockRepo.bulkCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects provided source over IMPORT default', async () => {
|
||||||
|
mockRepo.findDuplicate.mockResolvedValue(null);
|
||||||
|
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.importProducts('hh1', 'u1', [
|
||||||
|
{ ...createData, source: ProductSource.BARCODE_LOOKUP },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.arrayContaining([expect.objectContaining({ source: ProductSource.BARCODE_LOOKUP })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records error when repo throws during item processing', async () => {
|
||||||
|
mockRepo.findByBarcode.mockRejectedValue(new Error('DB error'));
|
||||||
|
mockRepo.bulkCreate.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await service.importProducts('hh1', 'u1', [{ ...createData, barcode: '111' }]);
|
||||||
|
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]).toMatchObject({ row: 1, message: 'Validation error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
134
packages/api/src/modules/products/products.service.ts
Normal file
134
packages/api/src/modules/products/products.service.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import type { ProductsRepository } from './products.repository.js';
|
||||||
|
import type { CreateProductInput, UpdateProductInput, ProductQueryInput } from '@meshitrack/shared';
|
||||||
|
import { ProductSource } from '@meshitrack/shared';
|
||||||
|
import { NotFoundError, ConflictError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductsService {
|
||||||
|
private readonly productsRepository: ProductsRepository;
|
||||||
|
|
||||||
|
public constructor({ productsRepository }: Deps) {
|
||||||
|
this.productsRepository = productsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: ProductQueryInput) {
|
||||||
|
return this.productsRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const product = await this.productsRepository.findById(id, householdId);
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError('Product not found');
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateProductInput, householdId: string, createdBy: string) {
|
||||||
|
if (data.barcode) {
|
||||||
|
const existing = await this.productsRepository.findByBarcode(householdId, data.barcode);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('A product with this barcode already exists in your household');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicate = await this.productsRepository.findDuplicate(
|
||||||
|
householdId,
|
||||||
|
data.name,
|
||||||
|
data.brand,
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ConflictError('A product with the same name and brand already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.productsRepository.create({
|
||||||
|
...data,
|
||||||
|
householdId,
|
||||||
|
createdBy,
|
||||||
|
source: data.source ?? ProductSource.MANUAL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateProductInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
|
||||||
|
if (data.barcode) {
|
||||||
|
const existing = await this.productsRepository.findByBarcode(householdId, data.barcode);
|
||||||
|
if (existing && existing._id.toString() !== id) {
|
||||||
|
throw new ConflictError('A product with this barcode already exists in your household');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name || data.brand !== undefined) {
|
||||||
|
const current = await this.productsRepository.findById(id, householdId);
|
||||||
|
const name = data.name ?? current!.name;
|
||||||
|
const brand = data.brand !== undefined ? data.brand : (current!.brand ?? undefined);
|
||||||
|
|
||||||
|
const duplicate = await this.productsRepository.findDuplicate(householdId, name, brand, id);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ConflictError('A product with the same name and brand already exists');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.productsRepository.update(id, householdId, data);
|
||||||
|
if (!updated) throw new NotFoundError('Product not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const deleted = await this.productsRepository.softDelete(id, householdId);
|
||||||
|
if (!deleted) throw new NotFoundError('Product not found');
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async importProducts(householdId: string, createdBy: string, items: CreateProductInput[]) {
|
||||||
|
const imported: number[] = [];
|
||||||
|
const skipped: number[] = [];
|
||||||
|
const errors: { row: number; message: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]!;
|
||||||
|
try {
|
||||||
|
if (item.barcode) {
|
||||||
|
const existing = await this.productsRepository.findByBarcode(householdId, item.barcode);
|
||||||
|
if (existing) {
|
||||||
|
skipped.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duplicate = await this.productsRepository.findDuplicate(
|
||||||
|
householdId,
|
||||||
|
item.name,
|
||||||
|
item.brand,
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
skipped.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
imported.push(i);
|
||||||
|
} catch {
|
||||||
|
errors.push({ row: i + 1, message: 'Validation error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate = imported.map((i) => ({
|
||||||
|
...items[i]!,
|
||||||
|
householdId,
|
||||||
|
createdBy,
|
||||||
|
source: items[i]!.source ?? ProductSource.IMPORT,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
await this.productsRepository.bulkCreate(householdId, toCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: imported.length,
|
||||||
|
skipped: skipped.length,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { NutritionCalculatorService } from './nutrition-calculator.service.js';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const service = new NutritionCalculatorService();
|
||||||
|
|
||||||
|
function makeProduct(
|
||||||
|
overrides: Partial<{
|
||||||
|
servingSize: number;
|
||||||
|
servingUnit: string;
|
||||||
|
nutrition: Record<string, number>;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
servingSize: overrides.servingSize ?? 100,
|
||||||
|
servingUnit: overrides.servingUnit ?? 'g',
|
||||||
|
nutrition: {
|
||||||
|
calories: 200,
|
||||||
|
protein: 20,
|
||||||
|
carbs: 10,
|
||||||
|
fat: 8,
|
||||||
|
...(overrides.nutrition ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(NutritionCalculatorService.name, () => {
|
||||||
|
describe('calculateRecipeNutrition', () => {
|
||||||
|
it('calculates total and per-serving nutrition from one ingredient', () => {
|
||||||
|
const product = makeProduct(); // 200 kcal per 100g
|
||||||
|
const productMap = new Map([['p1', product]]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'p1', quantity: 200 }], // 200g = 2 servings worth
|
||||||
|
productMap,
|
||||||
|
2, // 2 servings
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalNutrition.calories).toBe(400);
|
||||||
|
expect(result.perServingNutrition.calories).toBe(200);
|
||||||
|
expect(result.totalNutrition.protein).toBe(40);
|
||||||
|
expect(result.perServingNutrition.protein).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums contributions from multiple ingredients', () => {
|
||||||
|
const p1 = makeProduct({ nutrition: { calories: 100, protein: 10, carbs: 5, fat: 4 } });
|
||||||
|
const p2 = makeProduct({ nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 } });
|
||||||
|
const productMap = new Map([
|
||||||
|
['p1', p1],
|
||||||
|
['p2', p2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[
|
||||||
|
{ productId: 'p1', quantity: 100 }, // 1× serving
|
||||||
|
{ productId: 'p2', quantity: 100 }, // 1× serving
|
||||||
|
],
|
||||||
|
productMap,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalNutrition.calories).toBe(300);
|
||||||
|
expect(result.perServingNutrition.calories).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses zero nutrition for unknown product', () => {
|
||||||
|
const productMap = new Map<string, ReturnType<typeof makeProduct>>();
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'missing', quantity: 100 }],
|
||||||
|
productMap,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalNutrition.calories).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates optional nutrients (sodium, fiber, sugar)', () => {
|
||||||
|
const product = makeProduct({
|
||||||
|
nutrition: {
|
||||||
|
calories: 100,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 10,
|
||||||
|
fat: 2,
|
||||||
|
sodium: 800,
|
||||||
|
fiber: 4,
|
||||||
|
sugar: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const productMap = new Map([['p1', product]]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'p1', quantity: 100 }],
|
||||||
|
productMap,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalNutrition.sodium).toBe(800);
|
||||||
|
expect(result.totalNutrition.fiber).toBe(4);
|
||||||
|
expect(result.totalNutrition.sugar).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates saturatedFat and cholesterol across multiple ingredients', () => {
|
||||||
|
const p1 = makeProduct({
|
||||||
|
nutrition: {
|
||||||
|
calories: 100,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 10,
|
||||||
|
fat: 3,
|
||||||
|
saturatedFat: 1.5,
|
||||||
|
cholesterol: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const p2 = makeProduct({
|
||||||
|
nutrition: {
|
||||||
|
calories: 150,
|
||||||
|
protein: 8,
|
||||||
|
carbs: 12,
|
||||||
|
fat: 5,
|
||||||
|
saturatedFat: 2.5,
|
||||||
|
cholesterol: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const productMap = new Map([
|
||||||
|
['p1', p1],
|
||||||
|
['p2', p2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[
|
||||||
|
{ productId: 'p1', quantity: 100 },
|
||||||
|
{ productId: 'p2', quantity: 100 },
|
||||||
|
],
|
||||||
|
productMap,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.totalNutrition.saturatedFat).toBe(4);
|
||||||
|
expect(result.totalNutrition.cholesterol).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles product with zero servingSize', () => {
|
||||||
|
const product = makeProduct({ servingSize: 0 });
|
||||||
|
const productMap = new Map([['p1', product]]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'p1', quantity: 100 }],
|
||||||
|
productMap,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ratio = 0 when servingSize = 0
|
||||||
|
expect(result.totalNutrition.calories).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero servings', () => {
|
||||||
|
const product = makeProduct();
|
||||||
|
const productMap = new Map([['p1', product]]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'p1', quantity: 100 }],
|
||||||
|
productMap,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.perServingNutrition.calories).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiplies saturatedFat and cholesterol by ratio', () => {
|
||||||
|
const product = makeProduct({
|
||||||
|
nutrition: {
|
||||||
|
calories: 100,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 10,
|
||||||
|
fat: 3,
|
||||||
|
saturatedFat: 2,
|
||||||
|
cholesterol: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const productMap = new Map([['p1', product]]);
|
||||||
|
|
||||||
|
const result = service.calculateRecipeNutrition(
|
||||||
|
[{ productId: 'p1', quantity: 200 }], // 2x serving
|
||||||
|
productMap,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2x ratio, then divide by 2 servings = same as per serving
|
||||||
|
expect(result.totalNutrition.saturatedFat).toBe(4);
|
||||||
|
expect(result.totalNutrition.cholesterol).toBe(80);
|
||||||
|
expect(result.perServingNutrition.saturatedFat).toBe(2);
|
||||||
|
expect(result.perServingNutrition.cholesterol).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateWarnings', () => {
|
||||||
|
it('flags HIGH_CALORIES when > 800 kcal/serving', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 900,
|
||||||
|
protein: 20,
|
||||||
|
carbs: 50,
|
||||||
|
fat: 30,
|
||||||
|
});
|
||||||
|
expect(warnings).toContain(NutritionWarning.HIGH_CALORIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags HIGH_SODIUM when > 1500 mg/serving', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 400,
|
||||||
|
protein: 15,
|
||||||
|
carbs: 30,
|
||||||
|
fat: 10,
|
||||||
|
sodium: 1600,
|
||||||
|
});
|
||||||
|
expect(warnings).toContain(NutritionWarning.HIGH_SODIUM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags LOW_PROTEIN when < 10 g/serving', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 300,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 40,
|
||||||
|
fat: 10,
|
||||||
|
});
|
||||||
|
expect(warnings).toContain(NutritionWarning.LOW_PROTEIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags LOW_FIBER when fiber is present and < 3 g/serving', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 300,
|
||||||
|
protein: 15,
|
||||||
|
carbs: 40,
|
||||||
|
fat: 10,
|
||||||
|
fiber: 1,
|
||||||
|
});
|
||||||
|
expect(warnings).toContain(NutritionWarning.LOW_FIBER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag LOW_FIBER when fiber is absent', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 300,
|
||||||
|
protein: 15,
|
||||||
|
carbs: 40,
|
||||||
|
fat: 10,
|
||||||
|
});
|
||||||
|
expect(warnings).not.toContain(NutritionWarning.LOW_FIBER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no warnings for a healthy meal', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 450,
|
||||||
|
protein: 30,
|
||||||
|
carbs: 40,
|
||||||
|
fat: 12,
|
||||||
|
sodium: 600,
|
||||||
|
fiber: 8,
|
||||||
|
sugar: 10,
|
||||||
|
saturatedFat: 4,
|
||||||
|
cholesterol: 80,
|
||||||
|
});
|
||||||
|
expect(warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can return multiple warnings', () => {
|
||||||
|
const warnings = service.generateWarnings({
|
||||||
|
calories: 900,
|
||||||
|
protein: 5,
|
||||||
|
carbs: 80,
|
||||||
|
fat: 40,
|
||||||
|
sodium: 2000,
|
||||||
|
sugar: 30,
|
||||||
|
saturatedFat: 20,
|
||||||
|
cholesterol: 250,
|
||||||
|
fiber: 1,
|
||||||
|
});
|
||||||
|
expect(warnings.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
127
packages/api/src/modules/recipes/nutrition-calculator.service.ts
Normal file
127
packages/api/src/modules/recipes/nutrition-calculator.service.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import type { NutritionInfo, RecipeIngredient } from '@meshitrack/shared';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
export interface ProductLike {
|
||||||
|
servingSize: number;
|
||||||
|
servingUnit: string;
|
||||||
|
nutrition: {
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fat: number;
|
||||||
|
fiber?: number | null;
|
||||||
|
sugar?: number | null;
|
||||||
|
sodium?: number | null;
|
||||||
|
saturatedFat?: number | null;
|
||||||
|
cholesterol?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeNutritionResult {
|
||||||
|
totalNutrition: NutritionInfo;
|
||||||
|
perServingNutrition: NutritionInfo;
|
||||||
|
ingredientContributions: NutritionInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const WARNING_THRESHOLDS: Array<{
|
||||||
|
warning: NutritionWarning;
|
||||||
|
check: (n: NutritionInfo) => boolean;
|
||||||
|
}> = [
|
||||||
|
{ warning: NutritionWarning.HIGH_CALORIES, check: (n) => n.calories > 800 },
|
||||||
|
{ warning: NutritionWarning.HIGH_SODIUM, check: (n) => (n.sodium ?? 0) > 1500 },
|
||||||
|
{ warning: NutritionWarning.HIGH_SUGAR, check: (n) => (n.sugar ?? 0) > 25 },
|
||||||
|
{ warning: NutritionWarning.HIGH_SATURATED_FAT, check: (n) => (n.saturatedFat ?? 0) > 13 },
|
||||||
|
{ warning: NutritionWarning.LOW_PROTEIN, check: (n) => n.protein < 10 },
|
||||||
|
{ warning: NutritionWarning.LOW_FIBER, check: (n) => (n.fiber ?? 999) < 3 },
|
||||||
|
{ warning: NutritionWarning.HIGH_CHOLESTEROL, check: (n) => (n.cholesterol ?? 0) > 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates nutrition for a recipe from its persisted (metric) ingredients and the
|
||||||
|
* corresponding product documents. All ingredient units must already be RecipeUnit
|
||||||
|
* (conversion is handled before calling this service).
|
||||||
|
*/
|
||||||
|
export class NutritionCalculatorService {
|
||||||
|
/**
|
||||||
|
* Calculate total and per-serving nutrition for a recipe.
|
||||||
|
*
|
||||||
|
* For each ingredient:
|
||||||
|
* ratio = ingredient.quantity / product.servingSize
|
||||||
|
* contribution = product.nutrition * ratio
|
||||||
|
* totalNutrition = sum of contributions
|
||||||
|
* perServingNutrition = totalNutrition / servings
|
||||||
|
*/
|
||||||
|
public calculateRecipeNutrition(
|
||||||
|
ingredients: Array<Pick<RecipeIngredient, 'productId' | 'quantity'>>,
|
||||||
|
productMap: Map<string, ProductLike>,
|
||||||
|
servings: number,
|
||||||
|
): RecipeNutritionResult {
|
||||||
|
const ingredientContributions: NutritionInfo[] = [];
|
||||||
|
|
||||||
|
for (const ingredient of ingredients) {
|
||||||
|
const product = productMap.get(ingredient.productId);
|
||||||
|
if (!product) {
|
||||||
|
ingredientContributions.push(zeroNutrition());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = product.servingSize > 0 ? ingredient.quantity / product.servingSize : 0;
|
||||||
|
ingredientContributions.push(multiplyNutrition(product.nutrition, ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNutrition = sumNutrition(ingredientContributions);
|
||||||
|
const perServingNutrition = multiplyNutrition(totalNutrition, servings > 0 ? 1 / servings : 0);
|
||||||
|
|
||||||
|
return { totalNutrition, perServingNutrition, ingredientContributions };
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateWarnings(perServingNutrition: NutritionInfo): NutritionWarning[] {
|
||||||
|
return WARNING_THRESHOLDS.filter(({ check }) => check(perServingNutrition)).map(
|
||||||
|
({ warning }) => warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeroNutrition(): NutritionInfo {
|
||||||
|
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplyNutrition(n: ProductLike['nutrition'], factor: number): NutritionInfo {
|
||||||
|
return {
|
||||||
|
calories: round(n.calories * factor),
|
||||||
|
protein: round(n.protein * factor),
|
||||||
|
carbs: round(n.carbs * factor),
|
||||||
|
fat: round(n.fat * factor),
|
||||||
|
...(n.fiber != null ? { fiber: round(n.fiber * factor) } : {}),
|
||||||
|
...(n.sugar != null ? { sugar: round(n.sugar * factor) } : {}),
|
||||||
|
...(n.sodium != null ? { sodium: round(n.sodium * factor) } : {}),
|
||||||
|
...(n.saturatedFat != null ? { saturatedFat: round(n.saturatedFat * factor) } : {}),
|
||||||
|
...(n.cholesterol != null ? { cholesterol: round(n.cholesterol * factor) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumNutrition(items: NutritionInfo[]): NutritionInfo {
|
||||||
|
const total = zeroNutrition();
|
||||||
|
for (const item of items) {
|
||||||
|
total.calories += item.calories;
|
||||||
|
total.protein += item.protein;
|
||||||
|
total.carbs += item.carbs;
|
||||||
|
total.fat += item.fat;
|
||||||
|
if (item.fiber != null) total.fiber = round((total.fiber ?? 0) + item.fiber);
|
||||||
|
if (item.sugar != null) total.sugar = round((total.sugar ?? 0) + item.sugar);
|
||||||
|
if (item.sodium != null) total.sodium = round((total.sodium ?? 0) + item.sodium);
|
||||||
|
if (item.saturatedFat != null)
|
||||||
|
total.saturatedFat = round((total.saturatedFat ?? 0) + item.saturatedFat);
|
||||||
|
if (item.cholesterol != null)
|
||||||
|
total.cholesterol = round((total.cholesterol ?? 0) + item.cholesterol);
|
||||||
|
}
|
||||||
|
total.calories = round(total.calories);
|
||||||
|
total.protein = round(total.protein);
|
||||||
|
total.carbs = round(total.carbs);
|
||||||
|
total.fat = round(total.fat);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
217
packages/api/src/modules/recipes/recipes.repository.test.ts
Normal file
217
packages/api/src/modules/recipes/recipes.repository.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({
|
||||||
|
mockFind: vi.fn(),
|
||||||
|
mockFindOne: vi.fn(),
|
||||||
|
mockFindOneAndUpdate: vi.fn(),
|
||||||
|
mockSave: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../schemas/recipe.schema.js', () => {
|
||||||
|
const chain = () => ({
|
||||||
|
sort: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnThis(),
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFind,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findOneChain = () => ({
|
||||||
|
lean: vi.fn().mockReturnThis(),
|
||||||
|
exec: mockFindOne,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateChain = () => ({
|
||||||
|
exec: mockFindOneAndUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeModel {
|
||||||
|
data: unknown;
|
||||||
|
constructor(data: unknown) {
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
save() {
|
||||||
|
mockSave(this.data);
|
||||||
|
return Promise.resolve({ toObject: () => this.data });
|
||||||
|
}
|
||||||
|
static find = vi.fn(() => chain());
|
||||||
|
static findOne = vi.fn(() => findOneChain());
|
||||||
|
static findOneAndUpdate = vi.fn(() => updateChain());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { RecipeModel: FakeModel };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RecipesRepository } from './recipes.repository.js';
|
||||||
|
|
||||||
|
describe(RecipesRepository.name, () => {
|
||||||
|
let repo: RecipesRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo = new RecipesRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByHousehold', () => {
|
||||||
|
it('returns paginated list without filters', async () => {
|
||||||
|
const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe 1' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 20 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hasMore when more items exist', async () => {
|
||||||
|
const items = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
_id: { toString: () => `r${i}` },
|
||||||
|
name: `Recipe ${i}`,
|
||||||
|
}));
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByHousehold('hh1', { limit: 2 });
|
||||||
|
expect(result.data).toHaveLength(2);
|
||||||
|
expect(result.pagination.hasMore).toBe(true);
|
||||||
|
expect(result.pagination.cursor).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies text search filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { q: 'pasta', limit: 20 });
|
||||||
|
// No error thrown means the $text filter was applied
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cuisine filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { cuisine: 'Italian', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies isFavorite filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { isFavorite: true, limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies maxCalories filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { maxCalories: 500, limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies tags filter', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { tags: 'vegetarian,quick', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty tags', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { tags: ',', limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies cursor for pagination', async () => {
|
||||||
|
const cursor = Buffer.from('abc123').toString('base64');
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
await repo.findByHousehold('hh1', { cursor, limit: 20 });
|
||||||
|
expect(mockFind).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('returns recipe when found', async () => {
|
||||||
|
const recipe = { _id: 'r1', householdId: 'hh1', name: 'Recipe' };
|
||||||
|
mockFindOne.mockResolvedValue(recipe);
|
||||||
|
const result = await repo.findById('r1', 'hh1');
|
||||||
|
expect(result).toEqual(recipe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not found', async () => {
|
||||||
|
mockFindOne.mockResolvedValue(null);
|
||||||
|
const result = await repo.findById('missing', 'hh1');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByProductId', () => {
|
||||||
|
it('returns recipes containing the product', async () => {
|
||||||
|
const items = [{ _id: { toString: () => 'r1' }, name: 'Recipe' }];
|
||||||
|
mockFind.mockResolvedValue(items);
|
||||||
|
|
||||||
|
const result = await repo.findByProductId('hh1', 'p1', { limit: 20 });
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports cursor pagination', async () => {
|
||||||
|
const cursor = Buffer.from('r1').toString('base64');
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const result = await repo.findByProductId('hh1', 'p1', { cursor, limit: 20 });
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults limit to 20', async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
const result = await repo.findByProductId('hh1', 'p1', {});
|
||||||
|
expect(result.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAllByProductId', () => {
|
||||||
|
it('returns all recipes with the product', async () => {
|
||||||
|
const recipes = [{ _id: 'r1' }, { _id: 'r2' }];
|
||||||
|
mockFind.mockResolvedValue(recipes);
|
||||||
|
const result = await repo.findAllByProductId('hh1', 'p1');
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('saves and returns the recipe', async () => {
|
||||||
|
const data = { name: 'New Recipe', servings: 2, steps: [], tags: [], isFavorite: false };
|
||||||
|
const computed = {
|
||||||
|
ingredients: [],
|
||||||
|
totalNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||||
|
perServingNutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await repo.create(data, computed, 'hh1', 'user-1');
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ name: 'New Recipe' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates and returns the recipe', async () => {
|
||||||
|
const updated = { _id: 'r1', name: 'Updated' };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||||
|
const result = await repo.update('r1', 'hh1', { name: 'Updated' });
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies computed fields when provided', async () => {
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ _id: 'r1' });
|
||||||
|
await repo.update(
|
||||||
|
'r1',
|
||||||
|
'hh1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
totalNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 },
|
||||||
|
perServingNutrition: { calories: 100, protein: 10, carbs: 5, fat: 3 },
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(mockFindOneAndUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('softDelete', () => {
|
||||||
|
it('sets deletedAt and returns', async () => {
|
||||||
|
const deleted = { _id: 'r1', deletedAt: new Date() };
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue(deleted);
|
||||||
|
const result = await repo.softDelete('r1', 'hh1');
|
||||||
|
expect(result).toEqual(deleted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
packages/api/src/modules/recipes/recipes.repository.ts
Normal file
141
packages/api/src/modules/recipes/recipes.repository.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { RecipeModel } from '../../schemas/recipe.schema.js';
|
||||||
|
import type { CreateRecipeInput, UpdateRecipeInput, RecipeQueryInput } from '@meshitrack/shared';
|
||||||
|
import type { RecipeIngredient as StoredIngredient } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
interface NutritionInfo {
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fat: number;
|
||||||
|
fiber?: number;
|
||||||
|
sugar?: number;
|
||||||
|
sodium?: number;
|
||||||
|
saturatedFat?: number;
|
||||||
|
cholesterol?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComputedFields {
|
||||||
|
ingredients: StoredIngredient[];
|
||||||
|
totalNutrition: NutritionInfo;
|
||||||
|
perServingNutrition: NutritionInfo;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecipesRepository {
|
||||||
|
public async findByHousehold(householdId: string, query: RecipeQueryInput) {
|
||||||
|
const filter: Record<string, unknown> = { householdId, deletedAt: { $exists: false } };
|
||||||
|
|
||||||
|
if (query.q) filter['$text'] = { $search: query.q };
|
||||||
|
if (query.cuisine) filter['cuisine'] = { $regex: query.cuisine, $options: 'i' };
|
||||||
|
if (query.isFavorite !== undefined) filter['isFavorite'] = query.isFavorite;
|
||||||
|
if (query.maxCalories !== undefined)
|
||||||
|
filter['perServingNutrition.calories'] = { $lte: query.maxCalories };
|
||||||
|
|
||||||
|
if (query.tags) {
|
||||||
|
const tagList = query.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (tagList.length > 0) filter['tags'] = { $all: tagList };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = query.limit;
|
||||||
|
const items = await RecipeModel.find(filter)
|
||||||
|
.sort({ _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string, householdId: string) {
|
||||||
|
return RecipeModel.findOne({ _id: id, householdId, deletedAt: { $exists: false } })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByProductId(
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
query: { cursor?: string; limit?: number },
|
||||||
|
) {
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
householdId,
|
||||||
|
'ingredients.productId': productId,
|
||||||
|
deletedAt: { $exists: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const limit = query.limit ?? 20;
|
||||||
|
if (query.cursor) {
|
||||||
|
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||||
|
filter['_id'] = { $gt: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await RecipeModel.find(filter)
|
||||||
|
.sort({ _id: 1 })
|
||||||
|
.limit(limit + 1)
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
const hasMore = items.length > limit;
|
||||||
|
const data = hasMore ? items.slice(0, limit) : items;
|
||||||
|
const cursor =
|
||||||
|
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||||
|
|
||||||
|
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findAllByProductId(householdId: string, productId: string) {
|
||||||
|
return RecipeModel.find({
|
||||||
|
householdId,
|
||||||
|
'ingredients.productId': productId,
|
||||||
|
deletedAt: { $exists: false },
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(
|
||||||
|
data: Omit<CreateRecipeInput, 'ingredients'>,
|
||||||
|
computed: ComputedFields,
|
||||||
|
householdId: string,
|
||||||
|
createdBy: string,
|
||||||
|
) {
|
||||||
|
const recipe = new RecipeModel({ ...data, ...computed, householdId, createdBy });
|
||||||
|
const saved = await recipe.save();
|
||||||
|
return saved.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
id: string,
|
||||||
|
householdId: string,
|
||||||
|
data: Partial<UpdateRecipeInput>,
|
||||||
|
computed?: Partial<ComputedFields>,
|
||||||
|
) {
|
||||||
|
const update: Record<string, unknown> = { ...data };
|
||||||
|
if (computed) Object.assign(update, computed);
|
||||||
|
return RecipeModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||||
|
{ $set: update },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async softDelete(id: string, householdId: string) {
|
||||||
|
return RecipeModel.findOneAndUpdate(
|
||||||
|
{ _id: id, householdId, deletedAt: { $exists: false } },
|
||||||
|
{ $set: { deletedAt: new Date() } },
|
||||||
|
{ new: true, lean: true },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
471
packages/api/src/modules/recipes/recipes.routes.test.ts
Normal file
471
packages/api/src/modules/recipes/recipes.routes.test.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||||
|
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||||
|
|
||||||
|
vi.mock('jose', () => ({
|
||||||
|
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||||
|
jwtVerify: vi.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
sub: 'kc-1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
realm_access: { roles: ['member'] },
|
||||||
|
householdIds: ['hh1'],
|
||||||
|
},
|
||||||
|
protectedHeader: { alg: 'RS256' },
|
||||||
|
key: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockFindByHousehold,
|
||||||
|
mockFindById,
|
||||||
|
mockFindByProductId,
|
||||||
|
mockFindAllByProductId,
|
||||||
|
mockCreate,
|
||||||
|
mockUpdate,
|
||||||
|
mockSoftDelete,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockFindByHousehold: vi.fn(),
|
||||||
|
mockFindById: vi.fn(),
|
||||||
|
mockFindByProductId: vi.fn(),
|
||||||
|
mockFindAllByProductId: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockSoftDelete: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockFindByIds } = vi.hoisted(() => ({
|
||||||
|
mockFindByIds: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./recipes.repository.js', () => ({
|
||||||
|
RecipesRepository: class {
|
||||||
|
findByHousehold = mockFindByHousehold;
|
||||||
|
findById = mockFindById;
|
||||||
|
findByProductId = mockFindByProductId;
|
||||||
|
findAllByProductId = mockFindAllByProductId;
|
||||||
|
create = mockCreate;
|
||||||
|
update = mockUpdate;
|
||||||
|
softDelete = mockSoftDelete;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../products/products.repository.js', () => ({
|
||||||
|
ProductsRepository: class {
|
||||||
|
findByIds = mockFindByIds;
|
||||||
|
findById = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../users/users.repository.js', () => ({
|
||||||
|
UsersRepository: class {
|
||||||
|
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import authPlugin from '../../plugins/auth.plugin.js';
|
||||||
|
import householdPlugin from '../../plugins/household.plugin.js';
|
||||||
|
import usersRoutes from '../users/users.routes.js';
|
||||||
|
import recipesRoutes from './recipes.routes.js';
|
||||||
|
|
||||||
|
const nutrition = { calories: 200, protein: 20, carbs: 10, fat: 8 };
|
||||||
|
|
||||||
|
function makeProduct(id = 'p1') {
|
||||||
|
return {
|
||||||
|
_id: id,
|
||||||
|
householdId: 'hh1',
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: 'g',
|
||||||
|
nutrition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecipe(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
_id: 'recipe-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Grilled Chicken',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken Breast',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
nutritionContribution: nutrition,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Grill the chicken.' }],
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
totalNutrition: nutrition,
|
||||||
|
perServingNutrition: nutrition,
|
||||||
|
warnings: [],
|
||||||
|
createdBy: 'kc-1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recipes.routes', () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildTestApp>>;
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.setValidatorCompiler(validatorCompiler);
|
||||||
|
instance.setSerializerCompiler(serializerCompiler);
|
||||||
|
await instance.register(fastifyAwilixPlugin, {
|
||||||
|
disposeOnClose: true,
|
||||||
|
disposeOnResponse: true,
|
||||||
|
strictBooleanEnforced: true,
|
||||||
|
});
|
||||||
|
await instance.register(authPlugin);
|
||||||
|
await instance.register(householdPlugin);
|
||||||
|
await instance.register(usersRoutes);
|
||||||
|
await instance.register(recipesRoutes);
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
app = await buildTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/recipes', () => {
|
||||||
|
it('returns paginated list', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [makeRecipe()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
expect(body.data[0].name).toBe('Grilled Chicken');
|
||||||
|
expect(body.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty list', async () => {
|
||||||
|
mockFindByHousehold.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().data).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/recipes/:id', () => {
|
||||||
|
it('returns recipe when found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRecipe());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Grilled Chicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recipe with all optional fields', async () => {
|
||||||
|
mockFindById.mockResolvedValue(
|
||||||
|
makeRecipe({
|
||||||
|
description: 'A delicious dish',
|
||||||
|
prepTime: 10,
|
||||||
|
cookTime: 20,
|
||||||
|
totalTime: 30,
|
||||||
|
cuisine: 'Italian',
|
||||||
|
imageUrl: 'https://example.com/image.jpg',
|
||||||
|
source: {
|
||||||
|
type: 'url',
|
||||||
|
url: 'https://example.com/recipe',
|
||||||
|
importedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken Breast',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
originalQuantity: 7,
|
||||||
|
originalUnit: 'oz',
|
||||||
|
preparation: 'diced',
|
||||||
|
isOptional: false,
|
||||||
|
nutritionContribution: nutrition,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Prep.', duration: 5, tip: 'Use sharp knife.' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.description).toBe('A delicious dish');
|
||||||
|
expect(body.prepTime).toBe(10);
|
||||||
|
expect(body.cookTime).toBe(20);
|
||||||
|
expect(body.totalTime).toBe(30);
|
||||||
|
expect(body.cuisine).toBe('Italian');
|
||||||
|
expect(body.imageUrl).toBe('https://example.com/image.jpg');
|
||||||
|
expect(body.source.type).toBe('url');
|
||||||
|
expect(body.source.url).toBe('https://example.com/recipe');
|
||||||
|
expect(body.source.importedAt).toBeDefined();
|
||||||
|
expect(body.ingredients[0].originalQuantity).toBe(7);
|
||||||
|
expect(body.ingredients[0].originalUnit).toBe('oz');
|
||||||
|
expect(body.ingredients[0].preparation).toBe('diced');
|
||||||
|
expect(body.steps[0].duration).toBe(5);
|
||||||
|
expect(body.steps[0].tip).toBe('Use sharp knife.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recipe with source but no url or importedAt', async () => {
|
||||||
|
mockFindById.mockResolvedValue(
|
||||||
|
makeRecipe({
|
||||||
|
source: { type: 'manual' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.source.type).toBe('manual');
|
||||||
|
expect(body.source.url).toBeUndefined();
|
||||||
|
expect(body.source.importedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recipe with source.importedAt as string', async () => {
|
||||||
|
mockFindById.mockResolvedValue(
|
||||||
|
makeRecipe({
|
||||||
|
source: {
|
||||||
|
type: 'url',
|
||||||
|
url: 'https://example.com',
|
||||||
|
importedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().source.importedAt).toBe('2024-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
mockFindById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/missing',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/recipes', () => {
|
||||||
|
it('creates a recipe with metric ingredients', async () => {
|
||||||
|
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||||
|
mockCreate.mockResolvedValue(makeRecipe());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/recipes',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Grilled Chicken',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken Breast',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Grill the chicken.' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().name).toBe('Grilled Chicken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing name', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/recipes',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ servings: 2, ingredients: [], steps: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/v1/households/:householdId/recipes/:id', () => {
|
||||||
|
it('updates a recipe name', async () => {
|
||||||
|
const updated = makeRecipe({ name: 'Updated Recipe' });
|
||||||
|
mockFindById.mockResolvedValue(makeRecipe());
|
||||||
|
mockUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Updated Recipe' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().name).toBe('Updated Recipe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates recipe with new ingredients and recalculates', async () => {
|
||||||
|
const updated = makeRecipe({ name: 'Grilled Chicken' });
|
||||||
|
mockFindById.mockResolvedValue(makeRecipe());
|
||||||
|
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||||
|
mockUpdate.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken Breast',
|
||||||
|
quantity: 300,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/households/:householdId/recipes/:id', () => {
|
||||||
|
it('soft-deletes a recipe', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRecipe());
|
||||||
|
mockSoftDelete.mockResolvedValue(makeRecipe());
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/recipes/:id/scale', () => {
|
||||||
|
it('returns scaled recipe preview', async () => {
|
||||||
|
mockFindById.mockResolvedValue(makeRecipe());
|
||||||
|
mockFindByIds.mockResolvedValue([makeProduct()]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/recipes/recipe-1/scale',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetServings: 4 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.servings).toBe(4);
|
||||||
|
expect(body.ingredients[0].quantity).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/recipes/import-text', () => {
|
||||||
|
it('returns available:false with NoOp provider', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/recipes/import-text',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: 'Some recipe text' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().available).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/households/:householdId/recipes/import-url', () => {
|
||||||
|
it('returns available:false with NoOp provider', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/hh1/recipes/import-url',
|
||||||
|
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://example.com/recipe' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().available).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/households/:householdId/recipes/by-product/:productId', () => {
|
||||||
|
it('returns recipes using a product', async () => {
|
||||||
|
mockFindByProductId.mockResolvedValue({
|
||||||
|
data: [makeRecipe()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/hh1/recipes/by-product/p1',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
353
packages/api/src/modules/recipes/recipes.routes.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { asClass, asValue, Lifetime } from 'awilix';
|
||||||
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
CreateRecipeSchema,
|
||||||
|
UpdateRecipeSchema,
|
||||||
|
RecipeQuerySchema,
|
||||||
|
ScaleRecipeSchema,
|
||||||
|
ImportRecipeTextSchema,
|
||||||
|
ImportRecipeUrlSchema,
|
||||||
|
RecipeResponseSchema,
|
||||||
|
RecipeListResponseSchema,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import { RecipesRepository } from './recipes.repository.js';
|
||||||
|
import { RecipesService } from './recipes.service.js';
|
||||||
|
import { ProductsRepository } from '../products/products.repository.js';
|
||||||
|
import { NoOpLlmProvider } from '../llm/no-op-llm.provider.js';
|
||||||
|
import type { NutritionInfo } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type NullableNutritionInfo = {
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fat: number;
|
||||||
|
fiber?: number | null;
|
||||||
|
sugar?: number | null;
|
||||||
|
sodium?: number | null;
|
||||||
|
saturatedFat?: number | null;
|
||||||
|
cholesterol?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyIngredient = {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
originalQuantity?: number | null;
|
||||||
|
originalUnit?: string | null;
|
||||||
|
preparation?: string | null;
|
||||||
|
isOptional: boolean;
|
||||||
|
nutritionContribution: NullableNutritionInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnyRecipeDoc = {
|
||||||
|
_id: string | { toString: () => string };
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
servings: number;
|
||||||
|
prepTime?: number | null;
|
||||||
|
cookTime?: number | null;
|
||||||
|
totalTime?: number | null;
|
||||||
|
ingredients: AnyIngredient[];
|
||||||
|
steps: Array<{
|
||||||
|
order: number;
|
||||||
|
instruction: string;
|
||||||
|
duration?: number | null;
|
||||||
|
tip?: string | null;
|
||||||
|
}>;
|
||||||
|
tags: string[];
|
||||||
|
cuisine?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
source?: { type: string; url?: string | null; importedAt?: Date | string | null } | null;
|
||||||
|
totalNutrition: NullableNutritionInfo;
|
||||||
|
perServingNutrition: NullableNutritionInfo;
|
||||||
|
warnings: string[];
|
||||||
|
isFavorite: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStr(v: string | { toString: () => string }): string {
|
||||||
|
return typeof v === 'string' ? v : v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(v: string | Date): string {
|
||||||
|
return typeof v === 'string' ? v : v.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNullNutrition(n: NullableNutritionInfo): NutritionInfo {
|
||||||
|
return {
|
||||||
|
calories: n.calories,
|
||||||
|
protein: n.protein,
|
||||||
|
carbs: n.carbs,
|
||||||
|
fat: n.fat,
|
||||||
|
...(n.fiber != null ? { fiber: n.fiber } : {}),
|
||||||
|
...(n.sugar != null ? { sugar: n.sugar } : {}),
|
||||||
|
...(n.sodium != null ? { sodium: n.sodium } : {}),
|
||||||
|
...(n.saturatedFat != null ? { saturatedFat: n.saturatedFat } : {}),
|
||||||
|
...(n.cholesterol != null ? { cholesterol: n.cholesterol } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecipeResponse(doc: AnyRecipeDoc): z.infer<typeof RecipeResponseSchema> {
|
||||||
|
return {
|
||||||
|
_id: toStr(doc._id),
|
||||||
|
householdId: doc.householdId,
|
||||||
|
name: doc.name,
|
||||||
|
...(doc.description ? { description: doc.description } : {}),
|
||||||
|
servings: doc.servings,
|
||||||
|
...(doc.prepTime != null ? { prepTime: doc.prepTime } : {}),
|
||||||
|
...(doc.cookTime != null ? { cookTime: doc.cookTime } : {}),
|
||||||
|
...(doc.totalTime != null ? { totalTime: doc.totalTime } : {}),
|
||||||
|
ingredients: doc.ingredients.map((ing) => ({
|
||||||
|
productId: ing.productId,
|
||||||
|
productName: ing.productName,
|
||||||
|
quantity: ing.quantity,
|
||||||
|
unit: ing.unit as 'g' | 'ml' | 'piece' | 'slice',
|
||||||
|
...(ing.originalQuantity != null ? { originalQuantity: ing.originalQuantity } : {}),
|
||||||
|
...(ing.originalUnit ? { originalUnit: ing.originalUnit as never } : {}),
|
||||||
|
...(ing.preparation ? { preparation: ing.preparation } : {}),
|
||||||
|
isOptional: ing.isOptional,
|
||||||
|
nutritionContribution: stripNullNutrition(ing.nutritionContribution),
|
||||||
|
})),
|
||||||
|
steps: doc.steps.map((s) => ({
|
||||||
|
order: s.order,
|
||||||
|
instruction: s.instruction,
|
||||||
|
...(s.duration != null ? { duration: s.duration } : {}),
|
||||||
|
...(s.tip ? { tip: s.tip } : {}),
|
||||||
|
})),
|
||||||
|
tags: doc.tags,
|
||||||
|
...(doc.cuisine ? { cuisine: doc.cuisine } : {}),
|
||||||
|
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
|
||||||
|
...(doc.source
|
||||||
|
? {
|
||||||
|
source: {
|
||||||
|
type: doc.source.type as 'manual' | 'url' | 'llm_import' | 'text_import',
|
||||||
|
...(doc.source.url ? { url: doc.source.url } : {}),
|
||||||
|
...(doc.source.importedAt
|
||||||
|
? {
|
||||||
|
importedAt:
|
||||||
|
typeof doc.source.importedAt === 'string'
|
||||||
|
? doc.source.importedAt
|
||||||
|
: (doc.source.importedAt as Date).toISOString(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
totalNutrition: stripNullNutrition(doc.totalNutrition),
|
||||||
|
perServingNutrition: stripNullNutrition(doc.perServingNutrition),
|
||||||
|
warnings: doc.warnings as never,
|
||||||
|
isFavorite: doc.isFavorite,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
createdAt: toIso(doc.createdAt),
|
||||||
|
updatedAt: toIso(doc.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/awilix' {
|
||||||
|
interface Cradle {
|
||||||
|
recipesRepository: RecipesRepository;
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
recipesService: RecipesService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (fastify) => {
|
||||||
|
fastify.diContainer.register({
|
||||||
|
recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
recipesService: asClass(RecipesService, { lifetime: Lifetime.SINGLETON }),
|
||||||
|
llmProvider: asValue(new NoOpLlmProvider()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||||
|
const householdParams = z.object({ householdId: z.string() });
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/recipes — list/search
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/recipes',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
querystring: RecipeQuerySchema,
|
||||||
|
response: { 200: RecipeListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const result = await service.list(request.params.householdId, request.query);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toRecipeResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/recipes/:id — get by id
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 200: RecipeResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const recipe = await service.getById(request.params.id, request.params.householdId);
|
||||||
|
return reply.send(toRecipeResponse(recipe));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/recipes — create
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/recipes',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: CreateRecipeSchema,
|
||||||
|
response: { 201: RecipeResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const recipe = await service.create(
|
||||||
|
request.body,
|
||||||
|
request.params.householdId,
|
||||||
|
request.user.keycloakId,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(toRecipeResponse(recipe));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/v1/households/:householdId/recipes/:id — update
|
||||||
|
app.route({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: UpdateRecipeSchema,
|
||||||
|
response: { 200: RecipeResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const recipe = await service.update(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toRecipeResponse(recipe));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/households/:householdId/recipes/:id — soft delete
|
||||||
|
app.route({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/:id',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
response: { 204: z.undefined() },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
await service.delete(request.params.id, request.params.householdId);
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/recipes/:id/scale — scale preview
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/:id/scale',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ id: z.string() }),
|
||||||
|
body: ScaleRecipeSchema,
|
||||||
|
response: { 200: RecipeResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const scaled = await service.scale(
|
||||||
|
request.params.id,
|
||||||
|
request.params.householdId,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(toRecipeResponse(scaled as AnyRecipeDoc));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/recipes/import-text — LLM import from text
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/import-text',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: ImportRecipeTextSchema,
|
||||||
|
response: {
|
||||||
|
200: z.union([
|
||||||
|
z.object({ available: z.literal(false) }),
|
||||||
|
z.object({ available: z.literal(true), draft: z.unknown() }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const result = await service.importFromText(request.body.text, request.params.householdId);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/households/:householdId/recipes/import-url — LLM import from URL
|
||||||
|
app.route({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/import-url',
|
||||||
|
schema: {
|
||||||
|
params: householdParams,
|
||||||
|
body: ImportRecipeUrlSchema,
|
||||||
|
response: {
|
||||||
|
200: z.union([
|
||||||
|
z.object({ available: z.literal(false) }),
|
||||||
|
z.object({ available: z.literal(true), draft: z.unknown() }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const result = await service.importFromUrl(request.body.url, request.params.householdId);
|
||||||
|
return reply.send(result);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/households/:householdId/recipes/by-product/:productId
|
||||||
|
app.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/households/:householdId/recipes/by-product/:productId',
|
||||||
|
schema: {
|
||||||
|
params: householdParams.extend({ productId: z.string() }),
|
||||||
|
querystring: z.object({
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
}),
|
||||||
|
response: { 200: RecipeListResponseSchema },
|
||||||
|
},
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const service = fastify.diContainer.resolve('recipesService');
|
||||||
|
const result = await service.findByProduct(
|
||||||
|
request.params.householdId,
|
||||||
|
request.params.productId,
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
data: result.data.map(toRecipeResponse),
|
||||||
|
pagination: result.pagination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'recipes-routes',
|
||||||
|
dependencies: ['auth-plugin'],
|
||||||
|
},
|
||||||
|
);
|
||||||
344
packages/api/src/modules/recipes/recipes.service.test.ts
Normal file
344
packages/api/src/modules/recipes/recipes.service.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { RecipesService } from './recipes.service.js';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
|
||||||
|
const makeProduct = (id: string, servingUnit = 'g', servingSize = 100) => ({
|
||||||
|
_id: { toString: () => id },
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Test Product',
|
||||||
|
servingSize,
|
||||||
|
servingUnit,
|
||||||
|
densityGPerMl: undefined as number | undefined,
|
||||||
|
nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeRecipe = (id = 'recipe-1') => ({
|
||||||
|
_id: { toString: () => id },
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Test Recipe',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
nutritionContribution: { calories: 400, protein: 40, carbs: 0, fat: 16 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
totalNutrition: { calories: 400, protein: 40, carbs: 0, fat: 16 },
|
||||||
|
perServingNutrition: { calories: 200, protein: 20, carbs: 0, fat: 8 },
|
||||||
|
warnings: [],
|
||||||
|
createdBy: 'user-1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(RecipesService.name, () => {
|
||||||
|
const mockRecipesRepo = {
|
||||||
|
findByHousehold: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByProductId: vi.fn(),
|
||||||
|
findAllByProductId: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
softDelete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProductsRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByIds: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLlmProvider = {
|
||||||
|
extractNutrition: vi.fn(),
|
||||||
|
parseRecipe: vi.fn(),
|
||||||
|
parseRecipeFromUrl: vi.fn(),
|
||||||
|
parseReceipt: vi.fn(),
|
||||||
|
suggestMealPlan: vi.fn(),
|
||||||
|
parseNaturalLanguage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: RecipesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new RecipesService({
|
||||||
|
recipesRepository: mockRecipesRepo as never,
|
||||||
|
productsRepository: mockProductsRepo as never,
|
||||||
|
llmProvider: mockLlmProvider as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRecipesRepo.findByHousehold.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await service.list('hh1', { limit: 20 });
|
||||||
|
expect(mockRecipesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('returns recipe when found', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
|
||||||
|
const result = await service.getById('recipe-1', 'hh1');
|
||||||
|
expect(result).toEqual(recipe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('normalizes metric ingredients and calculates nutrition', async () => {
|
||||||
|
const product = makeProduct('p1');
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||||
|
mockRecipesRepo.create.mockResolvedValue(makeRecipe());
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
name: 'Test',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [_, computed] = mockRecipesRepo.create.mock.calls[0]!;
|
||||||
|
expect(computed.totalNutrition.calories).toBe(400); // 200g = 2× of 100g serving (200 kcal each)
|
||||||
|
expect(computed.perServingNutrition.calories).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestError for missing density on cup → g conversion', async () => {
|
||||||
|
const product = makeProduct('p1', 'g'); // g product, no density
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'Test',
|
||||||
|
servings: 1,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Sugar',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'cup',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError for unknown product', async () => {
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: 'Test',
|
||||||
|
servings: 1,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'unknown',
|
||||||
|
productName: 'X',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
'hh1',
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('soft-deletes recipe', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
mockRecipesRepo.softDelete.mockResolvedValue(recipe);
|
||||||
|
|
||||||
|
const result = await service.delete('recipe-1', 'hh1');
|
||||||
|
expect(mockRecipesRepo.softDelete).toHaveBeenCalledWith('recipe-1', 'hh1');
|
||||||
|
expect(result).toEqual(recipe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when not found', async () => {
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when softDelete returns null', async () => {
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
|
||||||
|
mockRecipesRepo.softDelete.mockResolvedValue(null);
|
||||||
|
await expect(service.delete('recipe-1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scale', () => {
|
||||||
|
it('returns scaled ingredient quantities and recalculated nutrition', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([makeProduct('p1')]);
|
||||||
|
|
||||||
|
const result = await service.scale('recipe-1', 'hh1', { targetServings: 4 });
|
||||||
|
|
||||||
|
expect(result.servings).toBe(4);
|
||||||
|
// 200g × (4/2) = 400g
|
||||||
|
expect(result.ingredients[0]!.quantity).toBe(400);
|
||||||
|
expect(result.totalNutrition.calories).toBe(800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importFromText', () => {
|
||||||
|
it('returns available:false when LLM returns null', async () => {
|
||||||
|
mockLlmProvider.parseRecipe.mockResolvedValue(null);
|
||||||
|
const result = await service.importFromText('some text', 'hh1');
|
||||||
|
expect(result).toEqual({ available: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns draft when LLM returns a recipe', async () => {
|
||||||
|
const draft = { name: 'Pasta', servings: 4, ingredients: [], steps: [] };
|
||||||
|
mockLlmProvider.parseRecipe.mockResolvedValue(draft);
|
||||||
|
const result = await service.importFromText('pasta recipe', 'hh1');
|
||||||
|
expect(result).toEqual({ available: true, draft });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importFromUrl', () => {
|
||||||
|
it('returns available:false when LLM returns null', async () => {
|
||||||
|
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(null);
|
||||||
|
const result = await service.importFromUrl('https://example.com/recipe', 'hh1');
|
||||||
|
expect(result).toEqual({ available: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns draft when LLM returns a recipe', async () => {
|
||||||
|
const draft = { name: 'Soup', servings: 2, ingredients: [], steps: [] };
|
||||||
|
mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(draft);
|
||||||
|
const result = await service.importFromUrl('https://example.com', 'hh1');
|
||||||
|
expect(result).toEqual({ available: true, draft });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('updates metadata without recalculating if no ingredients/servings changed', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
mockRecipesRepo.update.mockResolvedValue({ ...recipe, name: 'Renamed' });
|
||||||
|
|
||||||
|
const result = await service.update('recipe-1', 'hh1', { name: 'Renamed' });
|
||||||
|
expect(result.name).toBe('Renamed');
|
||||||
|
expect(mockProductsRepo.findByIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates nutrition when ingredients change', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
const product = makeProduct('p1');
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||||
|
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||||
|
|
||||||
|
await service.update('recipe-1', 'hh1', {
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Chicken',
|
||||||
|
quantity: 300,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
|
||||||
|
expect(mockRecipesRepo.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates nutrition when only servings change', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
const product = makeProduct('p1');
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(recipe);
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||||
|
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||||
|
|
||||||
|
await service.update('recipe-1', 'hh1', { servings: 4 });
|
||||||
|
|
||||||
|
expect(mockProductsRepo.findByIds).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when update returns null', async () => {
|
||||||
|
mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
|
||||||
|
mockRecipesRepo.update.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('recipe-1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByProduct', () => {
|
||||||
|
it('delegates to repository', async () => {
|
||||||
|
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||||
|
mockRecipesRepo.findByProductId.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const result = await service.findByProduct('hh1', 'p1', { limit: 20 });
|
||||||
|
expect(mockRecipesRepo.findByProductId).toHaveBeenCalledWith('hh1', 'p1', { limit: 20 });
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recalculateForProduct', () => {
|
||||||
|
it('recalculates all recipes containing the product', async () => {
|
||||||
|
const recipe = makeRecipe();
|
||||||
|
const product = makeProduct('p1');
|
||||||
|
mockRecipesRepo.findAllByProductId.mockResolvedValue([recipe]);
|
||||||
|
mockProductsRepo.findByIds.mockResolvedValue([product]);
|
||||||
|
mockRecipesRepo.update.mockResolvedValue(recipe);
|
||||||
|
|
||||||
|
await service.recalculateForProduct('hh1', 'p1');
|
||||||
|
|
||||||
|
expect(mockRecipesRepo.findAllByProductId).toHaveBeenCalledWith('hh1', 'p1');
|
||||||
|
expect(mockRecipesRepo.update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no recipes contain the product', async () => {
|
||||||
|
mockRecipesRepo.findAllByProductId.mockResolvedValue([]);
|
||||||
|
await service.recalculateForProduct('hh1', 'p1');
|
||||||
|
expect(mockRecipesRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
309
packages/api/src/modules/recipes/recipes.service.ts
Normal file
309
packages/api/src/modules/recipes/recipes.service.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import type { ProductsRepository } from '../products/products.repository.js';
|
||||||
|
import type { RecipesRepository } from './recipes.repository.js';
|
||||||
|
import { NutritionCalculatorService } from './nutrition-calculator.service.js';
|
||||||
|
import { toMetric } from './unit-conversion.service.js';
|
||||||
|
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||||
|
import type {
|
||||||
|
CreateRecipeInput,
|
||||||
|
UpdateRecipeInput,
|
||||||
|
RecipeQueryInput,
|
||||||
|
ScaleRecipeInput,
|
||||||
|
RecipeIngredient,
|
||||||
|
NutritionInfo,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
import type { ILlmProvider } from '../llm/llm-provider.interface.js';
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
recipesRepository: RecipesRepository;
|
||||||
|
productsRepository: ProductsRepository;
|
||||||
|
llmProvider: ILlmProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversionError {
|
||||||
|
ingredientIndex: number;
|
||||||
|
productId: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecipesService {
|
||||||
|
private readonly recipesRepository: RecipesRepository;
|
||||||
|
private readonly productsRepository: ProductsRepository;
|
||||||
|
private readonly llmProvider: ILlmProvider;
|
||||||
|
private readonly nutritionCalculator: NutritionCalculatorService;
|
||||||
|
|
||||||
|
public constructor({ recipesRepository, productsRepository, llmProvider }: Deps) {
|
||||||
|
this.recipesRepository = recipesRepository;
|
||||||
|
this.productsRepository = productsRepository;
|
||||||
|
this.llmProvider = llmProvider;
|
||||||
|
this.nutritionCalculator = new NutritionCalculatorService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(householdId: string, query: RecipeQueryInput) {
|
||||||
|
return this.recipesRepository.findByHousehold(householdId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string, householdId: string) {
|
||||||
|
const recipe = await this.recipesRepository.findById(id, householdId);
|
||||||
|
if (!recipe) throw new NotFoundError('Recipe not found');
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateRecipeInput, householdId: string, createdBy: string) {
|
||||||
|
const { normalizedIngredients, productMap } = await this.resolveAndNormalizeIngredients(
|
||||||
|
data.ingredients,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||||
|
this.nutritionCalculator.calculateRecipeNutrition(
|
||||||
|
normalizedIngredients,
|
||||||
|
productMap,
|
||||||
|
data.servings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||||
|
|
||||||
|
const storedIngredients = normalizedIngredients.map((ni, i) => ({
|
||||||
|
...ni,
|
||||||
|
nutritionContribution: ingredientContributions[i]!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ingredients: _ingredients, ...rest } = data;
|
||||||
|
return this.recipesRepository.create(
|
||||||
|
rest,
|
||||||
|
{
|
||||||
|
ingredients: storedIngredients,
|
||||||
|
totalNutrition,
|
||||||
|
perServingNutrition,
|
||||||
|
warnings,
|
||||||
|
},
|
||||||
|
householdId,
|
||||||
|
createdBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(id: string, householdId: string, data: UpdateRecipeInput) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
|
||||||
|
let computed:
|
||||||
|
| Partial<{
|
||||||
|
ingredients: RecipeIngredient[];
|
||||||
|
totalNutrition: NutritionInfo;
|
||||||
|
perServingNutrition: NutritionInfo;
|
||||||
|
warnings: string[];
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (data.ingredients || data.servings !== undefined) {
|
||||||
|
// Re-fetch current recipe to get current servings if not updating them
|
||||||
|
const current = await this.recipesRepository.findById(id, householdId);
|
||||||
|
const servings = data.servings ?? current!.servings;
|
||||||
|
const ingredientsInput =
|
||||||
|
data.ingredients ??
|
||||||
|
current!.ingredients.map((i) => ({
|
||||||
|
productId: i.productId,
|
||||||
|
productName: i.productName,
|
||||||
|
quantity: i.quantity,
|
||||||
|
unit: i.unit as string,
|
||||||
|
...(i.preparation ? { preparation: i.preparation } : {}),
|
||||||
|
isOptional: i.isOptional,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { normalizedIngredients, productMap } = await this.resolveAndNormalizeIngredients(
|
||||||
|
ingredientsInput,
|
||||||
|
householdId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||||
|
this.nutritionCalculator.calculateRecipeNutrition(
|
||||||
|
normalizedIngredients,
|
||||||
|
productMap,
|
||||||
|
servings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||||
|
|
||||||
|
computed = {
|
||||||
|
ingredients: normalizedIngredients.map((ni, i) => ({
|
||||||
|
...ni,
|
||||||
|
nutritionContribution: ingredientContributions[i]!,
|
||||||
|
})),
|
||||||
|
totalNutrition,
|
||||||
|
perServingNutrition,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ingredients: _ingredients, ...rest } = data;
|
||||||
|
const updated = await this.recipesRepository.update(id, householdId, rest, computed);
|
||||||
|
if (!updated) throw new NotFoundError('Recipe not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: string, householdId: string) {
|
||||||
|
await this.getById(id, householdId);
|
||||||
|
const deleted = await this.recipesRepository.softDelete(id, householdId);
|
||||||
|
if (!deleted) throw new NotFoundError('Recipe not found');
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scale(id: string, householdId: string, input: ScaleRecipeInput) {
|
||||||
|
const recipe = await this.getById(id, householdId);
|
||||||
|
const factor = input.targetServings / recipe.servings;
|
||||||
|
|
||||||
|
const scaledIngredients = recipe.ingredients.map((ing) => ({
|
||||||
|
...ing,
|
||||||
|
quantity: Math.round(ing.quantity * factor * 1000) / 1000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const productMap = await this.buildProductMap(
|
||||||
|
householdId,
|
||||||
|
recipe.ingredients.map((i) => i.productId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||||
|
this.nutritionCalculator.calculateRecipeNutrition(
|
||||||
|
scaledIngredients,
|
||||||
|
productMap,
|
||||||
|
input.targetServings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...recipe,
|
||||||
|
servings: input.targetServings,
|
||||||
|
ingredients: scaledIngredients.map((ing, i) => ({
|
||||||
|
...ing,
|
||||||
|
nutritionContribution: ingredientContributions[i]!,
|
||||||
|
})),
|
||||||
|
totalNutrition,
|
||||||
|
perServingNutrition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async importFromText(text: string, _householdId: string) {
|
||||||
|
const parsed = await this.llmProvider.parseRecipe(text);
|
||||||
|
if (!parsed) return { available: false as const };
|
||||||
|
return { available: true as const, draft: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async importFromUrl(url: string, _householdId: string) {
|
||||||
|
const parsed = await this.llmProvider.parseRecipeFromUrl(url);
|
||||||
|
if (!parsed) return { available: false as const };
|
||||||
|
return { available: true as const, draft: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByProduct(
|
||||||
|
householdId: string,
|
||||||
|
productId: string,
|
||||||
|
query: { cursor?: string; limit?: number },
|
||||||
|
) {
|
||||||
|
return this.recipesRepository.findByProductId(householdId, productId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by products service after a product nutrition update. */
|
||||||
|
public async recalculateForProduct(householdId: string, productId: string) {
|
||||||
|
const recipes = await this.recipesRepository.findAllByProductId(householdId, productId);
|
||||||
|
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const productIds = recipe.ingredients.map((i) => i.productId);
|
||||||
|
const productMap = await this.buildProductMap(householdId, productIds);
|
||||||
|
|
||||||
|
const { totalNutrition, perServingNutrition, ingredientContributions } =
|
||||||
|
this.nutritionCalculator.calculateRecipeNutrition(
|
||||||
|
recipe.ingredients,
|
||||||
|
productMap,
|
||||||
|
recipe.servings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings = this.nutritionCalculator.generateWarnings(perServingNutrition);
|
||||||
|
|
||||||
|
const updatedIngredients = recipe.ingredients.map((ing, i) => ({
|
||||||
|
...ing,
|
||||||
|
nutritionContribution: ingredientContributions[i]!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.recipesRepository.update(
|
||||||
|
recipe._id.toString(),
|
||||||
|
householdId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
ingredients: updatedIngredients as RecipeIngredient[],
|
||||||
|
totalNutrition,
|
||||||
|
perServingNutrition,
|
||||||
|
warnings,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAndNormalizeIngredients(
|
||||||
|
ingredients: Array<{
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
preparation?: string;
|
||||||
|
isOptional?: boolean;
|
||||||
|
}>,
|
||||||
|
householdId: string,
|
||||||
|
) {
|
||||||
|
const productIds = [...new Set(ingredients.map((i) => i.productId))];
|
||||||
|
const products = await this.productsRepository.findByIds(householdId, productIds);
|
||||||
|
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
||||||
|
|
||||||
|
const conversionErrors: ConversionError[] = [];
|
||||||
|
const normalizedIngredients: Array<Omit<RecipeIngredient, 'nutritionContribution'>> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < ingredients.length; i++) {
|
||||||
|
const ing = ingredients[i]!;
|
||||||
|
const product = productMap.get(ing.productId);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundError(`Product "${ing.productId}" not found in household`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = toMetric(
|
||||||
|
ing.quantity,
|
||||||
|
ing.unit as never,
|
||||||
|
product.servingUnit as 'g' | 'ml' | 'piece' | 'slice',
|
||||||
|
product.densityGPerMl ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
conversionErrors.push({
|
||||||
|
ingredientIndex: i,
|
||||||
|
productId: ing.productId,
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedIngredients.push({
|
||||||
|
productId: ing.productId,
|
||||||
|
productName: ing.productName,
|
||||||
|
quantity: result.quantity,
|
||||||
|
unit: result.unit,
|
||||||
|
...(ing.unit !== result.unit
|
||||||
|
? { originalQuantity: ing.quantity, originalUnit: ing.unit as never }
|
||||||
|
: {}),
|
||||||
|
...(ing.preparation ? { preparation: ing.preparation } : {}),
|
||||||
|
isOptional: ing.isOptional ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversionErrors.length > 0) {
|
||||||
|
throw new BadRequestError('Unit conversion failed for one or more ingredients', {
|
||||||
|
conversionErrors: conversionErrors.map((e) => e.message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normalizedIngredients, productMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildProductMap(householdId: string, productIds: string[]) {
|
||||||
|
const products = await this.productsRepository.findByIds(householdId, productIds);
|
||||||
|
return new Map(products.map((p) => [p._id.toString(), p]));
|
||||||
|
}
|
||||||
|
}
|
||||||
132
packages/api/src/modules/recipes/unit-conversion.service.test.ts
Normal file
132
packages/api/src/modules/recipes/unit-conversion.service.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toMetric } from './unit-conversion.service.js';
|
||||||
|
|
||||||
|
describe('toMetric', () => {
|
||||||
|
describe('metric pass-through', () => {
|
||||||
|
it('passes g through unchanged', () => {
|
||||||
|
const r = toMetric(100, 'g', 'g');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 100, unit: 'g' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes ml through unchanged', () => {
|
||||||
|
const r = toMetric(250, 'ml', 'ml');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 250, unit: 'ml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes piece through unchanged', () => {
|
||||||
|
const r = toMetric(2, 'piece', 'piece');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 2, unit: 'piece' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes slice through unchanged', () => {
|
||||||
|
const r = toMetric(3, 'slice', 'slice');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 3, unit: 'slice' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mass conversions', () => {
|
||||||
|
it('converts oz to g for a g-product', () => {
|
||||||
|
const r = toMetric(1, 'oz', 'g');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 28.35, unit: 'g' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts lb to g for a g-product', () => {
|
||||||
|
const r = toMetric(1, 'lb', 'g');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 453.592, unit: 'g' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts oz to ml using density for a ml-product', () => {
|
||||||
|
// 1 oz = 28.3495 g; density 1.03 g/ml → 27.524... ml
|
||||||
|
const r = toMetric(1, 'oz', 'ml', 1.03);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.unit).toBe('ml');
|
||||||
|
expect(r.quantity).toBeCloseTo(27.524, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns MISSING_DENSITY for oz → ml when density absent', () => {
|
||||||
|
const r = toMetric(1, 'oz', 'ml');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns INCOMPATIBLE_UNITS for oz → piece', () => {
|
||||||
|
const r = toMetric(1, 'oz', 'piece');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('volume conversions', () => {
|
||||||
|
it('converts tsp to ml for a ml-product', () => {
|
||||||
|
const r = toMetric(1, 'tsp', 'ml');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 4.929, unit: 'ml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts tbsp to ml for a ml-product', () => {
|
||||||
|
const r = toMetric(1, 'tbsp', 'ml');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 14.787, unit: 'ml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts fl_oz to ml for a ml-product', () => {
|
||||||
|
const r = toMetric(1, 'fl_oz', 'ml');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 29.574, unit: 'ml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts cup to ml for a ml-product', () => {
|
||||||
|
const r = toMetric(1, 'cup', 'ml');
|
||||||
|
expect(r).toEqual({ ok: true, quantity: 236.588, unit: 'ml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts cup to g using density for a g-product', () => {
|
||||||
|
// 1 cup = 236.588 ml; density 1.05 g/ml → 248.417 g
|
||||||
|
const r = toMetric(1, 'cup', 'g', 1.05);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.unit).toBe('g');
|
||||||
|
expect(r.quantity).toBeCloseTo(248.417, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns MISSING_DENSITY for cup → g when density absent', () => {
|
||||||
|
const r = toMetric(1, 'cup', 'g');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns INCOMPATIBLE_UNITS for cup → piece', () => {
|
||||||
|
const r = toMetric(1, 'cup', 'piece');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mass to ml cross-conversion', () => {
|
||||||
|
it('converts oz to ml using density', () => {
|
||||||
|
// 1 oz = 28.3495 g; density 0.9 g/ml → 28.3495 / 0.9 = 31.499... ml
|
||||||
|
const r = toMetric(1, 'oz', 'ml', 0.9);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.unit).toBe('ml');
|
||||||
|
expect(r.quantity).toBeCloseTo(31.499, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns MISSING_DENSITY for oz → ml when density absent', () => {
|
||||||
|
const r = toMetric(1, 'oz', 'ml');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'MISSING_DENSITY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns INCOMPATIBLE_UNITS for oz → piece', () => {
|
||||||
|
const r = toMetric(1, 'oz', 'piece');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown unit', () => {
|
||||||
|
it('returns INCOMPATIBLE_UNITS for unknown unit', () => {
|
||||||
|
const r = toMetric(1, 'gallon' as never, 'g');
|
||||||
|
expect(r).toMatchObject({ ok: false, code: 'INCOMPATIBLE_UNITS' });
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.message).toContain('Unknown unit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
packages/api/src/modules/recipes/unit-conversion.service.ts
Normal file
119
packages/api/src/modules/recipes/unit-conversion.service.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import type { RecipeUnit, ImperialUnit } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type AnyUnit = RecipeUnit | ImperialUnit;
|
||||||
|
|
||||||
|
interface ConversionSuccess {
|
||||||
|
ok: true;
|
||||||
|
quantity: number;
|
||||||
|
unit: RecipeUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversionError {
|
||||||
|
ok: false;
|
||||||
|
code: 'MISSING_DENSITY' | 'INCOMPATIBLE_UNITS';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversionResult = ConversionSuccess | ConversionError;
|
||||||
|
|
||||||
|
// US customary volume → ml (exact per NIST)
|
||||||
|
const VOLUME_TO_ML: Partial<Record<AnyUnit, number>> = {
|
||||||
|
tsp: 4.92892,
|
||||||
|
tbsp: 14.7868,
|
||||||
|
fl_oz: 29.5735,
|
||||||
|
cup: 236.588,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mass → grams (exact)
|
||||||
|
const MASS_TO_G: Partial<Record<AnyUnit, number>> = {
|
||||||
|
oz: 28.3495,
|
||||||
|
lb: 453.592,
|
||||||
|
};
|
||||||
|
|
||||||
|
const METRIC_UNITS = new Set<AnyUnit>(['g', 'ml', 'piece', 'slice']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a recipe ingredient quantity/unit to its metric RecipeUnit equivalent.
|
||||||
|
*
|
||||||
|
* - Already metric/discrete → returned unchanged.
|
||||||
|
* - Mass imperial (oz, lb) → grams.
|
||||||
|
* - Volume imperial (cup, tbsp, tsp, fl_oz) → ml.
|
||||||
|
* - Mass↔volume cross-conversion requires densityGPerMl on the product;
|
||||||
|
* returns MISSING_DENSITY error if absent.
|
||||||
|
* - Discrete (piece, slice) cannot be converted from imperial;
|
||||||
|
* returns INCOMPATIBLE_UNITS error.
|
||||||
|
*/
|
||||||
|
export function toMetric(
|
||||||
|
quantity: number,
|
||||||
|
unit: AnyUnit,
|
||||||
|
productServingUnit: RecipeUnit,
|
||||||
|
densityGPerMl?: number,
|
||||||
|
): ConversionResult {
|
||||||
|
// Already a valid storage unit — pass through.
|
||||||
|
if (METRIC_UNITS.has(unit)) {
|
||||||
|
return { ok: true, quantity, unit: unit as RecipeUnit };
|
||||||
|
}
|
||||||
|
|
||||||
|
const massMultiplier = MASS_TO_G[unit];
|
||||||
|
const volumeMultiplier = VOLUME_TO_ML[unit];
|
||||||
|
|
||||||
|
if (massMultiplier !== undefined) {
|
||||||
|
const inGrams = quantity * massMultiplier;
|
||||||
|
|
||||||
|
if (productServingUnit === 'g') {
|
||||||
|
return { ok: true, quantity: round(inGrams), unit: 'g' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productServingUnit === 'ml') {
|
||||||
|
if (!densityGPerMl) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'MISSING_DENSITY',
|
||||||
|
message: `Cannot convert mass unit "${unit}" to ml without product density (densityGPerMl).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, quantity: round(inGrams / densityGPerMl), unit: 'ml' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INCOMPATIBLE_UNITS',
|
||||||
|
message: `Cannot convert mass unit "${unit}" to discrete unit "${productServingUnit}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volumeMultiplier !== undefined) {
|
||||||
|
const inMl = quantity * volumeMultiplier;
|
||||||
|
|
||||||
|
if (productServingUnit === 'ml') {
|
||||||
|
return { ok: true, quantity: round(inMl), unit: 'ml' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productServingUnit === 'g') {
|
||||||
|
if (!densityGPerMl) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'MISSING_DENSITY',
|
||||||
|
message: `Cannot convert volume unit "${unit}" to g without product density (densityGPerMl).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, quantity: round(inMl * densityGPerMl), unit: 'g' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INCOMPATIBLE_UNITS',
|
||||||
|
message: `Cannot convert volume unit "${unit}" to discrete unit "${productServingUnit}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INCOMPATIBLE_UNITS',
|
||||||
|
message: `Unknown unit "${unit}".`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
34
packages/api/src/schemas/freshness-rule.schema.ts
Normal file
34
packages/api/src/schemas/freshness-rule.schema.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { StorageLocation, FreshnessRuleSource, ProductCategory } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const freshnessRuleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String },
|
||||||
|
category: { type: String, enum: Object.values(ProductCategory), required: true },
|
||||||
|
storageLocation: { type: String, enum: Object.values(StorageLocation), required: true },
|
||||||
|
shelfLifeDays: { type: Number, required: true, min: 1 },
|
||||||
|
openedLifeDays: { type: Number, required: true, min: 1 },
|
||||||
|
freezerLifeDays: { type: Number, min: 1 },
|
||||||
|
spoilageSignsToCheck: { type: [String], default: [] },
|
||||||
|
tips: { type: String },
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(FreshnessRuleSource),
|
||||||
|
default: FreshnessRuleSource.HOUSEHOLD,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
toJSON: { virtuals: true },
|
||||||
|
toObject: { virtuals: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
freshnessRuleSchema.index({ category: 1, storageLocation: 1, householdId: 1 }, { unique: true });
|
||||||
|
freshnessRuleSchema.index({ householdId: 1 });
|
||||||
|
|
||||||
|
export const FreshnessRuleModel = mongoose.model('FreshnessRule', freshnessRuleSchema);
|
||||||
|
export type FreshnessRuleDocument = mongoose.InferSchemaType<typeof freshnessRuleSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
59
packages/api/src/schemas/pantry-item.schema.ts
Normal file
59
packages/api/src/schemas/pantry-item.schema.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
StorageLocation,
|
||||||
|
ItemStatus,
|
||||||
|
FreshnessUrgency,
|
||||||
|
FreshnessSource,
|
||||||
|
ServingUnit,
|
||||||
|
} from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const freshnessEstimateSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
estimatedExpiryDate: { type: Date, required: true },
|
||||||
|
daysRemaining: { type: Number, required: true },
|
||||||
|
urgency: { type: String, enum: Object.values(FreshnessUrgency), required: true },
|
||||||
|
source: { type: String, enum: Object.values(FreshnessSource), required: true },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const pantryItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
productId: { type: String, required: true },
|
||||||
|
productName: { type: String, required: true },
|
||||||
|
storageLocation: { type: String, enum: Object.values(StorageLocation), required: true },
|
||||||
|
quantity: { type: Number, required: true, min: 0 },
|
||||||
|
unit: { type: String, enum: Object.values(ServingUnit), required: true },
|
||||||
|
purchaseDate: { type: Date, required: true },
|
||||||
|
expirationDate: { type: Date },
|
||||||
|
openedDate: { type: Date },
|
||||||
|
preparedDate: { type: Date },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(ItemStatus),
|
||||||
|
default: ItemStatus.SEALED,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
freshnessEstimate: { type: freshnessEstimateSchema, required: true },
|
||||||
|
notes: { type: String },
|
||||||
|
purchasePrice: { type: Number },
|
||||||
|
storeId: { type: String },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
toJSON: { virtuals: true },
|
||||||
|
toObject: { virtuals: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
pantryItemSchema.index({ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 });
|
||||||
|
pantryItemSchema.index({ householdId: 1, storageLocation: 1, status: 1 });
|
||||||
|
pantryItemSchema.index({ householdId: 1, productId: 1, status: 1 });
|
||||||
|
pantryItemSchema.index({ householdId: 1, 'freshnessEstimate.estimatedExpiryDate': 1 });
|
||||||
|
|
||||||
|
export const PantryItemModel = mongoose.model('PantryItem', pantryItemSchema);
|
||||||
|
export type PantryItemDocument = mongoose.InferSchemaType<typeof pantryItemSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
32
packages/api/src/schemas/product.schema.test.ts
Normal file
32
packages/api/src/schemas/product.schema.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ProductModel } from './product.schema.js';
|
||||||
|
|
||||||
|
describe(ProductModel.name, () => {
|
||||||
|
it('is a valid mongoose model', () => {
|
||||||
|
expect(ProductModel.modelName).toBe('Product');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has expected schema paths', () => {
|
||||||
|
const paths = Object.keys(ProductModel.schema.paths);
|
||||||
|
expect(paths).toContain('householdId');
|
||||||
|
expect(paths).toContain('name');
|
||||||
|
expect(paths).toContain('category');
|
||||||
|
expect(paths).toContain('servingSize');
|
||||||
|
expect(paths).toContain('servingUnit');
|
||||||
|
expect(paths).toContain('nutrition');
|
||||||
|
expect(paths).toContain('tags');
|
||||||
|
expect(paths).toContain('source');
|
||||||
|
expect(paths).toContain('createdBy');
|
||||||
|
expect(paths).toContain('deletedAt');
|
||||||
|
expect(paths).toContain('createdAt');
|
||||||
|
expect(paths).toContain('updatedAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has expected indexes defined', () => {
|
||||||
|
const indexes = ProductModel.schema.indexes();
|
||||||
|
const indexKeys = indexes.map(([key]) => Object.keys(key).join(','));
|
||||||
|
expect(indexKeys).toContain('householdId,name,brand,tags');
|
||||||
|
expect(indexKeys).toContain('householdId,deletedAt,category');
|
||||||
|
expect(indexKeys).toContain('householdId,barcode');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
packages/api/src/schemas/product.schema.ts
Normal file
66
packages/api/src/schemas/product.schema.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const nutritionInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
calories: { type: Number, required: true, min: 0 },
|
||||||
|
protein: { type: Number, required: true, min: 0 },
|
||||||
|
carbs: { type: Number, required: true, min: 0 },
|
||||||
|
fat: { type: Number, required: true, min: 0 },
|
||||||
|
fiber: { type: Number, min: 0 },
|
||||||
|
sugar: { type: Number, min: 0 },
|
||||||
|
sodium: { type: Number, min: 0 },
|
||||||
|
saturatedFat: { type: Number, min: 0 },
|
||||||
|
cholesterol: { type: Number, min: 0 },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const productSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
brand: { type: String },
|
||||||
|
barcode: { type: String },
|
||||||
|
category: { type: String, enum: Object.values(ProductCategory), required: true },
|
||||||
|
servingSize: { type: Number, required: true, min: 0 },
|
||||||
|
servingUnit: { type: String, enum: Object.values(ServingUnit), required: true },
|
||||||
|
densityGPerMl: { type: Number, min: 0 },
|
||||||
|
nutrition: { type: nutritionInfoSchema, required: true },
|
||||||
|
tags: { type: [String], default: [] },
|
||||||
|
imageUrl: { type: String },
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(ProductSource),
|
||||||
|
required: true,
|
||||||
|
default: ProductSource.MANUAL,
|
||||||
|
},
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
deletedAt: { type: Date },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
toJSON: { virtuals: true },
|
||||||
|
toObject: { virtuals: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
productSchema.index(
|
||||||
|
{ householdId: 1, name: 'text', brand: 'text', tags: 'text' },
|
||||||
|
{ name: 'product_text_search' },
|
||||||
|
);
|
||||||
|
productSchema.index({ householdId: 1, deletedAt: 1, category: 1 });
|
||||||
|
productSchema.index(
|
||||||
|
{ householdId: 1, barcode: 1 },
|
||||||
|
{
|
||||||
|
name: 'product_barcode_unique',
|
||||||
|
unique: true,
|
||||||
|
partialFilterExpression: { barcode: { $exists: true }, deletedAt: { $exists: false } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
productSchema.index({ householdId: 1, name: 1, brand: 1 }, { name: 'product_dedup' });
|
||||||
|
|
||||||
|
export const ProductModel = mongoose.model('Product', productSchema);
|
||||||
|
export type ProductDocument = mongoose.InferSchemaType<typeof productSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
94
packages/api/src/schemas/recipe.schema.ts
Normal file
94
packages/api/src/schemas/recipe.schema.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
const nutritionInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
calories: { type: Number, required: true, min: 0 },
|
||||||
|
protein: { type: Number, required: true, min: 0 },
|
||||||
|
carbs: { type: Number, required: true, min: 0 },
|
||||||
|
fat: { type: Number, required: true, min: 0 },
|
||||||
|
fiber: { type: Number, min: 0 },
|
||||||
|
sugar: { type: Number, min: 0 },
|
||||||
|
sodium: { type: Number, min: 0 },
|
||||||
|
saturatedFat: { type: Number, min: 0 },
|
||||||
|
cholesterol: { type: Number, min: 0 },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeIngredientSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
productId: { type: String, required: true },
|
||||||
|
productName: { type: String, required: true },
|
||||||
|
quantity: { type: Number, required: true, min: 0 },
|
||||||
|
unit: { type: String, enum: ['g', 'ml', 'piece', 'slice'], required: true },
|
||||||
|
originalQuantity: { type: Number },
|
||||||
|
originalUnit: { type: String },
|
||||||
|
preparation: { type: String },
|
||||||
|
isOptional: { type: Boolean, default: false },
|
||||||
|
nutritionContribution: { type: nutritionInfoSchema, required: true },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeStepSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
order: { type: Number, required: true },
|
||||||
|
instruction: { type: String, required: true },
|
||||||
|
duration: { type: Number },
|
||||||
|
tip: { type: String },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeSourceSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
type: { type: String, enum: ['manual', 'url', 'llm_import', 'text_import'], required: true },
|
||||||
|
url: { type: String },
|
||||||
|
importedAt: { type: Date },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
description: { type: String },
|
||||||
|
servings: { type: Number, required: true, min: 1 },
|
||||||
|
prepTime: { type: Number },
|
||||||
|
cookTime: { type: Number },
|
||||||
|
totalTime: { type: Number },
|
||||||
|
ingredients: { type: [recipeIngredientSchema], default: [] },
|
||||||
|
steps: { type: [recipeStepSchema], default: [] },
|
||||||
|
tags: { type: [String], default: [] },
|
||||||
|
cuisine: { type: String },
|
||||||
|
imageUrl: { type: String },
|
||||||
|
source: { type: recipeSourceSchema },
|
||||||
|
totalNutrition: { type: nutritionInfoSchema, required: true },
|
||||||
|
perServingNutrition: { type: nutritionInfoSchema, required: true },
|
||||||
|
warnings: { type: [String], enum: Object.values(NutritionWarning), default: [] },
|
||||||
|
isFavorite: { type: Boolean, default: false },
|
||||||
|
createdBy: { type: String, required: true },
|
||||||
|
deletedAt: { type: Date },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
toJSON: { virtuals: true },
|
||||||
|
toObject: { virtuals: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
recipeSchema.index(
|
||||||
|
{ householdId: 1, name: 'text', tags: 'text', cuisine: 'text' },
|
||||||
|
{ name: 'recipe_text_search' },
|
||||||
|
);
|
||||||
|
recipeSchema.index({ householdId: 1, 'ingredients.productId': 1 });
|
||||||
|
recipeSchema.index({ householdId: 1, tags: 1 });
|
||||||
|
recipeSchema.index({ householdId: 1, isFavorite: 1 });
|
||||||
|
recipeSchema.index({ householdId: 1, deletedAt: 1 });
|
||||||
|
|
||||||
|
export const RecipeModel = mongoose.model('Recipe', recipeSchema);
|
||||||
|
export type RecipeDocument = mongoose.InferSchemaType<typeof recipeSchema> & {
|
||||||
|
_id: mongoose.Types.ObjectId;
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
enabled: false, // enable via --coverage flag or test:cov script
|
enabled: false, // enable via --coverage flag or test:cov script
|
||||||
|
all: true,
|
||||||
include: ['src/**/*.ts'],
|
include: ['src/**/*.ts'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'src/**/*.test.ts',
|
'src/**/*.test.ts',
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,6 @@ export * from './cabinet-event.enums.js';
|
||||||
export * from './regimen.enums.js';
|
export * from './regimen.enums.js';
|
||||||
export * from './refill.enums.js';
|
export * from './refill.enums.js';
|
||||||
export * from './purchase.enums.js';
|
export * from './purchase.enums.js';
|
||||||
|
export * from './product.enums.js';
|
||||||
|
export * from './recipe.enums.js';
|
||||||
|
export * from './pantry.enums.js';
|
||||||
|
|
|
||||||
53
packages/shared/src/enums/pantry.enums.test.ts
Normal file
53
packages/shared/src/enums/pantry.enums.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
StorageLocation,
|
||||||
|
ItemStatus,
|
||||||
|
FreshnessUrgency,
|
||||||
|
FreshnessSource,
|
||||||
|
FreshnessRuleSource,
|
||||||
|
} from './pantry.enums.js';
|
||||||
|
|
||||||
|
describe('Pantry Enums', () => {
|
||||||
|
describe('StorageLocation', () => {
|
||||||
|
it('has all expected values', () => {
|
||||||
|
expect(Object.values(StorageLocation)).toEqual(['pantry', 'fridge', 'freezer', 'counter']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ItemStatus', () => {
|
||||||
|
it('has all expected values', () => {
|
||||||
|
expect(Object.values(ItemStatus)).toEqual([
|
||||||
|
'sealed',
|
||||||
|
'opened',
|
||||||
|
'prepared',
|
||||||
|
'consumed',
|
||||||
|
'discarded',
|
||||||
|
'expired',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FreshnessUrgency', () => {
|
||||||
|
it('has all expected values', () => {
|
||||||
|
expect(Object.values(FreshnessUrgency)).toEqual([
|
||||||
|
'fresh',
|
||||||
|
'use_soon',
|
||||||
|
'urgent',
|
||||||
|
'check',
|
||||||
|
'expired',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FreshnessSource', () => {
|
||||||
|
it('has all expected values', () => {
|
||||||
|
expect(Object.values(FreshnessSource)).toEqual(['packaging', 'rule', 'manual']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FreshnessRuleSource', () => {
|
||||||
|
it('has all expected values', () => {
|
||||||
|
expect(Object.values(FreshnessRuleSource)).toEqual(['system', 'household']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
packages/shared/src/enums/pantry.enums.ts
Normal file
34
packages/shared/src/enums/pantry.enums.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
export enum StorageLocation {
|
||||||
|
PANTRY = 'pantry',
|
||||||
|
FRIDGE = 'fridge',
|
||||||
|
FREEZER = 'freezer',
|
||||||
|
COUNTER = 'counter',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemStatus {
|
||||||
|
SEALED = 'sealed',
|
||||||
|
OPENED = 'opened',
|
||||||
|
PREPARED = 'prepared',
|
||||||
|
CONSUMED = 'consumed',
|
||||||
|
DISCARDED = 'discarded',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FreshnessUrgency {
|
||||||
|
FRESH = 'fresh',
|
||||||
|
USE_SOON = 'use_soon',
|
||||||
|
URGENT = 'urgent',
|
||||||
|
CHECK = 'check',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FreshnessSource {
|
||||||
|
PACKAGING = 'packaging',
|
||||||
|
RULE = 'rule',
|
||||||
|
MANUAL = 'manual',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FreshnessRuleSource {
|
||||||
|
SYSTEM = 'system',
|
||||||
|
HOUSEHOLD = 'household',
|
||||||
|
}
|
||||||
41
packages/shared/src/enums/product.enums.test.ts
Normal file
41
packages/shared/src/enums/product.enums.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from './product.enums.js';
|
||||||
|
|
||||||
|
describe(ProductCategory.name, () => {
|
||||||
|
it('has expected values', () => {
|
||||||
|
expect(ProductCategory.DAIRY).toBe('dairy');
|
||||||
|
expect(ProductCategory.MEAT).toBe('meat');
|
||||||
|
expect(ProductCategory.VEGETABLES).toBe('vegetables');
|
||||||
|
expect(ProductCategory.OTHER).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 20 categories', () => {
|
||||||
|
expect(Object.values(ProductCategory)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(ServingUnit.name, () => {
|
||||||
|
it('has metric and discrete units only', () => {
|
||||||
|
expect(ServingUnit.GRAMS).toBe('g');
|
||||||
|
expect(ServingUnit.MILLILITERS).toBe('ml');
|
||||||
|
expect(ServingUnit.PIECES).toBe('piece');
|
||||||
|
expect(ServingUnit.SLICES).toBe('slice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has exactly 4 units (no imperial)', () => {
|
||||||
|
expect(Object.values(ServingUnit)).toHaveLength(4);
|
||||||
|
expect(Object.values(ServingUnit)).not.toContain('oz');
|
||||||
|
expect(Object.values(ServingUnit)).not.toContain('cup');
|
||||||
|
expect(Object.values(ServingUnit)).not.toContain('tbsp');
|
||||||
|
expect(Object.values(ServingUnit)).not.toContain('tsp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(ProductSource.name, () => {
|
||||||
|
it('has expected values', () => {
|
||||||
|
expect(ProductSource.MANUAL).toBe('manual');
|
||||||
|
expect(ProductSource.BARCODE_LOOKUP).toBe('barcode_lookup');
|
||||||
|
expect(ProductSource.LLM).toBe('llm');
|
||||||
|
expect(ProductSource.IMPORT).toBe('import');
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/shared/src/enums/product.enums.ts
Normal file
40
packages/shared/src/enums/product.enums.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
export enum ProductCategory {
|
||||||
|
DAIRY = 'dairy',
|
||||||
|
MEAT = 'meat',
|
||||||
|
POULTRY = 'poultry',
|
||||||
|
SEAFOOD = 'seafood',
|
||||||
|
FRUITS = 'fruits',
|
||||||
|
VEGETABLES = 'vegetables',
|
||||||
|
GRAINS = 'grains',
|
||||||
|
LEGUMES = 'legumes',
|
||||||
|
NUTS_SEEDS = 'nuts_seeds',
|
||||||
|
OILS_FATS = 'oils_fats',
|
||||||
|
CONDIMENTS = 'condiments',
|
||||||
|
SPICES = 'spices',
|
||||||
|
BEVERAGES = 'beverages',
|
||||||
|
SNACKS = 'snacks',
|
||||||
|
FROZEN = 'frozen',
|
||||||
|
CANNED = 'canned',
|
||||||
|
BAKERY = 'bakery',
|
||||||
|
DELI = 'deli',
|
||||||
|
SUPPLEMENTS = 'supplements',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metric and discrete units only. Imperial/volume cooking units (oz, cup, tbsp, tsp)
|
||||||
|
* are accepted at recipe-input time in Phase 6 and converted before persistence.
|
||||||
|
*/
|
||||||
|
export enum ServingUnit {
|
||||||
|
GRAMS = 'g',
|
||||||
|
MILLILITERS = 'ml',
|
||||||
|
PIECES = 'piece',
|
||||||
|
SLICES = 'slice',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProductSource {
|
||||||
|
MANUAL = 'manual',
|
||||||
|
BARCODE_LOOKUP = 'barcode_lookup',
|
||||||
|
LLM = 'llm',
|
||||||
|
IMPORT = 'import',
|
||||||
|
}
|
||||||
18
packages/shared/src/enums/recipe.enums.test.ts
Normal file
18
packages/shared/src/enums/recipe.enums.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { NutritionWarning } from './recipe.enums.js';
|
||||||
|
|
||||||
|
describe(NutritionWarning.name, () => {
|
||||||
|
it('has expected values', () => {
|
||||||
|
expect(NutritionWarning.HIGH_CALORIES).toBe('high_calories');
|
||||||
|
expect(NutritionWarning.HIGH_SODIUM).toBe('high_sodium');
|
||||||
|
expect(NutritionWarning.HIGH_SUGAR).toBe('high_sugar');
|
||||||
|
expect(NutritionWarning.HIGH_SATURATED_FAT).toBe('high_saturated_fat');
|
||||||
|
expect(NutritionWarning.LOW_PROTEIN).toBe('low_protein');
|
||||||
|
expect(NutritionWarning.LOW_FIBER).toBe('low_fiber');
|
||||||
|
expect(NutritionWarning.HIGH_CHOLESTEROL).toBe('high_cholesterol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has exactly 7 warning types', () => {
|
||||||
|
expect(Object.values(NutritionWarning)).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
packages/shared/src/enums/recipe.enums.ts
Normal file
9
packages/shared/src/enums/recipe.enums.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export enum NutritionWarning {
|
||||||
|
HIGH_CALORIES = 'high_calories',
|
||||||
|
HIGH_SODIUM = 'high_sodium',
|
||||||
|
HIGH_SUGAR = 'high_sugar',
|
||||||
|
HIGH_SATURATED_FAT = 'high_saturated_fat',
|
||||||
|
LOW_PROTEIN = 'low_protein',
|
||||||
|
LOW_FIBER = 'low_fiber',
|
||||||
|
HIGH_CHOLESTEROL = 'high_cholesterol',
|
||||||
|
}
|
||||||
17
packages/shared/src/types/freshness.ts
Normal file
17
packages/shared/src/types/freshness.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { FreshnessRuleSource, StorageLocation } from '../enums/pantry.enums.js';
|
||||||
|
import type { ProductCategory } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
export interface FreshnessRule {
|
||||||
|
id: string;
|
||||||
|
householdId?: string;
|
||||||
|
category: ProductCategory;
|
||||||
|
storageLocation: StorageLocation;
|
||||||
|
shelfLifeDays: number;
|
||||||
|
openedLifeDays: number;
|
||||||
|
freezerLifeDays?: number;
|
||||||
|
spoilageSignsToCheck: string[];
|
||||||
|
tips?: string;
|
||||||
|
source: FreshnessRuleSource;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,7 @@ export * from './store.js';
|
||||||
export * from './medicine-price.js';
|
export * from './medicine-price.js';
|
||||||
export * from './refill.js';
|
export * from './refill.js';
|
||||||
export * from './purchase.js';
|
export * from './purchase.js';
|
||||||
|
export * from './product.js';
|
||||||
|
export * from './recipe.js';
|
||||||
|
export * from './pantry.js';
|
||||||
|
export * from './freshness.js';
|
||||||
|
|
|
||||||
36
packages/shared/src/types/pantry.ts
Normal file
36
packages/shared/src/types/pantry.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type {
|
||||||
|
FreshnessUrgency,
|
||||||
|
FreshnessSource,
|
||||||
|
ItemStatus,
|
||||||
|
StorageLocation,
|
||||||
|
} from '../enums/pantry.enums.js';
|
||||||
|
import type { ServingUnit } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
export interface FreshnessEstimate {
|
||||||
|
estimatedExpiryDate: Date;
|
||||||
|
daysRemaining: number;
|
||||||
|
urgency: FreshnessUrgency;
|
||||||
|
source: FreshnessSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PantryItem {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
storageLocation: StorageLocation;
|
||||||
|
quantity: number;
|
||||||
|
unit: ServingUnit;
|
||||||
|
purchaseDate: Date;
|
||||||
|
expirationDate?: Date;
|
||||||
|
openedDate?: Date;
|
||||||
|
preparedDate?: Date;
|
||||||
|
status: ItemStatus;
|
||||||
|
freshnessEstimate: FreshnessEstimate;
|
||||||
|
notes?: string;
|
||||||
|
purchasePrice?: number;
|
||||||
|
storeId?: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
33
packages/shared/src/types/product.ts
Normal file
33
packages/shared/src/types/product.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { ProductCategory, ServingUnit, ProductSource } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
export interface NutritionInfo {
|
||||||
|
calories: number; // kcal per serving
|
||||||
|
protein: number; // grams
|
||||||
|
carbs: number; // grams
|
||||||
|
fat: number; // grams
|
||||||
|
fiber?: number; // grams
|
||||||
|
sugar?: number; // grams
|
||||||
|
sodium?: number; // mg
|
||||||
|
saturatedFat?: number; // grams
|
||||||
|
cholesterol?: number; // mg
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
brand?: string;
|
||||||
|
barcode?: string; // EAN-13 / UPC-A, digits only
|
||||||
|
category: ProductCategory;
|
||||||
|
servingSize: number; // quantity of one serving in `servingUnit`
|
||||||
|
servingUnit: ServingUnit; // metric or discrete only
|
||||||
|
densityGPerMl?: number; // optional, enables volume↔mass conversion in Phase 6
|
||||||
|
nutrition: NutritionInfo; // values are PER serving (size = servingSize servingUnit)
|
||||||
|
tags: string[];
|
||||||
|
imageUrl?: string;
|
||||||
|
source: ProductSource;
|
||||||
|
createdBy: string; // userId
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date; // soft delete
|
||||||
|
}
|
||||||
58
packages/shared/src/types/recipe.ts
Normal file
58
packages/shared/src/types/recipe.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { NutritionInfo } from './product.js';
|
||||||
|
import type { NutritionWarning } from '../enums/recipe.enums.js';
|
||||||
|
|
||||||
|
/** Units stored in the database — always metric or discrete. */
|
||||||
|
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';
|
||||||
|
|
||||||
|
/** Accepted at recipe input / import only; converted to RecipeUnit before persistence. */
|
||||||
|
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';
|
||||||
|
|
||||||
|
export interface RecipeIngredient {
|
||||||
|
productId: string; // Reference to Product
|
||||||
|
productName: string; // Denormalized for display
|
||||||
|
quantity: number; // stored in metric (g | ml) or as a discrete count
|
||||||
|
unit: RecipeUnit; // metric/discrete only after normalization
|
||||||
|
originalQuantity?: number; // preserved from import (e.g. 1)
|
||||||
|
originalUnit?: ImperialUnit | RecipeUnit; // preserved from import (e.g. 'cup')
|
||||||
|
preparation?: string; // e.g., 'diced', 'minced', 'melted'
|
||||||
|
isOptional: boolean;
|
||||||
|
nutritionContribution: NutritionInfo; // per-ingredient computed nutrition
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeStep {
|
||||||
|
order: number;
|
||||||
|
instruction: string;
|
||||||
|
duration?: number; // minutes
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeSource {
|
||||||
|
type: 'manual' | 'url' | 'llm_import' | 'text_import';
|
||||||
|
url?: string;
|
||||||
|
importedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recipe {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
servings: number;
|
||||||
|
prepTime?: number; // minutes
|
||||||
|
cookTime?: number; // minutes
|
||||||
|
totalTime?: number; // minutes (computed or manual)
|
||||||
|
ingredients: RecipeIngredient[];
|
||||||
|
steps: RecipeStep[];
|
||||||
|
tags: string[];
|
||||||
|
cuisine?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
source?: RecipeSource;
|
||||||
|
totalNutrition: NutritionInfo; // denormalized, computed on save
|
||||||
|
perServingNutrition: NutritionInfo; // denormalized, computed on save
|
||||||
|
warnings: NutritionWarning[]; // computed on save
|
||||||
|
isFavorite: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
CreateFreshnessRuleSchema,
|
||||||
|
UpdateFreshnessRuleSchema,
|
||||||
|
FreshnessRuleQuerySchema,
|
||||||
|
} from './freshness-rule.schemas.js';
|
||||||
|
|
||||||
|
describe('FreshnessRule Schemas', () => {
|
||||||
|
describe('CreateFreshnessRuleSchema', () => {
|
||||||
|
const valid = {
|
||||||
|
category: 'dairy',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
shelfLifeDays: 14,
|
||||||
|
openedLifeDays: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
const result = CreateFreshnessRuleSchema.parse(valid);
|
||||||
|
expect(result).toMatchObject(valid);
|
||||||
|
expect(result.spoilageSignsToCheck).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all optional fields', () => {
|
||||||
|
const full = {
|
||||||
|
...valid,
|
||||||
|
freezerLifeDays: 90,
|
||||||
|
spoilageSignsToCheck: ['smell', 'discoloration'],
|
||||||
|
tips: 'Keep sealed tightly',
|
||||||
|
};
|
||||||
|
expect(CreateFreshnessRuleSchema.parse(full)).toMatchObject(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing category', () => {
|
||||||
|
const { category: _, ...rest } = valid;
|
||||||
|
expect(() => CreateFreshnessRuleSchema.parse(rest)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid category', () => {
|
||||||
|
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, category: 'candy' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero shelfLifeDays', () => {
|
||||||
|
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, shelfLifeDays: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero openedLifeDays', () => {
|
||||||
|
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, openedLifeDays: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateFreshnessRuleSchema', () => {
|
||||||
|
it('accepts partial update', () => {
|
||||||
|
const result = UpdateFreshnessRuleSchema.parse({ shelfLifeDays: 10 });
|
||||||
|
expect(result.shelfLifeDays).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
expect(UpdateFreshnessRuleSchema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FreshnessRuleQuerySchema', () => {
|
||||||
|
it('applies defaults', () => {
|
||||||
|
const result = FreshnessRuleQuerySchema.parse({});
|
||||||
|
expect(result.limit).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all filters', () => {
|
||||||
|
const input = {
|
||||||
|
category: 'meat',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
cursor: 'abc',
|
||||||
|
limit: 25,
|
||||||
|
};
|
||||||
|
expect(FreshnessRuleQuerySchema.parse(input)).toMatchObject(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/shared/src/validation/freshness-rule.schemas.ts
Normal file
56
packages/shared/src/validation/freshness-rule.schemas.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import { StorageLocation, FreshnessRuleSource } from '../enums/pantry.enums.js';
|
||||||
|
import { ProductCategory } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
export const CreateFreshnessRuleSchema = z.object({
|
||||||
|
category: z.nativeEnum(ProductCategory),
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation),
|
||||||
|
shelfLifeDays: z.number().int().min(1),
|
||||||
|
openedLifeDays: z.number().int().min(1),
|
||||||
|
freezerLifeDays: z.number().int().min(1).optional(),
|
||||||
|
spoilageSignsToCheck: z.array(z.string().min(1).max(200)).max(20).default([]),
|
||||||
|
tips: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateFreshnessRuleSchema = z.object({
|
||||||
|
shelfLifeDays: z.number().int().min(1).optional(),
|
||||||
|
openedLifeDays: z.number().int().min(1).optional(),
|
||||||
|
freezerLifeDays: z.number().int().min(1).optional(),
|
||||||
|
spoilageSignsToCheck: z.array(z.string().min(1).max(200)).max(20).optional(),
|
||||||
|
tips: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FreshnessRuleQuerySchema = z.object({
|
||||||
|
category: z.nativeEnum(ProductCategory).optional(),
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation).optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FreshnessRuleResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string().optional(),
|
||||||
|
category: z.nativeEnum(ProductCategory),
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation),
|
||||||
|
shelfLifeDays: z.number(),
|
||||||
|
openedLifeDays: z.number(),
|
||||||
|
freezerLifeDays: z.number().optional(),
|
||||||
|
spoilageSignsToCheck: z.array(z.string()),
|
||||||
|
tips: z.string().optional(),
|
||||||
|
source: z.nativeEnum(FreshnessRuleSource),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FreshnessRuleListResponseSchema = z.object({
|
||||||
|
data: z.array(FreshnessRuleResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type CreateFreshnessRuleInput = z.infer<typeof CreateFreshnessRuleSchema>;
|
||||||
|
export type UpdateFreshnessRuleInput = z.infer<typeof UpdateFreshnessRuleSchema>;
|
||||||
|
export type FreshnessRuleQueryInput = z.infer<typeof FreshnessRuleQuerySchema>;
|
||||||
|
|
@ -9,3 +9,7 @@ export * from './store.schemas.js';
|
||||||
export * from './medicine-price.schemas.js';
|
export * from './medicine-price.schemas.js';
|
||||||
export * from './refill.schemas.js';
|
export * from './refill.schemas.js';
|
||||||
export * from './purchase.schemas.js';
|
export * from './purchase.schemas.js';
|
||||||
|
export * from './product.schemas.js';
|
||||||
|
export * from './recipe.schemas.js';
|
||||||
|
export * from './pantry.schemas.js';
|
||||||
|
export * from './freshness-rule.schemas.js';
|
||||||
|
|
|
||||||
164
packages/shared/src/validation/pantry.schemas.test.ts
Normal file
164
packages/shared/src/validation/pantry.schemas.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
CreatePantryItemSchema,
|
||||||
|
UpdatePantryItemSchema,
|
||||||
|
TransitionPantryItemSchema,
|
||||||
|
BatchTransitionSchema,
|
||||||
|
PantryQuerySchema,
|
||||||
|
ExpiringQuerySchema,
|
||||||
|
WasteStatsQuerySchema,
|
||||||
|
} from './pantry.schemas.js';
|
||||||
|
|
||||||
|
describe('Pantry Schemas', () => {
|
||||||
|
describe('CreatePantryItemSchema', () => {
|
||||||
|
const valid = {
|
||||||
|
productId: 'p1',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 2,
|
||||||
|
unit: 'piece',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts valid input', () => {
|
||||||
|
expect(CreatePantryItemSchema.parse(valid)).toMatchObject(valid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all optional fields', () => {
|
||||||
|
const full = {
|
||||||
|
...valid,
|
||||||
|
purchaseDate: '2024-01-15T00:00:00Z',
|
||||||
|
expirationDate: '2024-02-15T00:00:00Z',
|
||||||
|
notes: 'Organic',
|
||||||
|
purchasePrice: 4.99,
|
||||||
|
storeId: 's1',
|
||||||
|
};
|
||||||
|
expect(CreatePantryItemSchema.parse(full)).toMatchObject(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing productId', () => {
|
||||||
|
const { productId: _, ...rest } = valid;
|
||||||
|
expect(() => CreatePantryItemSchema.parse(rest)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero quantity', () => {
|
||||||
|
expect(() => CreatePantryItemSchema.parse({ ...valid, quantity: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative quantity', () => {
|
||||||
|
expect(() => CreatePantryItemSchema.parse({ ...valid, quantity: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid storageLocation', () => {
|
||||||
|
expect(() => CreatePantryItemSchema.parse({ ...valid, storageLocation: 'garage' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative purchasePrice', () => {
|
||||||
|
expect(() => CreatePantryItemSchema.parse({ ...valid, purchasePrice: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdatePantryItemSchema', () => {
|
||||||
|
it('accepts partial update', () => {
|
||||||
|
const result = UpdatePantryItemSchema.parse({ quantity: 3 });
|
||||||
|
expect(result.quantity).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
const result = UpdatePantryItemSchema.parse({});
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid storageLocation', () => {
|
||||||
|
expect(() => UpdatePantryItemSchema.parse({ storageLocation: 'attic' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TransitionPantryItemSchema', () => {
|
||||||
|
it('accepts valid transition', () => {
|
||||||
|
const result = TransitionPantryItemSchema.parse({ status: 'opened' });
|
||||||
|
expect(result.status).toBe('opened');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts transition with date and notes', () => {
|
||||||
|
const input = {
|
||||||
|
status: 'consumed',
|
||||||
|
date: '2024-01-15T12:00:00Z',
|
||||||
|
notes: 'Used in soup',
|
||||||
|
};
|
||||||
|
expect(TransitionPantryItemSchema.parse(input)).toMatchObject(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects sealed status as target', () => {
|
||||||
|
expect(() => TransitionPantryItemSchema.parse({ status: 'sealed' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired status as target', () => {
|
||||||
|
expect(() => TransitionPantryItemSchema.parse({ status: 'expired' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BatchTransitionSchema', () => {
|
||||||
|
it('accepts valid batch transition', () => {
|
||||||
|
const input = {
|
||||||
|
itemIds: ['id1', 'id2'],
|
||||||
|
status: 'consumed',
|
||||||
|
};
|
||||||
|
expect(BatchTransitionSchema.parse(input)).toMatchObject(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty itemIds', () => {
|
||||||
|
expect(() => BatchTransitionSchema.parse({ itemIds: [], status: 'consumed' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only allows consumed or discarded', () => {
|
||||||
|
expect(() => BatchTransitionSchema.parse({ itemIds: ['id1'], status: 'opened' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PantryQuerySchema', () => {
|
||||||
|
it('applies defaults', () => {
|
||||||
|
const result = PantryQuerySchema.parse({});
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all filters', () => {
|
||||||
|
const input = {
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
status: 'sealed,opened',
|
||||||
|
urgency: 'urgent',
|
||||||
|
productId: 'p1',
|
||||||
|
cursor: 'abc',
|
||||||
|
limit: 50,
|
||||||
|
};
|
||||||
|
expect(PantryQuerySchema.parse(input)).toMatchObject(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExpiringQuerySchema', () => {
|
||||||
|
it('applies defaults', () => {
|
||||||
|
const result = ExpiringQuerySchema.parse({});
|
||||||
|
expect(result.days).toBe(7);
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects days > 365', () => {
|
||||||
|
expect(() => ExpiringQuerySchema.parse({ days: 400 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WasteStatsQuerySchema', () => {
|
||||||
|
it('defaults to month', () => {
|
||||||
|
const result = WasteStatsQuerySchema.parse({});
|
||||||
|
expect(result.period).toBe('month');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid periods', () => {
|
||||||
|
for (const period of ['week', 'month', 'quarter', 'year']) {
|
||||||
|
expect(WasteStatsQuerySchema.parse({ period }).period).toBe(period);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid period', () => {
|
||||||
|
expect(() => WasteStatsQuerySchema.parse({ period: 'decade' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
packages/shared/src/validation/pantry.schemas.ts
Normal file
143
packages/shared/src/validation/pantry.schemas.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import {
|
||||||
|
StorageLocation,
|
||||||
|
ItemStatus,
|
||||||
|
FreshnessUrgency,
|
||||||
|
FreshnessSource,
|
||||||
|
} from '../enums/pantry.enums.js';
|
||||||
|
import { ServingUnit } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
// --- PantryItem ---
|
||||||
|
|
||||||
|
export const CreatePantryItemSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit: z.nativeEnum(ServingUnit),
|
||||||
|
purchaseDate: z.iso.datetime().optional(),
|
||||||
|
expirationDate: z.iso.datetime().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
purchasePrice: z.number().nonnegative().optional(),
|
||||||
|
storeId: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdatePantryItemSchema = z.object({
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
unit: z.nativeEnum(ServingUnit).optional(),
|
||||||
|
expirationDate: z.iso.datetime().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TransitionPantryItemSchema = z.object({
|
||||||
|
status: z.enum([
|
||||||
|
ItemStatus.OPENED,
|
||||||
|
ItemStatus.PREPARED,
|
||||||
|
ItemStatus.CONSUMED,
|
||||||
|
ItemStatus.DISCARDED,
|
||||||
|
]),
|
||||||
|
date: z.iso.datetime().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BatchTransitionSchema = z.object({
|
||||||
|
itemIds: z.array(z.string().min(1)).min(1).max(100),
|
||||||
|
status: z.enum([ItemStatus.CONSUMED, ItemStatus.DISCARDED]),
|
||||||
|
date: z.iso.datetime().optional(),
|
||||||
|
notes: z.string().max(1000).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PantryQuerySchema = z.object({
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation).optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
urgency: z.string().optional(),
|
||||||
|
productId: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ExpiringQuerySchema = z.object({
|
||||||
|
days: z.coerce.number().int().min(1).max(365).default(7),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WasteStatsQuerySchema = z.object({
|
||||||
|
period: z.enum(['week', 'month', 'quarter', 'year']).default('month'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Response schemas ---
|
||||||
|
|
||||||
|
const FreshnessEstimateResponseSchema = z.object({
|
||||||
|
estimatedExpiryDate: z.string(),
|
||||||
|
daysRemaining: z.number(),
|
||||||
|
urgency: z.nativeEnum(FreshnessUrgency),
|
||||||
|
source: z.nativeEnum(FreshnessSource),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PantryItemResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
productId: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
storageLocation: z.nativeEnum(StorageLocation),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: z.string(),
|
||||||
|
purchaseDate: z.string(),
|
||||||
|
expirationDate: z.string().optional(),
|
||||||
|
openedDate: z.string().optional(),
|
||||||
|
preparedDate: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ItemStatus),
|
||||||
|
freshnessEstimate: FreshnessEstimateResponseSchema,
|
||||||
|
notes: z.string().optional(),
|
||||||
|
purchasePrice: z.number().optional(),
|
||||||
|
storeId: z.string().optional(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PantryItemListResponseSchema = z.object({
|
||||||
|
data: z.array(PantryItemResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WasteCategorySchema = z.object({
|
||||||
|
category: z.string(),
|
||||||
|
count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WasteProductSchema = z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WasteStatsResponseSchema = z.object({
|
||||||
|
period: z.object({
|
||||||
|
start: z.string(),
|
||||||
|
end: z.string(),
|
||||||
|
}),
|
||||||
|
totalItemsConsumed: z.number(),
|
||||||
|
totalItemsDiscarded: z.number(),
|
||||||
|
wastePercentage: z.number(),
|
||||||
|
topWastedCategories: z.array(WasteCategorySchema),
|
||||||
|
topWastedProducts: z.array(WasteProductSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BatchTransitionResponseSchema = z.object({
|
||||||
|
transitioned: z.number(),
|
||||||
|
failed: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type CreatePantryItemInput = z.infer<typeof CreatePantryItemSchema>;
|
||||||
|
export type UpdatePantryItemInput = z.infer<typeof UpdatePantryItemSchema>;
|
||||||
|
export type TransitionPantryItemInput = z.infer<typeof TransitionPantryItemSchema>;
|
||||||
|
export type BatchTransitionInput = z.infer<typeof BatchTransitionSchema>;
|
||||||
|
export type PantryQueryInput = z.infer<typeof PantryQuerySchema>;
|
||||||
|
export type ExpiringQueryInput = z.infer<typeof ExpiringQuerySchema>;
|
||||||
|
export type WasteStatsQueryInput = z.infer<typeof WasteStatsQuerySchema>;
|
||||||
75
packages/shared/src/validation/product.schemas.ts
Normal file
75
packages/shared/src/validation/product.schemas.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '../enums/product.enums.js';
|
||||||
|
|
||||||
|
export const NutritionInfoSchema = z.object({
|
||||||
|
calories: z.number().min(0),
|
||||||
|
protein: z.number().min(0),
|
||||||
|
carbs: z.number().min(0),
|
||||||
|
fat: z.number().min(0),
|
||||||
|
fiber: z.number().min(0).optional(),
|
||||||
|
sugar: z.number().min(0).optional(),
|
||||||
|
sodium: z.number().min(0).optional(),
|
||||||
|
saturatedFat: z.number().min(0).optional(),
|
||||||
|
cholesterol: z.number().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateProductSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim(),
|
||||||
|
brand: z.string().max(200).trim().optional(),
|
||||||
|
barcode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{8,14}$/, 'Barcode must be 8-14 digits')
|
||||||
|
.optional(),
|
||||||
|
category: z.nativeEnum(ProductCategory),
|
||||||
|
servingSize: z.number().positive(),
|
||||||
|
servingUnit: z.nativeEnum(ServingUnit),
|
||||||
|
densityGPerMl: z.number().positive().optional(),
|
||||||
|
nutrition: NutritionInfoSchema,
|
||||||
|
tags: z.array(z.string().min(1).max(50).trim()).max(20).default([]),
|
||||||
|
imageUrl: z.url().optional(),
|
||||||
|
source: z.nativeEnum(ProductSource).default(ProductSource.MANUAL),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateProductSchema = CreateProductSchema.partial();
|
||||||
|
|
||||||
|
export const ProductQuerySchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
category: z.nativeEnum(ProductCategory).optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
barcode: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProductResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
brand: z.string().optional(),
|
||||||
|
barcode: z.string().optional(),
|
||||||
|
category: z.string(),
|
||||||
|
servingSize: z.number(),
|
||||||
|
servingUnit: z.string(),
|
||||||
|
densityGPerMl: z.number().optional(),
|
||||||
|
nutrition: NutritionInfoSchema,
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
source: z.string(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
deletedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProductListResponseSchema = z.object({
|
||||||
|
data: z.array(ProductResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
total: z.number().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
|
||||||
|
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>;
|
||||||
|
export type ProductQueryInput = z.infer<typeof ProductQuerySchema>;
|
||||||
87
packages/shared/src/validation/recipe.schemas.test.ts
Normal file
87
packages/shared/src/validation/recipe.schemas.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CreateRecipeSchema, ScaleRecipeSchema, RecipeQuerySchema } from './recipe.schemas.js';
|
||||||
|
|
||||||
|
const validIngredient = {
|
||||||
|
productId: 'prod-1',
|
||||||
|
productName: 'Chicken Breast',
|
||||||
|
quantity: 150,
|
||||||
|
unit: 'g',
|
||||||
|
};
|
||||||
|
|
||||||
|
const validStep = { order: 1, instruction: 'Cook the chicken.' };
|
||||||
|
|
||||||
|
describe(CreateRecipeSchema.name, () => {
|
||||||
|
it('accepts a minimal valid recipe', () => {
|
||||||
|
const result = CreateRecipeSchema.safeParse({
|
||||||
|
name: 'Grilled Chicken',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [validIngredient],
|
||||||
|
steps: [validStep],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts imperial units in ingredients', () => {
|
||||||
|
const result = CreateRecipeSchema.safeParse({
|
||||||
|
name: 'Test Recipe',
|
||||||
|
servings: 1,
|
||||||
|
ingredients: [{ ...validIngredient, quantity: 1, unit: 'cup' }],
|
||||||
|
steps: [validStep],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty ingredients', () => {
|
||||||
|
const result = CreateRecipeSchema.safeParse({
|
||||||
|
name: 'Empty',
|
||||||
|
servings: 2,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [validStep],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects servings < 1', () => {
|
||||||
|
const result = CreateRecipeSchema.safeParse({
|
||||||
|
name: 'Bad',
|
||||||
|
servings: 0,
|
||||||
|
ingredients: [validIngredient],
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults isFavorite to false', () => {
|
||||||
|
const result = CreateRecipeSchema.safeParse({
|
||||||
|
name: 'Test',
|
||||||
|
servings: 1,
|
||||||
|
ingredients: [validIngredient],
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.isFavorite).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(ScaleRecipeSchema.name, () => {
|
||||||
|
it('accepts valid target servings', () => {
|
||||||
|
expect(ScaleRecipeSchema.safeParse({ targetServings: 4 }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero servings', () => {
|
||||||
|
expect(ScaleRecipeSchema.safeParse({ targetServings: 0 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(RecipeQuerySchema.name, () => {
|
||||||
|
it('defaults limit to 20', () => {
|
||||||
|
const result = RecipeQuerySchema.safeParse({});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps limit to 100', () => {
|
||||||
|
const result = RecipeQuerySchema.safeParse({ limit: '200' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/shared/src/validation/recipe.schemas.ts
Normal file
124
packages/shared/src/validation/recipe.schemas.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
import { NutritionWarning } from '../enums/recipe.enums.js';
|
||||||
|
import { NutritionInfoSchema } from './product.schemas.js';
|
||||||
|
|
||||||
|
const RECIPE_UNIT = ['g', 'ml', 'piece', 'slice'] as const;
|
||||||
|
const IMPERIAL_UNIT = ['oz', 'lb', 'cup', 'tbsp', 'tsp', 'fl_oz'] as const;
|
||||||
|
|
||||||
|
const RecipeUnitSchema = z.enum(RECIPE_UNIT);
|
||||||
|
const ImperialOrRecipeUnitSchema = z.enum([...RECIPE_UNIT, ...IMPERIAL_UNIT]);
|
||||||
|
|
||||||
|
const RecipeIngredientInputSchema = z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
productName: z.string().min(1).max(200),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
unit: ImperialOrRecipeUnitSchema,
|
||||||
|
preparation: z.string().max(100).trim().optional(),
|
||||||
|
isOptional: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecipeStepSchema = z.object({
|
||||||
|
order: z.number().int().min(1),
|
||||||
|
instruction: z.string().min(1).max(2000).trim(),
|
||||||
|
duration: z.number().int().min(1).optional(),
|
||||||
|
tip: z.string().max(500).trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecipeSourceSchema = z.object({
|
||||||
|
type: z.enum(['manual', 'url', 'llm_import', 'text_import']),
|
||||||
|
url: z.url().optional(),
|
||||||
|
importedAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateRecipeSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).trim(),
|
||||||
|
description: z.string().max(2000).trim().optional(),
|
||||||
|
servings: z.number().int().min(1).max(500),
|
||||||
|
prepTime: z.number().int().min(0).optional(),
|
||||||
|
cookTime: z.number().int().min(0).optional(),
|
||||||
|
totalTime: z.number().int().min(0).optional(),
|
||||||
|
ingredients: z.array(RecipeIngredientInputSchema).min(1).max(100),
|
||||||
|
steps: z.array(RecipeStepSchema).max(100),
|
||||||
|
tags: z.array(z.string().min(1).max(50).trim()).max(30).default([]),
|
||||||
|
cuisine: z.string().max(100).trim().optional(),
|
||||||
|
imageUrl: z.url().optional(),
|
||||||
|
source: RecipeSourceSchema.optional(),
|
||||||
|
isFavorite: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateRecipeSchema = CreateRecipeSchema.partial();
|
||||||
|
|
||||||
|
export const ScaleRecipeSchema = z.object({
|
||||||
|
targetServings: z.number().int().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ImportRecipeTextSchema = z.object({
|
||||||
|
text: z.string().min(1).max(50000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ImportRecipeUrlSchema = z.object({
|
||||||
|
url: z.url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecipeQuerySchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
cuisine: z.string().optional(),
|
||||||
|
maxCalories: z.coerce.number().int().min(0).optional(),
|
||||||
|
isFavorite: z.coerce.boolean().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const StoredRecipeIngredientSchema = z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: RecipeUnitSchema,
|
||||||
|
originalQuantity: z.number().optional(),
|
||||||
|
originalUnit: ImperialOrRecipeUnitSchema.optional(),
|
||||||
|
preparation: z.string().optional(),
|
||||||
|
isOptional: z.boolean(),
|
||||||
|
nutritionContribution: NutritionInfoSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecipeResponseSchema = z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
householdId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
servings: z.number(),
|
||||||
|
prepTime: z.number().optional(),
|
||||||
|
cookTime: z.number().optional(),
|
||||||
|
totalTime: z.number().optional(),
|
||||||
|
ingredients: z.array(StoredRecipeIngredientSchema),
|
||||||
|
steps: z.array(RecipeStepSchema),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
cuisine: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
source: RecipeSourceSchema.optional(),
|
||||||
|
totalNutrition: NutritionInfoSchema,
|
||||||
|
perServingNutrition: NutritionInfoSchema,
|
||||||
|
warnings: z.array(z.nativeEnum(NutritionWarning)),
|
||||||
|
isFavorite: z.boolean(),
|
||||||
|
createdBy: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecipeListResponseSchema = z.object({
|
||||||
|
data: z.array(RecipeResponseSchema),
|
||||||
|
pagination: z.object({
|
||||||
|
cursor: z.string().nullable(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
total: z.number().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateRecipeInput = z.infer<typeof CreateRecipeSchema>;
|
||||||
|
export type UpdateRecipeInput = z.infer<typeof UpdateRecipeSchema>;
|
||||||
|
export type RecipeQueryInput = z.infer<typeof RecipeQuerySchema>;
|
||||||
|
export type ScaleRecipeInput = z.infer<typeof ScaleRecipeSchema>;
|
||||||
|
export type ImportRecipeTextInput = z.infer<typeof ImportRecipeTextSchema>;
|
||||||
|
export type ImportRecipeUrlInput = z.infer<typeof ImportRecipeUrlSchema>;
|
||||||
|
export type RecipeIngredientInput = z.infer<typeof RecipeIngredientInputSchema>;
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
all: true,
|
||||||
include: ['src/**/*.ts'],
|
include: ['src/**/*.ts'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'src/**/*.test.ts',
|
'src/**/*.test.ts',
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||||
|
const { mockUseSWR } = vi.hoisted(() => ({ mockUseSWR: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
|
vi.mock('swr', () => ({ default: mockUseSWR }));
|
||||||
|
|
||||||
|
vi.mock('@/services/cabinet', () => ({
|
||||||
|
getCabinetSummary: vi.fn(),
|
||||||
|
listCabinetItems: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/purchases', () => ({
|
||||||
|
listPurchases: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/refills', () => ({
|
||||||
|
getRefillAlerts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/cabinet-events', () => ({
|
||||||
|
listCabinetEvents: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
|
@ -14,6 +34,7 @@ import DashboardPage from '../page';
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
||||||
|
mockUseSWR.mockReturnValue({ data: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(DashboardPage.name, () => {
|
describe(DashboardPage.name, () => {
|
||||||
|
|
@ -27,13 +48,274 @@ describe(DashboardPage.name, () => {
|
||||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders page when household loaded', () => {
|
it('renders greeting with user name', () => {
|
||||||
mockUseApi.mockReturnValue({
|
mockUseApi.mockReturnValue({
|
||||||
householdId: 'hh1',
|
householdId: 'hh1',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
profile: { displayName: 'Alice' },
|
profile: { displayName: 'John Doe' },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
||||||
|
expect(screen.getByText(/John/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders generic greeting without profile', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, profile: null });
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/there/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cabinet items', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryData = { data: [{ _id: '1' }, { _id: '2' }] };
|
||||||
|
const cabinetData = {
|
||||||
|
data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: summaryData };
|
||||||
|
if (key.includes('cabinet-items')) return { data: cabinetData };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('50 tablets')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty states when no data', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No pending orders.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows refill alerts with days left', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const refillData = {
|
||||||
|
data: [{ medicineId: 'm1', medicineName: 'Vitamin C', daysUntilEmpty: 5 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: refillData };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Vitamin C')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('5d')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/critically low/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pending purchases', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const purchaseData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'p1',
|
||||||
|
storeName: 'Pharmacy Plus',
|
||||||
|
items: [{ name: 'A' }, { name: 'B' }],
|
||||||
|
status: 'ordered',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: purchaseData };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pharmacy Plus')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2 items')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ordered')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows recent events', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
_id: 'e1',
|
||||||
|
eventType: 'consumed',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
createdAt: '2026-01-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: eventData };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('consumed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "good shape" message when no critical alerts', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Your cabinet is in good shape.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stat badges with summary data', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary'))
|
||||||
|
return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts'))
|
||||||
|
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total medicines')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Running low')).toHaveLength(2);
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles store without name', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const purchaseData = {
|
||||||
|
data: [{ _id: 'p1', storeName: undefined, items: [{ name: 'A' }], status: 'ordered' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: purchaseData };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unknown store')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cabinet item with no name', () => {
|
||||||
|
mockUseApi.mockReturnValue({
|
||||||
|
householdId: 'hh1',
|
||||||
|
isLoading: false,
|
||||||
|
profile: { displayName: 'Jane' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cabinetData = {
|
||||||
|
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUseSWR.mockImplementation((key: string) => {
|
||||||
|
if (!key) return { data: undefined };
|
||||||
|
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-items')) return { data: cabinetData };
|
||||||
|
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||||
|
if (key.includes('purchases')) return { data: { data: [] } };
|
||||||
|
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||||
|
return { data: undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListRegimens } = vi.hoisted(() => ({
|
||||||
|
mockListRegimens: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/regimens', () => ({
|
||||||
|
listRegimens: mockListRegimens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SchedulePage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const makeRegimen = (overrides = {}) => ({
|
||||||
|
_id: 'reg1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Daily Vitamins',
|
||||||
|
isActive: true,
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
medicineId: 'm1',
|
||||||
|
medicineName: 'Vitamin D',
|
||||||
|
medicineStrength: '1000',
|
||||||
|
medicineStrengthUnit: 'IU',
|
||||||
|
dosage: 1,
|
||||||
|
dosageUnit: 'tablet',
|
||||||
|
frequency: 'daily',
|
||||||
|
timeOfDay: 'morning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SchedulePage', () => {
|
||||||
|
it('shows loading skeleton when session is loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no household message', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no regimens', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No active regimens found.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows set up link in empty state', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Set up a regimen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medication in morning slot', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [makeRegimen()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Morning')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1000 IU')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Daily Vitamins')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows frequency label', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [makeRegimen()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Once daily/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows custom frequency', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeRegimen({
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
medicineId: 'm1',
|
||||||
|
medicineName: 'Custom Med',
|
||||||
|
medicineStrength: '50',
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
dosage: 1,
|
||||||
|
dosageUnit: 'tablet',
|
||||||
|
frequency: 'custom',
|
||||||
|
customFrequencyPerDay: 4,
|
||||||
|
timeOfDay: 'morning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/4x daily/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows instructions when present', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeRegimen({
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
medicineId: 'm1',
|
||||||
|
medicineName: 'Med A',
|
||||||
|
medicineStrength: '10',
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
dosage: 1,
|
||||||
|
dosageUnit: 'pill',
|
||||||
|
frequency: 'daily',
|
||||||
|
timeOfDay: 'evening',
|
||||||
|
instructions: 'Take with food',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Take with food')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Evening')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dose count', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [makeRegimen()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 dose')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when fetch fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error for non-Error failures', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockRejectedValue('unexpected');
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to load regimens')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups into "any" slot when timeOfDay is missing', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
makeRegimen({
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
medicineId: 'm2',
|
||||||
|
medicineName: 'Aspirin',
|
||||||
|
medicineStrength: '100',
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
dosage: 1,
|
||||||
|
dosageUnit: 'tablet',
|
||||||
|
frequency: 'as_needed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Any time')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates through regimens', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [makeRegimen()],
|
||||||
|
pagination: { cursor: 'next', hasMore: true },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [
|
||||||
|
makeRegimen({
|
||||||
|
_id: 'reg2',
|
||||||
|
name: 'Second Regimen',
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
medicineId: 'm2',
|
||||||
|
medicineName: 'Iron',
|
||||||
|
medicineStrength: '65',
|
||||||
|
medicineStrengthUnit: 'mg',
|
||||||
|
dosage: 1,
|
||||||
|
dosageUnit: 'tablet',
|
||||||
|
frequency: 'daily',
|
||||||
|
timeOfDay: 'afternoon',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Iron')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockListRegimens).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows regimen and dose summary', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRegimens.mockResolvedValue({
|
||||||
|
data: [makeRegimen()],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SchedulePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/1 active regimen/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/1 dose per day/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
339
packages/web/src/app/(dashboard)/pantry/PantryList.tsx
Normal file
339
packages/web/src/app/(dashboard)/pantry/PantryList.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry';
|
||||||
|
import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { PantryItemResponseSchema } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type PantryItem = z.infer<typeof PantryItemResponseSchema>;
|
||||||
|
|
||||||
|
const STORAGE_TABS = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: StorageLocation.FRIDGE, label: 'Fridge' },
|
||||||
|
{ value: StorageLocation.FREEZER, label: 'Freezer' },
|
||||||
|
{ value: StorageLocation.PANTRY, label: 'Pantry' },
|
||||||
|
{ value: StorageLocation.COUNTER, label: 'Counter' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const URGENCY_COLORS: Record<string, { bg: string; color: string; label: string }> = {
|
||||||
|
[FreshnessUrgency.FRESH]: {
|
||||||
|
bg: 'var(--success-soft, #d4edda)',
|
||||||
|
color: 'var(--success, #28a745)',
|
||||||
|
label: 'Fresh',
|
||||||
|
},
|
||||||
|
[FreshnessUrgency.USE_SOON]: {
|
||||||
|
bg: 'var(--warning-soft, #fff3cd)',
|
||||||
|
color: 'var(--warning, #856404)',
|
||||||
|
label: 'Use soon',
|
||||||
|
},
|
||||||
|
[FreshnessUrgency.URGENT]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Urgent' },
|
||||||
|
[FreshnessUrgency.CHECK]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Check' },
|
||||||
|
[FreshnessUrgency.EXPIRED]: {
|
||||||
|
bg: 'var(--danger-soft)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
label: 'Expired',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
[ItemStatus.SEALED]: 'Sealed',
|
||||||
|
[ItemStatus.OPENED]: 'Opened',
|
||||||
|
[ItemStatus.PREPARED]: 'Prepared',
|
||||||
|
[ItemStatus.CONSUMED]: 'Consumed',
|
||||||
|
[ItemStatus.DISCARDED]: 'Discarded',
|
||||||
|
[ItemStatus.EXPIRED]: 'Expired',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
[ItemStatus.SEALED]: [ItemStatus.OPENED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
[ItemStatus.OPENED]: [ItemStatus.PREPARED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
[ItemStatus.PREPARED]: [ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITION_LABELS: Record<string, string> = {
|
||||||
|
[ItemStatus.OPENED]: 'Open',
|
||||||
|
[ItemStatus.PREPARED]: 'Prepare',
|
||||||
|
[ItemStatus.CONSUMED]: 'Consume',
|
||||||
|
[ItemStatus.DISCARDED]: 'Discard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PantryList({ householdId }: { householdId: string }) {
|
||||||
|
const [items, setItems] = useState<PantryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [storageFilter, setStorageFilter] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
if (!householdId) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await listPantryItems(householdId, {
|
||||||
|
storageLocation: storageFilter || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
setItems(result.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [householdId, storageFilter, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
async function handleTransition(id: string, status: string) {
|
||||||
|
try {
|
||||||
|
const updated = await transitionPantryItem(householdId, id, { status } as never);
|
||||||
|
setItems((prev) => prev.map((item) => (item._id === id ? updated : item)));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Transition failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deletePantryItem(householdId, id);
|
||||||
|
setItems((prev) => prev.filter((item) => item._id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||||
|
{/* Storage tabs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
paddingBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STORAGE_TABS.map((tab) => {
|
||||||
|
const isActive = storageFilter === tab.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => setStorageFilter(tab.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
color: isActive ? 'var(--brand)' : 'var(--ink-muted)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: isActive ? '2px solid var(--brand)' : '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 24, alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="sealed">Sealed</option>
|
||||||
|
<option value="opened">Opened</option>
|
||||||
|
<option value="prepared">Prepared</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 140,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '64px 24px',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{storageFilter || statusFilter
|
||||||
|
? 'No items match your filters.'
|
||||||
|
: 'No pantry items yet. Add your first item.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<PantryCard
|
||||||
|
key={item._id}
|
||||||
|
item={item}
|
||||||
|
onTransition={handleTransition}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PantryCard({
|
||||||
|
item,
|
||||||
|
onTransition,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: PantryItem;
|
||||||
|
onTransition: (id: string, status: string) => void;
|
||||||
|
onDelete: (id: string, name: string) => void;
|
||||||
|
}) {
|
||||||
|
const urgency =
|
||||||
|
URGENCY_COLORS[item.freshnessEstimate.urgency] ?? URGENCY_COLORS[FreshnessUrgency.FRESH];
|
||||||
|
const transitions = VALID_TRANSITIONS[item.status] ?? [];
|
||||||
|
const daysText =
|
||||||
|
item.freshnessEstimate.daysRemaining >= 0
|
||||||
|
? `${item.freshnessEstimate.daysRemaining}d left`
|
||||||
|
: `${Math.abs(item.freshnessEstimate.daysRemaining)}d overdue`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--ink)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.productName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>
|
||||||
|
{item.quantity} {item.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: urgency.bg,
|
||||||
|
color: urgency.color,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{urgency.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', gap: 12 }}>
|
||||||
|
<span>{STATUS_LABELS[item.status] ?? item.status}</span>
|
||||||
|
<span>{daysText}</span>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>{item.storageLocation}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{transitions.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||||
|
{transitions.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => onTransition(item._id, status)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: status === ItemStatus.CONSUMED ? 'var(--brand-soft)' : 'var(--bg)',
|
||||||
|
color: status === ItemStatus.CONSUMED ? 'var(--brand)' : 'var(--ink-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: status === ItemStatus.CONSUMED ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TRANSITION_LABELS[status] ?? status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item._id, item.productName)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
packages/web/src/app/(dashboard)/pantry/__tests__/page.test.tsx
Normal file
317
packages/web/src/app/(dashboard)/pantry/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListPantryItems, mockTransitionPantryItem, mockDeletePantryItem } = vi.hoisted(() => ({
|
||||||
|
mockListPantryItems: vi.fn(),
|
||||||
|
mockTransitionPantryItem: vi.fn(),
|
||||||
|
mockDeletePantryItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/pantry', () => ({
|
||||||
|
listPantryItems: mockListPantryItems,
|
||||||
|
transitionPantryItem: mockTransitionPantryItem,
|
||||||
|
deletePantryItem: mockDeletePantryItem,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { default: (props: any) => props.children };
|
||||||
|
});
|
||||||
|
|
||||||
|
import PantryPage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const SAMPLE_ITEM = {
|
||||||
|
_id: 'pi-1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Whole Milk',
|
||||||
|
storageLocation: 'fridge',
|
||||||
|
quantity: 1,
|
||||||
|
unit: 'liter',
|
||||||
|
purchaseDate: '2026-05-01T00:00:00.000Z',
|
||||||
|
status: 'sealed',
|
||||||
|
freshnessEstimate: {
|
||||||
|
estimatedExpiryDate: '2026-05-15T00:00:00.000Z',
|
||||||
|
daysRemaining: 11,
|
||||||
|
urgency: 'fresh',
|
||||||
|
source: 'rule',
|
||||||
|
},
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PantryPage', () => {
|
||||||
|
it('shows loading skeleton when session is loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows household prompt when no householdId', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pantry items when household exists', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no items', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows freshness badge', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('11d left')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overdue text for negative days', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
...SAMPLE_ITEM,
|
||||||
|
_id: 'pi-2',
|
||||||
|
freshnessEstimate: {
|
||||||
|
...SAMPLE_ITEM.freshnessEstimate,
|
||||||
|
daysRemaining: -2,
|
||||||
|
urgency: 'expired',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('2d overdue')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows transition buttons for active items', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Consume')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Discard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles transition click', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockTransitionPantryItem.mockResolvedValue({
|
||||||
|
...SAMPLE_ITEM,
|
||||||
|
status: 'opened',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Open'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTransitionPantryItem).toHaveBeenCalledWith('hh1', 'pi-1', { status: 'opened' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles delete with confirmation', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeletePantryItem.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeletePantryItem).toHaveBeenCalledWith('hh1', 'pi-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete when confirm is cancelled', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeletePantryItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by storage tab', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Fridge'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ storageLocation: 'fridge' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status select', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'opened' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ status: 'opened' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when fetch fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when transition fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [SAMPLE_ITEM],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockTransitionPantryItem.mockRejectedValue(new Error('Transition failed'));
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Open'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Transition failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows filter empty state message', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListPantryItems.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PantryPage />);
|
||||||
|
|
||||||
|
// Click a filter tab to trigger filter state
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Fridge'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No items match your filters/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
packages/web/src/app/(dashboard)/pantry/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/pantry/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { PantryList } from './PantryList';
|
||||||
|
|
||||||
|
function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
marginBottom: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoHousehold() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to create or join a household before managing your pantry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PantryPage() {
|
||||||
|
const { householdId, isLoading } = useApi();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||||
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||||
|
<NoHousehold />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||||
|
<PantryList householdId={householdId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
packages/web/src/app/(dashboard)/products/ImportDialog.tsx
Normal file
235
packages/web/src/app/(dashboard)/products/ImportDialog.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { importProducts } from '@/services/products';
|
||||||
|
|
||||||
|
export interface ImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
householdId: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
skippedDuplicates: number;
|
||||||
|
errors: { row: number; message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportDialog({ open, onClose, householdId, onSuccess }: ImportDialogProps) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = e.target.files?.[0] ?? null;
|
||||||
|
setFile(selected);
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!file) {
|
||||||
|
setError('Please select a file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await importProducts(householdId, file);
|
||||||
|
setResult(res);
|
||||||
|
if (res.imported > 0) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setFile(null);
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-base, #fff)',
|
||||||
|
borderRadius: 'var(--r-lg, 12px)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 480,
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
|
||||||
|
Import Products
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14, margin: '0 0 8px', color: 'var(--ink)' }}>
|
||||||
|
Import complete
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
|
||||||
|
Imported: <strong>{result.imported}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
|
||||||
|
Skipped (duplicates): <strong>{result.skippedDuplicates}</strong>
|
||||||
|
</p>
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--danger)', margin: 0 }}>
|
||||||
|
Errors ({result.errors.length}):
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '4px 0 0',
|
||||||
|
paddingLeft: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
maxHeight: 120,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.errors.map((err, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
Row {err.row}: {err.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
color: file ? 'var(--ink)' : 'var(--ink-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file ? file.name : 'Choose .csv or .json file...'}
|
||||||
|
</button>
|
||||||
|
{file && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: '4px 0 0' }}>
|
||||||
|
Size: {(file.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || uploading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: !file || uploading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: !file || uploading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
packages/web/src/app/(dashboard)/products/ProductList.tsx
Normal file
349
packages/web/src/app/(dashboard)/products/ProductList.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listProducts, deleteProduct, createProduct, updateProduct } from '@/services/products';
|
||||||
|
import { ProductCategory, type ServingUnit } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { ProductResponseSchema, CreateProductInput } from '@meshitrack/shared';
|
||||||
|
import { ProductModal } from './ProductModal';
|
||||||
|
import { ImportDialog } from './ImportDialog';
|
||||||
|
|
||||||
|
type Product = z.infer<typeof ProductResponseSchema>;
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = Object.values(ProductCategory);
|
||||||
|
|
||||||
|
const SERVING_UNIT_LABELS: Record<string, string> = {
|
||||||
|
g: 'g',
|
||||||
|
ml: 'ml',
|
||||||
|
piece: 'pc',
|
||||||
|
slice: 'sl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductList({ householdId }: { householdId: string }) {
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
// Debounce search input
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const fetchProducts = useCallback(async () => {
|
||||||
|
if (!householdId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await listProducts(householdId, {
|
||||||
|
q: debouncedSearch || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
setProducts(result.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [householdId, debouncedSearch, category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts();
|
||||||
|
}, [fetchProducts]);
|
||||||
|
|
||||||
|
async function handleDelete(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteProduct(householdId, id);
|
||||||
|
setProducts((prev) => prev.filter((p) => p._id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(data: CreateProductInput) {
|
||||||
|
await createProduct(householdId, data);
|
||||||
|
await fetchProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(data: CreateProductInput) {
|
||||||
|
if (!editingProduct) return;
|
||||||
|
await updateProduct(householdId, editingProduct._id, data);
|
||||||
|
await fetchProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||||
|
{/* Filters */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
minWidth: 240,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{CATEGORY_OPTIONS.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImportDialogOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Product
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 120,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '64px 24px',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>No products yet. Add your first product to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product._id}
|
||||||
|
product={product}
|
||||||
|
onDelete={() => handleDelete(product._id, product.name)}
|
||||||
|
onEdit={() => setEditingProduct(product)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductModal
|
||||||
|
open={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSave={handleCreate}
|
||||||
|
householdId={householdId}
|
||||||
|
title="Add Product"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProductModal
|
||||||
|
open={editingProduct !== null}
|
||||||
|
onClose={() => setEditingProduct(null)}
|
||||||
|
onSave={handleEdit}
|
||||||
|
householdId={householdId}
|
||||||
|
initial={
|
||||||
|
editingProduct
|
||||||
|
? {
|
||||||
|
name: editingProduct.name,
|
||||||
|
brand: editingProduct.brand,
|
||||||
|
barcode: editingProduct.barcode,
|
||||||
|
category: editingProduct.category as ProductCategory,
|
||||||
|
servingSize: editingProduct.servingSize,
|
||||||
|
servingUnit: editingProduct.servingUnit as ServingUnit,
|
||||||
|
densityGPerMl: editingProduct.densityGPerMl,
|
||||||
|
nutrition: editingProduct.nutrition,
|
||||||
|
tags: editingProduct.tags,
|
||||||
|
imageUrl: editingProduct.imageUrl,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title="Edit Product"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onClose={() => setImportDialogOpen(false)}
|
||||||
|
householdId={householdId}
|
||||||
|
onSuccess={fetchProducts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductCard({
|
||||||
|
product,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
product: Product;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, fontSize: 14, margin: 0, color: 'var(--ink)' }}>
|
||||||
|
{product.name}
|
||||||
|
</p>
|
||||||
|
{product.brand && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: 0 }}>{product.brand}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--brand-soft, #e8f0fe)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.category.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
|
||||||
|
<span>{product.nutrition.calories} kcal</span>
|
||||||
|
<span>
|
||||||
|
{product.servingSize}
|
||||||
|
{SERVING_UNIT_LABELS[product.servingUnit] ?? product.servingUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--ink-dim)' }}>
|
||||||
|
<span>P: {product.nutrition.protein}g</span>
|
||||||
|
<span>C: {product.nutrition.carbs}g</span>
|
||||||
|
<span>F: {product.nutrition.fat}g</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label="Edit product"
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label="Delete product"
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
533
packages/web/src/app/(dashboard)/products/ProductModal.tsx
Normal file
533
packages/web/src/app/(dashboard)/products/ProductModal.tsx
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||||
|
import type { CreateProductInput } from '@meshitrack/shared';
|
||||||
|
import { lookupBarcode } from '@/services/products';
|
||||||
|
|
||||||
|
export interface ProductModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: CreateProductInput) => Promise<void>;
|
||||||
|
householdId: string;
|
||||||
|
initial?: Partial<CreateProductInput>;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = Object.values(ProductCategory);
|
||||||
|
const SERVING_UNIT_OPTIONS = Object.values(ServingUnit);
|
||||||
|
|
||||||
|
const SERVING_UNIT_LABELS: Record<string, string> = {
|
||||||
|
g: 'Grams (g)',
|
||||||
|
ml: 'Milliliters (ml)',
|
||||||
|
piece: 'Piece',
|
||||||
|
slice: 'Slice',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
householdId,
|
||||||
|
initial,
|
||||||
|
title,
|
||||||
|
}: ProductModalProps) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [lookingUp, setLookingUp] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [brand, setBrand] = useState('');
|
||||||
|
const [barcode, setBarcode] = useState('');
|
||||||
|
const [category, setCategory] = useState<ProductCategory>(ProductCategory.OTHER);
|
||||||
|
const [servingSize, setServingSize] = useState<number | ''>('');
|
||||||
|
const [servingUnit, setServingUnit] = useState<ServingUnit>(ServingUnit.GRAMS);
|
||||||
|
const [densityGPerMl, setDensityGPerMl] = useState<number | ''>('');
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
|
|
||||||
|
// Nutrition
|
||||||
|
const [calories, setCalories] = useState<number | ''>('');
|
||||||
|
const [protein, setProtein] = useState<number | ''>('');
|
||||||
|
const [carbs, setCarbs] = useState<number | ''>('');
|
||||||
|
const [fat, setFat] = useState<number | ''>('');
|
||||||
|
const [fiber, setFiber] = useState<number | ''>('');
|
||||||
|
const [sugar, setSugar] = useState<number | ''>('');
|
||||||
|
const [sodium, setSodium] = useState<number | ''>('');
|
||||||
|
const [saturatedFat, setSaturatedFat] = useState<number | ''>('');
|
||||||
|
const [cholesterol, setCholesterol] = useState<number | ''>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(initial?.name ?? '');
|
||||||
|
setBrand(initial?.brand ?? '');
|
||||||
|
setBarcode(initial?.barcode ?? '');
|
||||||
|
setCategory(initial?.category ?? ProductCategory.OTHER);
|
||||||
|
setServingSize(initial?.servingSize ?? '');
|
||||||
|
setServingUnit(initial?.servingUnit ?? ServingUnit.GRAMS);
|
||||||
|
setDensityGPerMl(initial?.densityGPerMl ?? '');
|
||||||
|
setTags(initial?.tags?.join(', ') ?? '');
|
||||||
|
setImageUrl(initial?.imageUrl ?? '');
|
||||||
|
setCalories(initial?.nutrition?.calories ?? '');
|
||||||
|
setProtein(initial?.nutrition?.protein ?? '');
|
||||||
|
setCarbs(initial?.nutrition?.carbs ?? '');
|
||||||
|
setFat(initial?.nutrition?.fat ?? '');
|
||||||
|
setFiber(initial?.nutrition?.fiber ?? '');
|
||||||
|
setSugar(initial?.nutrition?.sugar ?? '');
|
||||||
|
setSodium(initial?.nutrition?.sodium ?? '');
|
||||||
|
setSaturatedFat(initial?.nutrition?.saturatedFat ?? '');
|
||||||
|
setCholesterol(initial?.nutrition?.cholesterol ?? '');
|
||||||
|
setError('');
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (servingSize === '' || servingSize <= 0) {
|
||||||
|
setError('Serving size must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (calories === '' || protein === '' || carbs === '' || fat === '') {
|
||||||
|
setError('Calories, protein, carbs, and fat are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: CreateProductInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
category,
|
||||||
|
servingSize: Number(servingSize),
|
||||||
|
servingUnit,
|
||||||
|
nutrition: {
|
||||||
|
calories: Number(calories),
|
||||||
|
protein: Number(protein),
|
||||||
|
carbs: Number(carbs),
|
||||||
|
fat: Number(fat),
|
||||||
|
...(fiber !== '' && { fiber: Number(fiber) }),
|
||||||
|
...(sugar !== '' && { sugar: Number(sugar) }),
|
||||||
|
...(sodium !== '' && { sodium: Number(sodium) }),
|
||||||
|
...(saturatedFat !== '' && { saturatedFat: Number(saturatedFat) }),
|
||||||
|
...(cholesterol !== '' && { cholesterol: Number(cholesterol) }),
|
||||||
|
},
|
||||||
|
tags: tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
source: ProductSource.MANUAL,
|
||||||
|
...(brand.trim() && { brand: brand.trim() }),
|
||||||
|
...(barcode.trim() && { barcode: barcode.trim() }),
|
||||||
|
...(densityGPerMl !== '' && { densityGPerMl: Number(densityGPerMl) }),
|
||||||
|
...(imageUrl.trim() && { imageUrl: imageUrl.trim() }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(data);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-base, #fff)',
|
||||||
|
borderRadius: 'var(--r-lg, 12px)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 560,
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
|
||||||
|
{title ?? (initial ? 'Edit Product' : 'Add Product')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* Basic info */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Name *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Product name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Brand</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={brand}
|
||||||
|
onChange={(e) => setBrand(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Barcode</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
value={barcode}
|
||||||
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
placeholder="8-14 digits"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={lookingUp || !barcode.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!barcode.trim()) return;
|
||||||
|
setLookingUp(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await lookupBarcode(householdId, barcode.trim());
|
||||||
|
if ('found' in result) {
|
||||||
|
setError('Product not found for this barcode');
|
||||||
|
} else {
|
||||||
|
setName(result.name ?? '');
|
||||||
|
setBrand(result.brand ?? '');
|
||||||
|
setCategory((result.category as ProductCategory) ?? ProductCategory.OTHER);
|
||||||
|
setServingSize(result.servingSize ?? '');
|
||||||
|
setServingUnit((result.servingUnit as ServingUnit) ?? ServingUnit.GRAMS);
|
||||||
|
setDensityGPerMl(result.densityGPerMl ?? '');
|
||||||
|
setTags(result.tags?.join(', ') ?? '');
|
||||||
|
setImageUrl(result.imageUrl ?? '');
|
||||||
|
setCalories(result.nutrition?.calories ?? '');
|
||||||
|
setProtein(result.nutrition?.protein ?? '');
|
||||||
|
setCarbs(result.nutrition?.carbs ?? '');
|
||||||
|
setFat(result.nutrition?.fat ?? '');
|
||||||
|
setFiber(result.nutrition?.fiber ?? '');
|
||||||
|
setSugar(result.nutrition?.sugar ?? '');
|
||||||
|
setSodium(result.nutrition?.sodium ?? '');
|
||||||
|
setSaturatedFat(result.nutrition?.saturatedFat ?? '');
|
||||||
|
setCholesterol(result.nutrition?.cholesterol ?? '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Barcode lookup failed');
|
||||||
|
} finally {
|
||||||
|
setLookingUp(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: lookingUp || !barcode.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: lookingUp || !barcode.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lookingUp ? 'Looking up...' : 'Lookup'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Category *</label>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as ProductCategory)}
|
||||||
|
>
|
||||||
|
{CATEGORY_OPTIONS.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Serving Size *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={servingSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServingSize(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
placeholder="e.g. 100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Serving Unit *</label>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={servingUnit}
|
||||||
|
onChange={(e) => setServingUnit(e.target.value as ServingUnit)}
|
||||||
|
>
|
||||||
|
{SERVING_UNIT_OPTIONS.map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{SERVING_UNIT_LABELS[unit] ?? unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Density (g/ml)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={densityGPerMl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDensityGPerMl(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Image URL</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="e.g. organic, gluten-free"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nutrition */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
paddingTop: 16,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)', margin: '0 0 12px' }}>
|
||||||
|
Nutrition (per serving)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Calories *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={calories}
|
||||||
|
onChange={(e) => setCalories(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Protein (g) *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={protein}
|
||||||
|
onChange={(e) => setProtein(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Carbs (g) *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={carbs}
|
||||||
|
onChange={(e) => setCarbs(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Fat (g) *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={fat}
|
||||||
|
onChange={(e) => setFat(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Fiber (g)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={fiber}
|
||||||
|
onChange={(e) => setFiber(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Sugar (g)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={sugar}
|
||||||
|
onChange={(e) => setSugar(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Sodium (mg)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={sodium}
|
||||||
|
onChange={(e) => setSodium(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Sat. Fat (g)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={saturatedFat}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSaturatedFat(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Cholesterol (mg)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
value={cholesterol}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCholesterol(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: saving ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockImportProducts } = vi.hoisted(() => ({
|
||||||
|
mockImportProducts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/products', () => ({
|
||||||
|
importProducts: mockImportProducts,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ImportDialog } from '../ImportDialog';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
householdId: 'hh1',
|
||||||
|
onSuccess: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ImportDialog', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ImportDialog {...defaultProps} open={false} />);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Import Products title when open', () => {
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Import Products')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows file chooser button', () => {
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when upload clicked without file', async () => {
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
expect(screen.getByText('Please select a file')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows file name after selection', () => {
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['name,category\nTest,other'], 'products.csv', {
|
||||||
|
type: 'text/csv',
|
||||||
|
});
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
|
expect(screen.getByText('products.csv')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads file and shows success result', async () => {
|
||||||
|
mockImportProducts.mockResolvedValue({
|
||||||
|
imported: 5,
|
||||||
|
skippedDuplicates: 1,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
expect(defaultProps.onSuccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows errors in result', async () => {
|
||||||
|
mockImportProducts.mockResolvedValue({
|
||||||
|
imported: 0,
|
||||||
|
skippedDuplicates: 0,
|
||||||
|
errors: [{ row: 2, message: 'Missing name' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/Row 2: Missing name/)).toBeInTheDocument();
|
||||||
|
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error on import failure', async () => {
|
||||||
|
mockImportProducts.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error on non-Error rejection', async () => {
|
||||||
|
mockImportProducts.mockRejectedValue('unknown');
|
||||||
|
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Import failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dialog via Cancel button', () => {
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Cancel'));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dialog via Close button after result', async () => {
|
||||||
|
mockImportProducts.mockResolvedValue({
|
||||||
|
imported: 1,
|
||||||
|
skippedDuplicates: 0,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ImportDialog {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
fireEvent.click(screen.getByText('Upload'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Close'));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockLookupBarcode } = vi.hoisted(() => ({
|
||||||
|
mockLookupBarcode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/products', () => ({
|
||||||
|
lookupBarcode: mockLookupBarcode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ProductModal } from '../ProductModal';
|
||||||
|
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn().mockResolvedValue(undefined),
|
||||||
|
householdId: 'hh1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProductModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ProductModal {...defaultProps} open={false} />);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Add Product title by default', () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Add Product')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom title when provided', () => {
|
||||||
|
render(<ProductModal {...defaultProps} title="Custom Title" />);
|
||||||
|
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Edit Product title when initial is provided', () => {
|
||||||
|
render(<ProductModal {...defaultProps} initial={{ name: 'Test' }} />);
|
||||||
|
expect(screen.getByText('Edit Product')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills form fields from initial', () => {
|
||||||
|
render(
|
||||||
|
<ProductModal
|
||||||
|
{...defaultProps}
|
||||||
|
initial={{
|
||||||
|
name: 'Chicken',
|
||||||
|
brand: 'Tyson',
|
||||||
|
barcode: '12345678',
|
||||||
|
category: ProductCategory.MEAT,
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: ServingUnit.GRAMS,
|
||||||
|
tags: ['organic', 'protein'],
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByDisplayValue('Chicken')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Tyson')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('12345678')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('organic, protein')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('165')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when name is empty and form submitted', async () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||||
|
expect(defaultProps.onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when serving size is empty', async () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
expect(screen.getByText('Serving size must be a positive number')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when nutrition fields are missing', async () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||||
|
target: { value: '100' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
expect(
|
||||||
|
screen.getByText('Calories, protein, carbs, and fat are required'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSave with correct data on valid submit', async () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Chicken Breast' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||||
|
target: { value: '100' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill nutrition
|
||||||
|
const numberInputs = screen.getAllByRole('spinbutton');
|
||||||
|
// serving size is index 0, calories=1, protein=2, carbs=3, fat=4
|
||||||
|
fireEvent.change(numberInputs[1]!, { target: { value: '165' } });
|
||||||
|
fireEvent.change(numberInputs[2]!, { target: { value: '31' } });
|
||||||
|
fireEvent.change(numberInputs[3]!, { target: { value: '0' } });
|
||||||
|
fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
servingSize: 100,
|
||||||
|
nutrition: expect.objectContaining({
|
||||||
|
calories: 165,
|
||||||
|
protein: 31,
|
||||||
|
carbs: 0,
|
||||||
|
fat: 3.6,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes modal after successful save', async () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||||
|
|
||||||
|
const numberInputs = screen.getAllByRole('spinbutton');
|
||||||
|
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||||
|
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||||
|
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||||
|
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when onSave rejects', async () => {
|
||||||
|
const saveFn = vi.fn().mockRejectedValue(new Error('Server error'));
|
||||||
|
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||||
|
|
||||||
|
const numberInputs = screen.getAllByRole('spinbutton');
|
||||||
|
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||||
|
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||||
|
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||||
|
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error when onSave throws non-Error', async () => {
|
||||||
|
const saveFn = vi.fn().mockRejectedValue('unknown');
|
||||||
|
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||||
|
|
||||||
|
const numberInputs = screen.getAllByRole('spinbutton');
|
||||||
|
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||||
|
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||||
|
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||||
|
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to save')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when Cancel is clicked', () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('Cancel'));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when backdrop is clicked', () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
// Click the backdrop (outermost div with onClick=onClose)
|
||||||
|
const backdrop = screen.getByText('Add Product').closest('div')!.parentElement!;
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Lookup button is disabled when barcode is empty', () => {
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
const lookupBtn = screen.getByText('Lookup');
|
||||||
|
expect(lookupBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('barcode lookup fills form on success', async () => {
|
||||||
|
mockLookupBarcode.mockResolvedValue({
|
||||||
|
_id: 'p1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Nutella',
|
||||||
|
brand: 'Ferrero',
|
||||||
|
category: 'snacks',
|
||||||
|
servingSize: 15,
|
||||||
|
servingUnit: 'g',
|
||||||
|
nutrition: { calories: 80, protein: 1, carbs: 8.5, fat: 4.7 },
|
||||||
|
tags: ['spread'],
|
||||||
|
source: 'barcode_lookup',
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||||
|
target: { value: '3017620422003' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Lookup'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('Nutella')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByDisplayValue('Ferrero')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('80')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('barcode lookup shows not found', async () => {
|
||||||
|
mockLookupBarcode.mockResolvedValue({ found: false });
|
||||||
|
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||||
|
target: { value: '0000000000000' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Lookup'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Product not found for this barcode')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('barcode lookup shows error on failure', async () => {
|
||||||
|
mockLookupBarcode.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<ProductModal {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||||
|
target: { value: '1234567890123' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Lookup'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Barcode lookup failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({
|
||||||
|
mockListProducts: vi.fn(),
|
||||||
|
mockDeleteProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/products', () => ({
|
||||||
|
listProducts: mockListProducts,
|
||||||
|
deleteProduct: mockDeleteProduct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { default: (props: any) => props.children };
|
||||||
|
});
|
||||||
|
|
||||||
|
import ProductsPage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const SAMPLE_PRODUCT = {
|
||||||
|
_id: 'p1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Chicken Breast',
|
||||||
|
brand: 'Tyson',
|
||||||
|
category: 'meat',
|
||||||
|
servingSize: 100,
|
||||||
|
servingUnit: 'g',
|
||||||
|
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||||
|
tags: [],
|
||||||
|
source: 'manual',
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProductsPage', () => {
|
||||||
|
it('shows loading skeleton when session is loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Product Library')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('All categories')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows household prompt when no householdId', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders products when household exists', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [SAMPLE_PRODUCT],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Tyson')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('165 kcal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no products', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No products yet/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message on fetch failure', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by search input with debounce', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search products...');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'chicken' } });
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(mockListProducts).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ q: 'chicken' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 500 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by category dropdown', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const categorySelect = screen.getByRole('combobox');
|
||||||
|
fireEvent.change(categorySelect, { target: { value: 'meat' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockListProducts).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ category: 'meat' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes product on confirm', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [SAMPLE_PRODUCT],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteProduct.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||||
|
fireEvent.click(deleteBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteProduct).toHaveBeenCalledWith('hh1', 'p1');
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('Chicken Breast')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete when confirm is cancelled', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [SAMPLE_PRODUCT],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||||
|
fireEvent.click(deleteBtn);
|
||||||
|
|
||||||
|
expect(mockDeleteProduct).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when delete fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [SAMPLE_PRODUCT],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteProduct.mockRejectedValue(new Error('Delete failed'));
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||||
|
fireEvent.click(deleteBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows macro breakdown in product card', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListProducts.mockResolvedValue({
|
||||||
|
data: [SAMPLE_PRODUCT],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ProductsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText('P: 31g')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('C: 0g')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
packages/web/src/app/(dashboard)/products/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/products/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { ProductList } from './ProductList';
|
||||||
|
|
||||||
|
function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
marginBottom: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoHousehold() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to create or join a household before managing your product library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const { householdId, isLoading } = useApi();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||||
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||||
|
<NoHousehold />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||||
|
<ProductList householdId={householdId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
589
packages/web/src/app/(dashboard)/recipes/RecipeEditor.tsx
Normal file
589
packages/web/src/app/(dashboard)/recipes/RecipeEditor.tsx
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createRecipe, updateRecipe } from '@/services/recipes';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { RecipeResponseSchema, CreateRecipeInput } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||||
|
type IngredientUnit = CreateRecipeInput['ingredients'][number]['unit'];
|
||||||
|
|
||||||
|
type IngredientInput = {
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: IngredientUnit;
|
||||||
|
preparation: string;
|
||||||
|
isOptional: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepInput = {
|
||||||
|
order: number;
|
||||||
|
instruction: string;
|
||||||
|
duration: string;
|
||||||
|
tip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[NutritionWarning.HIGH_CALORIES]: 'High calories',
|
||||||
|
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
|
||||||
|
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
|
||||||
|
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
|
||||||
|
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
|
||||||
|
[NutritionWarning.LOW_FIBER]: 'Low fiber',
|
||||||
|
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol',
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNIT_OPTIONS = ['g', 'ml', 'piece', 'slice', 'oz', 'lb', 'cup', 'tbsp', 'tsp', 'fl_oz'];
|
||||||
|
|
||||||
|
function emptyIngredient(): IngredientInput {
|
||||||
|
return {
|
||||||
|
productId: '',
|
||||||
|
productName: '',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'g',
|
||||||
|
preparation: '',
|
||||||
|
isOptional: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStep(order: number): StepInput {
|
||||||
|
return { order, instruction: '', duration: '', tip: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingredientFromRecipe(ing: Recipe['ingredients'][0]): IngredientInput {
|
||||||
|
return {
|
||||||
|
productId: ing.productId,
|
||||||
|
productName: ing.productName,
|
||||||
|
quantity: ing.originalQuantity ?? ing.quantity,
|
||||||
|
unit: ing.originalUnit ?? ing.unit,
|
||||||
|
preparation: ing.preparation ?? '',
|
||||||
|
isOptional: ing.isOptional,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepFromRecipe(step: Recipe['steps'][0]): StepInput {
|
||||||
|
return {
|
||||||
|
order: step.order,
|
||||||
|
instruction: step.instruction,
|
||||||
|
duration: step.duration ? String(step.duration) : '',
|
||||||
|
tip: step.tip ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function NutritionDisplay({
|
||||||
|
nutrition,
|
||||||
|
warnings,
|
||||||
|
}: {
|
||||||
|
nutrition: Recipe['perServingNutrition'] | null;
|
||||||
|
warnings: string[];
|
||||||
|
}) {
|
||||||
|
if (!nutrition) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Per serving (estimated)
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--ink-muted)' }}>Calories</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{Math.round(nutrition.calories)} kcal</span>
|
||||||
|
<span style={{ color: 'var(--ink-muted)' }}>Protein</span>
|
||||||
|
<span>{nutrition.protein.toFixed(1)}g</span>
|
||||||
|
<span style={{ color: 'var(--ink-muted)' }}>Carbs</span>
|
||||||
|
<span>{nutrition.carbs.toFixed(1)}g</span>
|
||||||
|
<span style={{ color: 'var(--ink-muted)' }}>Fat</span>
|
||||||
|
<span>{nutrition.fat.toFixed(1)}g</span>
|
||||||
|
</div>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
padding: '2px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WARNING_LABELS[w] ?? w}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeEditorProps {
|
||||||
|
householdId: string;
|
||||||
|
existing?: Recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipeEditor({ householdId, existing }: RecipeEditorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [name, setName] = useState(existing?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(existing?.description ?? '');
|
||||||
|
const [servings, setServings] = useState(existing?.servings ?? 4);
|
||||||
|
const [prepTime, setPrepTime] = useState(existing?.prepTime ? String(existing.prepTime) : '');
|
||||||
|
const [cookTime, setCookTime] = useState(existing?.cookTime ? String(existing.cookTime) : '');
|
||||||
|
const [cuisine, setCuisine] = useState(existing?.cuisine ?? '');
|
||||||
|
const [tags, setTags] = useState(existing?.tags.join(', ') ?? '');
|
||||||
|
const [isFavorite, setIsFavorite] = useState(existing?.isFavorite ?? false);
|
||||||
|
const [ingredients, setIngredients] = useState<IngredientInput[]>(
|
||||||
|
existing ? existing.ingredients.map(ingredientFromRecipe) : [emptyIngredient()],
|
||||||
|
);
|
||||||
|
const [steps, setSteps] = useState<StepInput[]>(
|
||||||
|
existing ? existing.steps.map(stepFromRecipe) : [emptyStep(1)],
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
function updateIngredient(
|
||||||
|
i: number,
|
||||||
|
field: keyof IngredientInput,
|
||||||
|
value: IngredientInput[keyof IngredientInput],
|
||||||
|
) {
|
||||||
|
setIngredients((prev) =>
|
||||||
|
prev.map((ing, idx) => (idx === i ? { ...ing, [field]: value } : ing)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(i: number) {
|
||||||
|
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIngredient() {
|
||||||
|
setIngredients((prev) => [...prev, emptyIngredient()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStep(i: number, field: keyof StepInput, value: string) {
|
||||||
|
setSteps((prev) => prev.map((s, idx) => (idx === i ? { ...s, [field]: value } : s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
setSteps((prev) => [...prev, emptyStep(prev.length + 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(i: number) {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, order: idx + 1 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
const tagList = tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
servings,
|
||||||
|
prepTime: prepTime ? Number(prepTime) : undefined,
|
||||||
|
cookTime: cookTime ? Number(cookTime) : undefined,
|
||||||
|
cuisine: cuisine.trim() || undefined,
|
||||||
|
tags: tagList,
|
||||||
|
isFavorite,
|
||||||
|
ingredients: ingredients.map((ing) => ({
|
||||||
|
productId: ing.productId.trim(),
|
||||||
|
productName: ing.productName.trim(),
|
||||||
|
quantity: Number(ing.quantity),
|
||||||
|
unit: ing.unit,
|
||||||
|
preparation: ing.preparation.trim() || undefined,
|
||||||
|
isOptional: ing.isOptional,
|
||||||
|
})),
|
||||||
|
steps: steps
|
||||||
|
.filter((s) => s.instruction.trim())
|
||||||
|
.map((s, i) => ({
|
||||||
|
order: i + 1,
|
||||||
|
instruction: s.instruction.trim(),
|
||||||
|
duration: s.duration ? Number(s.duration) : undefined,
|
||||||
|
tip: s.tip.trim() || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existing) {
|
||||||
|
await updateRecipe(householdId, existing._id, payload);
|
||||||
|
router.push(`/recipes/${existing._id}`);
|
||||||
|
} else {
|
||||||
|
const created = await createRecipe(householdId, payload);
|
||||||
|
router.push(`/recipes/${created._id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save recipe');
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 32, maxWidth: 1100 }}
|
||||||
|
>
|
||||||
|
{/* Main form */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{error && <p style={{ color: 'var(--danger)', fontSize: 14, margin: 0 }}>{error}</p>}
|
||||||
|
|
||||||
|
{/* Basics */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Name *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Recipe name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{ ...inputStyle, resize: 'vertical' }}
|
||||||
|
placeholder="Brief description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Servings *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
value={servings}
|
||||||
|
onChange={(e) => setServings(Number(e.target.value))}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Prep time (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={prepTime}
|
||||||
|
onChange={(e) => setPrepTime(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Cook time (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={cookTime}
|
||||||
|
onChange={(e) => setCookTime(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Cuisine</label>
|
||||||
|
<input
|
||||||
|
value={cuisine}
|
||||||
|
onChange={(e) => setCuisine(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Italian, Japanese..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="vegetarian, quick..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isFavorite}
|
||||||
|
onChange={(e) => setIsFavorite(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Mark as favorite
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Ingredients */}
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2fr 80px 90px 1fr auto',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
placeholder="Product name"
|
||||||
|
value={ing.productName}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateIngredient(i, 'productName', e.target.value);
|
||||||
|
updateIngredient(
|
||||||
|
i,
|
||||||
|
'productId',
|
||||||
|
e.target.value.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, fontSize: 13 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={ing.quantity}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => updateIngredient(i, 'quantity', e.target.value)}
|
||||||
|
style={{ ...inputStyle, fontSize: 13 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={ing.unit}
|
||||||
|
onChange={(e) => updateIngredient(i, 'unit', e.target.value as IngredientUnit)}
|
||||||
|
style={{ ...inputStyle, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{UNIT_OPTIONS.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
placeholder="Preparation (optional)"
|
||||||
|
value={ing.preparation}
|
||||||
|
onChange={(e) => updateIngredient(i, 'preparation', e.target.value)}
|
||||||
|
style={{ ...inputStyle, fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeIngredient(i)}
|
||||||
|
disabled={ingredients.length === 1}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 18,
|
||||||
|
padding: '0 4px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addIngredient}
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add ingredient
|
||||||
|
</button>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 6 }}>
|
||||||
|
Volume/imperial units (cup, tbsp, etc.) are automatically converted to metric before
|
||||||
|
saving. A product must be linked for nutrition to calculate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
minWidth: 20,
|
||||||
|
paddingTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i + 1}.
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<textarea
|
||||||
|
value={step.instruction}
|
||||||
|
onChange={(e) => updateStep(i, 'instruction', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
style={{ ...inputStyle, resize: 'vertical', fontSize: 13 }}
|
||||||
|
placeholder="Instruction..."
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Duration (min)"
|
||||||
|
value={step.duration}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => updateStep(i, 'duration', e.target.value)}
|
||||||
|
style={{ ...inputStyle, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Tip (optional)"
|
||||||
|
value={step.tip}
|
||||||
|
onChange={(e) => updateStep(i, 'tip', e.target.value)}
|
||||||
|
style={{ ...inputStyle, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeStep(i)}
|
||||||
|
disabled={steps.length === 1}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 18,
|
||||||
|
padding: '0 4px',
|
||||||
|
lineHeight: 1,
|
||||||
|
paddingTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addStep}
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, paddingTop: 8 }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: saving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : existing ? 'Save changes' : 'Create recipe'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
background: 'none',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nutrition sidebar */}
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<NutritionDisplay
|
||||||
|
nutrition={existing?.perServingNutrition ?? null}
|
||||||
|
warnings={existing?.warnings ?? []}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
Nutrition is calculated server-side when you save. Link ingredients to products in the
|
||||||
|
product library for accurate data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
packages/web/src/app/(dashboard)/recipes/RecipeList.tsx
Normal file
331
packages/web/src/app/(dashboard)/recipes/RecipeList.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { listRecipes, deleteRecipe } from '@/services/recipes';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||||
|
|
||||||
|
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[NutritionWarning.HIGH_CALORIES]: 'High cal',
|
||||||
|
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
|
||||||
|
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
|
||||||
|
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
|
||||||
|
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
|
||||||
|
[NutritionWarning.LOW_FIBER]: 'Low fiber',
|
||||||
|
[NutritionWarning.HIGH_CHOLESTEROL]: 'High chol',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(minutes?: number): string {
|
||||||
|
if (!minutes) return '';
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delay]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipeList({ householdId }: { householdId: string }) {
|
||||||
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterCuisine, setFilterCuisine] = useState('');
|
||||||
|
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const fetchRecipes = useCallback(async () => {
|
||||||
|
if (!householdId) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await listRecipes(householdId, {
|
||||||
|
q: debouncedSearch || undefined,
|
||||||
|
cuisine: filterCuisine || undefined,
|
||||||
|
isFavorite: favoritesOnly || undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
setRecipes(result.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [householdId, debouncedSearch, filterCuisine, favoritesOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecipes();
|
||||||
|
}, [fetchRecipes]);
|
||||||
|
|
||||||
|
async function handleDelete(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteRecipe(householdId, id);
|
||||||
|
setRecipes((prev) => prev.filter((r) => r._id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete recipe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search recipes..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 240px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cuisine..."
|
||||||
|
value={filterCuisine}
|
||||||
|
onChange={(e) => setFilterCuisine(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={favoritesOnly}
|
||||||
|
onChange={(e) => setFavoritesOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Favorites
|
||||||
|
</label>
|
||||||
|
<Link
|
||||||
|
href="/recipes/new"
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
fontSize: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ New Recipe
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 160,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recipes.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '64px 24px',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{search || filterCuisine || favoritesOnly
|
||||||
|
? 'No recipes match your filters.'
|
||||||
|
: 'No recipes yet. Create your first recipe.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recipes.map((recipe) => (
|
||||||
|
<RecipeCard key={recipe._id} recipe={recipe} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipeCard({
|
||||||
|
recipe,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
recipe: Recipe;
|
||||||
|
onDelete: (id: string, name: string) => void;
|
||||||
|
}) {
|
||||||
|
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Link
|
||||||
|
href={`/recipes/${recipe._id}`}
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--ink)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recipe.isFavorite && <span style={{ marginRight: 4 }}>★</span>}
|
||||||
|
{recipe.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipe.cuisine && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--ink-muted)' }}>{recipe.cuisine}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{recipe.servings} serving{recipe.servings !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{time > 0 && <span>{formatTime(time)}</span>}
|
||||||
|
<span style={{ color: 'var(--ink)', fontWeight: 500 }}>
|
||||||
|
{Math.round(recipe.perServingNutrition.calories)} kcal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipe.warnings.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{recipe.warnings.slice(0, 3).map((w) => (
|
||||||
|
<span
|
||||||
|
key={w}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--danger-soft, #fee)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WARNING_LABELS[w] ?? w}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{recipe.tags.slice(0, 4).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--bg-subtle, var(--bg))',
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
<Link
|
||||||
|
href={`/recipes/${recipe._id}/edit`}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(recipe._id, recipe.name)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockGetRecipe, mockScaleRecipe } = vi.hoisted(() => ({
|
||||||
|
mockGetRecipe: vi.fn(),
|
||||||
|
mockScaleRecipe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/recipes', () => ({
|
||||||
|
getRecipe: mockGetRecipe,
|
||||||
|
scaleRecipe: mockScaleRecipe,
|
||||||
|
listRecipes: vi.fn(),
|
||||||
|
deleteRecipe: vi.fn(),
|
||||||
|
createRecipe: vi.fn(),
|
||||||
|
updateRecipe: vi.fn(),
|
||||||
|
importRecipeFromText: vi.fn(),
|
||||||
|
importRecipeFromUrl: vi.fn(),
|
||||||
|
listRecipesByProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useParams: () => ({ id: 'r1' }),
|
||||||
|
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import RecipeDetailPage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const SAMPLE_RECIPE = {
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
description: 'Classic Italian pasta',
|
||||||
|
servings: 4,
|
||||||
|
prepTime: 15,
|
||||||
|
cookTime: 30,
|
||||||
|
totalTime: 45,
|
||||||
|
cuisine: 'Italian',
|
||||||
|
tags: ['pasta'],
|
||||||
|
isFavorite: true,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Spaghetti',
|
||||||
|
quantity: 400,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productId: 'p2',
|
||||||
|
productName: 'Parmesan',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: true,
|
||||||
|
preparation: 'grated',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ order: 1, instruction: 'Boil water', duration: 10 },
|
||||||
|
{ order: 2, instruction: 'Cook pasta', tip: 'Al dente' },
|
||||||
|
],
|
||||||
|
perServingNutrition: {
|
||||||
|
calories: 450,
|
||||||
|
protein: 25,
|
||||||
|
carbs: 55,
|
||||||
|
fat: 12,
|
||||||
|
fiber: 3,
|
||||||
|
sugar: 5,
|
||||||
|
sodium: 400,
|
||||||
|
saturatedFat: 4,
|
||||||
|
cholesterol: 50,
|
||||||
|
},
|
||||||
|
totalNutrition: {
|
||||||
|
calories: 1800,
|
||||||
|
protein: 100,
|
||||||
|
carbs: 220,
|
||||||
|
fat: 48,
|
||||||
|
},
|
||||||
|
warnings: ['high_calories'],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecipeDetailPage', () => {
|
||||||
|
it('shows loading skeleton', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when fetch fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows recipe not found fallback', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
// Resolve with null-like to trigger error path
|
||||||
|
mockGetRecipe.mockRejectedValue(new Error('Recipe not found'));
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Recipe not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recipe details', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Classic Italian pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Starred')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows ingredients with optional indicator', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Parmesan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('(optional)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows steps with duration and tips', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Boil water')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cook pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tip: Al dente')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('10 min')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nutritional warnings', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nutritional alerts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nutrition panel', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('25.0g')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows edit link', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-Error fetch failure', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockRejectedValue('string error');
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recipe without description', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue({
|
||||||
|
...SAMPLE_RECIPE,
|
||||||
|
description: undefined,
|
||||||
|
warnings: [],
|
||||||
|
isFavorite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipeDetailPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Starred')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockGetRecipe } = vi.hoisted(() => ({
|
||||||
|
mockGetRecipe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/recipes', () => ({
|
||||||
|
getRecipe: mockGetRecipe,
|
||||||
|
createRecipe: vi.fn(),
|
||||||
|
updateRecipe: vi.fn(),
|
||||||
|
listRecipes: vi.fn(),
|
||||||
|
deleteRecipe: vi.fn(),
|
||||||
|
scaleRecipe: vi.fn(),
|
||||||
|
importRecipeFromText: vi.fn(),
|
||||||
|
importRecipeFromUrl: vi.fn(),
|
||||||
|
listRecipesByProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useParams: () => ({ id: 'r1' }),
|
||||||
|
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import EditRecipePage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const SAMPLE_RECIPE = {
|
||||||
|
_id: 'r1',
|
||||||
|
name: 'Pasta',
|
||||||
|
description: '',
|
||||||
|
servings: 4,
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
ingredients: [
|
||||||
|
{ productId: 'p1', productName: 'Flour', quantity: 200, unit: 'g', isOptional: false },
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Mix' }],
|
||||||
|
perServingNutrition: { calories: 200, protein: 5, carbs: 30, fat: 4 },
|
||||||
|
totalNutrition: { calories: 800, protein: 20, carbs: 120, fat: 16 },
|
||||||
|
warnings: [],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
householdId: 'hh1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EditRecipePage', () => {
|
||||||
|
it('shows loading state', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<EditRecipePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit Recipe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when fetch fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
render(<EditRecipePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders editor when recipe loads', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||||
|
|
||||||
|
render(<EditRecipePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback when recipe is not found', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockGetRecipe.mockRejectedValue('unexpected');
|
||||||
|
|
||||||
|
render(<EditRecipePage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
packages/web/src/app/(dashboard)/recipes/[id]/edit/page.tsx
Normal file
61
packages/web/src/app/(dashboard)/recipes/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { RecipeEditor } from '../../RecipeEditor';
|
||||||
|
import { getRecipe } from '@/services/recipes';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||||
|
|
||||||
|
export default function EditRecipePage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!householdId || !params.id) return;
|
||||||
|
getRecipe(householdId, params.id)
|
||||||
|
.then(setRecipe)
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [householdId, params.id]);
|
||||||
|
|
||||||
|
const title = recipe?.name ? `Edit: ${recipe.name}` : 'Edit Recipe';
|
||||||
|
|
||||||
|
if (sessionLoading || loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
|
||||||
|
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !recipe) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
|
||||||
|
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
|
||||||
|
{error || 'Recipe not found'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title={title} crumbs={['Recipes', recipe.name, 'Edit']} />
|
||||||
|
<div style={{ padding: '28px 32px 56px' }}>
|
||||||
|
<RecipeEditor householdId={householdId!} existing={recipe} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { getRecipe, scaleRecipe } from '@/services/recipes';
|
||||||
|
import { NutritionWarning } from '@meshitrack/shared';
|
||||||
|
import type { z } from 'zod/v4';
|
||||||
|
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||||
|
|
||||||
|
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||||
|
|
||||||
|
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[NutritionWarning.HIGH_CALORIES]: 'High calories (>800 kcal/serving)',
|
||||||
|
[NutritionWarning.HIGH_SODIUM]: 'High sodium (>1500mg/serving)',
|
||||||
|
[NutritionWarning.HIGH_SUGAR]: 'High sugar (>25g/serving)',
|
||||||
|
[NutritionWarning.HIGH_SATURATED_FAT]: 'High saturated fat (>13g/serving)',
|
||||||
|
[NutritionWarning.LOW_PROTEIN]: 'Low protein (<10g/serving)',
|
||||||
|
[NutritionWarning.LOW_FIBER]: 'Low fiber (<3g/serving)',
|
||||||
|
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol (>200mg/serving)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(minutes?: number): string {
|
||||||
|
if (!minutes) return '';
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NutritionPanel({
|
||||||
|
nutrition,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
nutrition: Recipe['perServingNutrition'];
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
marginBottom: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px' }}>
|
||||||
|
<NutrRow label="Calories" value={`${Math.round(nutrition.calories)} kcal`} bold />
|
||||||
|
<NutrRow label="Protein" value={`${nutrition.protein.toFixed(1)}g`} />
|
||||||
|
<NutrRow label="Carbs" value={`${nutrition.carbs.toFixed(1)}g`} />
|
||||||
|
<NutrRow label="Fat" value={`${nutrition.fat.toFixed(1)}g`} />
|
||||||
|
{nutrition.fiber !== undefined && (
|
||||||
|
<NutrRow label="Fiber" value={`${nutrition.fiber.toFixed(1)}g`} />
|
||||||
|
)}
|
||||||
|
{nutrition.sugar !== undefined && (
|
||||||
|
<NutrRow label="Sugar" value={`${nutrition.sugar.toFixed(1)}g`} />
|
||||||
|
)}
|
||||||
|
{nutrition.sodium !== undefined && (
|
||||||
|
<NutrRow label="Sodium" value={`${Math.round(nutrition.sodium)}mg`} />
|
||||||
|
)}
|
||||||
|
{nutrition.saturatedFat !== undefined && (
|
||||||
|
<NutrRow label="Sat. fat" value={`${nutrition.saturatedFat.toFixed(1)}g`} />
|
||||||
|
)}
|
||||||
|
{nutrition.cholesterol !== undefined && (
|
||||||
|
<NutrRow label="Cholesterol" value={`${Math.round(nutrition.cholesterol)}mg`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NutrRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--ink)', fontWeight: bold ? 600 : 400 }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const { householdId, isLoading: sessionLoading } = useApi();
|
||||||
|
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [scaledServings, setScaledServings] = useState<number | null>(null);
|
||||||
|
const [scaledRecipe, setScaledRecipe] = useState<Recipe | null>(null);
|
||||||
|
const [scaling, setScaling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!householdId || !params.id) return;
|
||||||
|
setLoading(true);
|
||||||
|
getRecipe(householdId, params.id)
|
||||||
|
.then((r) => {
|
||||||
|
setRecipe(r);
|
||||||
|
setScaledServings(r.servings);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [householdId, params.id]);
|
||||||
|
|
||||||
|
async function handleScale() {
|
||||||
|
if (!householdId || !recipe || !scaledServings) return;
|
||||||
|
if (scaledServings === recipe.servings) {
|
||||||
|
setScaledRecipe(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScaling(true);
|
||||||
|
try {
|
||||||
|
const result = await scaleRecipe(householdId, recipe._id, { targetServings: scaledServings });
|
||||||
|
setScaledRecipe(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to scale recipe');
|
||||||
|
} finally {
|
||||||
|
setScaling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionLoading || loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 200,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !recipe) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||||
|
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
|
||||||
|
{error || 'Recipe not found'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayRecipe = scaledRecipe ?? recipe;
|
||||||
|
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader
|
||||||
|
title={recipe.name}
|
||||||
|
subtitle={recipe.cuisine ?? undefined}
|
||||||
|
crumbs={['Recipes', recipe.name]}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '28px 32px 56px', maxWidth: 1100 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 32 }}>
|
||||||
|
{/* Main content */}
|
||||||
|
<div>
|
||||||
|
{/* Meta */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>
|
||||||
|
{recipe.servings} servings
|
||||||
|
</span>
|
||||||
|
{time > 0 && (
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>{formatTime(time)}</span>
|
||||||
|
)}
|
||||||
|
{recipe.isFavorite && (
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--brand)' }}>Starred</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/recipes/${recipe._id}/edit`}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipe.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recipe.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{recipe.warnings.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--danger-soft, #fee)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--danger)' }}
|
||||||
|
>
|
||||||
|
Nutritional alerts
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{recipe.warnings.map((w) => (
|
||||||
|
<li key={w} style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 2 }}>
|
||||||
|
{WARNING_LABELS[w] ?? w}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ingredients */}
|
||||||
|
<section style={{ marginBottom: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{displayRecipe.ingredients.map((ing, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{ing.isOptional && (
|
||||||
|
<span style={{ color: 'var(--ink-muted)', fontSize: 12 }}>(optional) </span>
|
||||||
|
)}
|
||||||
|
{ing.productName}
|
||||||
|
{ing.preparation && (
|
||||||
|
<span style={{ color: 'var(--ink-muted)' }}>, {ing.preparation}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--ink-muted)', marginLeft: 16 }}>
|
||||||
|
{ing.originalQuantity != null && ing.originalUnit
|
||||||
|
? `${ing.originalQuantity} ${ing.originalUnit}`
|
||||||
|
: `${ing.quantity} ${ing.unit}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
{recipe.steps.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
|
||||||
|
<ol style={{ paddingLeft: 20, margin: 0 }}>
|
||||||
|
{recipe.steps
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((step) => (
|
||||||
|
<li key={step.order} style={{ marginBottom: 16 }}>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{step.instruction}
|
||||||
|
</p>
|
||||||
|
{step.duration && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4 }}>
|
||||||
|
{formatTime(step.duration)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step.tip && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
marginTop: 4,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tip: {step.tip}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div>
|
||||||
|
{/* Scale */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>Scale Recipe</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
value={scaledServings ?? recipe.servings}
|
||||||
|
onChange={(e) => setScaledServings(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: 70,
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>servings</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScale}
|
||||||
|
disabled={scaling}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: scaling ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scaling ? '...' : 'Scale'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scaledRecipe && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setScaledRecipe(null);
|
||||||
|
setScaledServings(recipe.servings);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--ink-muted)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset to original
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nutrition */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NutritionPanel
|
||||||
|
nutrition={displayRecipe.perServingNutrition}
|
||||||
|
label={`Per serving (${displayRecipe.servings} total)`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NutritionPanel
|
||||||
|
nutrition={displayRecipe.totalNutrition}
|
||||||
|
label="Total (all servings)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockCreateRecipe, mockUpdateRecipe } = vi.hoisted(() => ({
|
||||||
|
mockCreateRecipe: vi.fn(),
|
||||||
|
mockUpdateRecipe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockPush, mockBack } = vi.hoisted(() => ({
|
||||||
|
mockPush: vi.fn(),
|
||||||
|
mockBack: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/recipes', () => ({
|
||||||
|
createRecipe: mockCreateRecipe,
|
||||||
|
updateRecipe: mockUpdateRecipe,
|
||||||
|
listRecipes: vi.fn(),
|
||||||
|
deleteRecipe: vi.fn(),
|
||||||
|
getRecipe: vi.fn(),
|
||||||
|
scaleRecipe: vi.fn(),
|
||||||
|
importRecipeFromText: vi.fn(),
|
||||||
|
importRecipeFromUrl: vi.fn(),
|
||||||
|
listRecipesByProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: mockPush, back: mockBack }),
|
||||||
|
useParams: () => ({ id: 'r1' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RecipeEditor } from '../RecipeEditor';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('RecipeEditor', () => {
|
||||||
|
it('renders create form by default', () => {
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Ingredients')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Instructions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders edit form when existing recipe provided', () => {
|
||||||
|
const existing = {
|
||||||
|
_id: 'r1',
|
||||||
|
name: 'Pasta',
|
||||||
|
description: 'Good pasta',
|
||||||
|
servings: 2,
|
||||||
|
prepTime: 10,
|
||||||
|
cookTime: 20,
|
||||||
|
cuisine: 'Italian',
|
||||||
|
tags: ['pasta', 'quick'],
|
||||||
|
isFavorite: true,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Spaghetti',
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
originalQuantity: 200,
|
||||||
|
originalUnit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Boil water', duration: 5, tip: 'Use salted water' }],
|
||||||
|
perServingNutrition: { calories: 300, protein: 10, carbs: 40, fat: 8 },
|
||||||
|
totalNutrition: { calories: 600, protein: 20, carbs: 80, fat: 16 },
|
||||||
|
warnings: [],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
householdId: 'hh1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Good pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits create form', async () => {
|
||||||
|
mockCreateRecipe.mockResolvedValue({ _id: 'new1' });
|
||||||
|
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||||
|
target: { value: 'New Recipe' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Flour' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Create recipe'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateRecipe).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ name: 'New Recipe' }),
|
||||||
|
);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/recipes/new1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits update form', async () => {
|
||||||
|
mockUpdateRecipe.mockResolvedValue({ _id: 'r1' });
|
||||||
|
const existing = {
|
||||||
|
_id: 'r1',
|
||||||
|
name: 'Old Name',
|
||||||
|
description: '',
|
||||||
|
servings: 4,
|
||||||
|
tags: [],
|
||||||
|
isFavorite: false,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Test',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Do thing' }],
|
||||||
|
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 3 },
|
||||||
|
totalNutrition: { calories: 400, protein: 20, carbs: 40, fat: 12 },
|
||||||
|
warnings: [],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
householdId: 'hh1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue('Old Name'), {
|
||||||
|
target: { value: 'New Name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save changes'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateRecipe).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
'r1',
|
||||||
|
expect.objectContaining({ name: 'New Name' }),
|
||||||
|
);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/recipes/r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when create fails', async () => {
|
||||||
|
mockCreateRecipe.mockRejectedValue(new Error('Server error'));
|
||||||
|
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Item' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Create recipe'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback error for non-Error failure', async () => {
|
||||||
|
mockCreateRecipe.mockRejectedValue('unexpected');
|
||||||
|
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||||
|
target: { value: 'Test' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||||
|
target: { value: 'Item' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Create recipe'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to save recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add and remove ingredients', () => {
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('+ Add ingredient'));
|
||||||
|
|
||||||
|
const nameInputs = screen.getAllByPlaceholderText('Product name');
|
||||||
|
expect(nameInputs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can add and remove steps', () => {
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('+ Add step'));
|
||||||
|
|
||||||
|
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
|
||||||
|
expect(stepInputs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel button calls router.back', () => {
|
||||||
|
render(<RecipeEditor householdId="hh1" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Cancel'));
|
||||||
|
|
||||||
|
expect(mockBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
423
packages/web/src/app/(dashboard)/recipes/__tests__/page.test.tsx
Normal file
423
packages/web/src/app/(dashboard)/recipes/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListRecipes, mockDeleteRecipe } = vi.hoisted(() => ({
|
||||||
|
mockListRecipes: vi.fn(),
|
||||||
|
mockDeleteRecipe: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/recipes', () => ({
|
||||||
|
listRecipes: mockListRecipes,
|
||||||
|
deleteRecipe: mockDeleteRecipe,
|
||||||
|
createRecipe: vi.fn(),
|
||||||
|
updateRecipe: vi.fn(),
|
||||||
|
getRecipe: vi.fn(),
|
||||||
|
scaleRecipe: vi.fn(),
|
||||||
|
importRecipeFromText: vi.fn(),
|
||||||
|
importRecipeFromUrl: vi.fn(),
|
||||||
|
listRecipesByProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import RecipesPage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
const SAMPLE_RECIPE = {
|
||||||
|
_id: 'r1',
|
||||||
|
householdId: 'hh1',
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
description: 'Classic Italian pasta',
|
||||||
|
servings: 4,
|
||||||
|
prepTime: 15,
|
||||||
|
cookTime: 30,
|
||||||
|
totalTime: 45,
|
||||||
|
cuisine: 'Italian',
|
||||||
|
tags: ['pasta', 'comfort'],
|
||||||
|
isFavorite: true,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
productId: 'p1',
|
||||||
|
productName: 'Spaghetti',
|
||||||
|
quantity: 400,
|
||||||
|
unit: 'g',
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ order: 1, instruction: 'Boil water' }],
|
||||||
|
perServingNutrition: {
|
||||||
|
calories: 450,
|
||||||
|
protein: 25,
|
||||||
|
carbs: 55,
|
||||||
|
fat: 12,
|
||||||
|
fiber: 3,
|
||||||
|
sugar: 5,
|
||||||
|
sodium: 400,
|
||||||
|
},
|
||||||
|
totalNutrition: {
|
||||||
|
calories: 1800,
|
||||||
|
protein: 100,
|
||||||
|
carbs: 220,
|
||||||
|
fat: 48,
|
||||||
|
},
|
||||||
|
warnings: ['high_calories'],
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecipesPage', () => {
|
||||||
|
it('shows loading skeleton when session is loading', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Search recipes...')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows household prompt when no householdId', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recipe list when household exists', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no recipes', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No recipes yet/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows nutrition info on recipe card', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows time on recipe card', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows warning labels', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('High cal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tags on recipe card', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('comfort')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows favorite star', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Star character is rendered for favorites
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese').closest('a')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows edit link', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles delete with confirmation', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteRecipe.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteRecipe).toHaveBeenCalledWith('hh1', 'r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete when confirm is cancelled', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
expect(mockDeleteRecipe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when fetch fails', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows filter empty state message', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText('Cuisine...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Cuisine...'), {
|
||||||
|
target: { value: 'Thai' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/No recipes match your filters/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows recipe without totalTime using prep+cook', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [{ ...SAMPLE_RECIPE, totalTime: undefined }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours correctly', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [{ ...SAMPLE_RECIPE, totalTime: 90 }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1h 30m')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats exact hours', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [{ ...SAMPLE_RECIPE, totalTime: 120 }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('2h')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles delete error', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteRecipe.mockRejectedValue(new Error('Delete failed'));
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-Error delete failure', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [SAMPLE_RECIPE],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
mockDeleteRecipe.mockRejectedValue('string error');
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Failed to delete recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows + New Recipe link', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('+ New Recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recipe with no warnings renders without warning badges', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [{ ...SAMPLE_RECIPE, warnings: [], tags: [] }],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('High cal')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles favorites checkbox', async () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
mockListRecipes.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
pagination: { cursor: null, hasMore: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RecipesPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Favorites')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Favorites'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockListRecipes).toHaveBeenCalledWith(
|
||||||
|
'hh1',
|
||||||
|
expect.objectContaining({ isFavorite: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
vi.mock('@/services/recipes', () => ({
|
||||||
|
createRecipe: vi.fn(),
|
||||||
|
updateRecipe: vi.fn(),
|
||||||
|
listRecipes: vi.fn(),
|
||||||
|
deleteRecipe: vi.fn(),
|
||||||
|
getRecipe: vi.fn(),
|
||||||
|
scaleRecipe: vi.fn(),
|
||||||
|
importRecipeFromText: vi.fn(),
|
||||||
|
importRecipeFromUrl: vi.fn(),
|
||||||
|
listRecipesByProduct: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import NewRecipePage from '../page';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('NewRecipePage', () => {
|
||||||
|
it('shows loading state', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||||
|
|
||||||
|
render(<NewRecipePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('New Recipe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows household prompt when no householdId', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||||
|
|
||||||
|
render(<NewRecipePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders editor when household exists', () => {
|
||||||
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||||
|
|
||||||
|
render(<NewRecipePage />);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/web/src/app/(dashboard)/recipes/new/page.tsx
Normal file
40
packages/web/src/app/(dashboard)/recipes/new/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { RecipeEditor } from '../RecipeEditor';
|
||||||
|
|
||||||
|
export default function NewRecipePage() {
|
||||||
|
const { householdId, isLoading } = useApi();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||||
|
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||||
|
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to create or join a household first.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||||
|
<div style={{ padding: '28px 32px 56px' }}>
|
||||||
|
<RecipeEditor householdId={householdId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
packages/web/src/app/(dashboard)/recipes/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/recipes/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useApi } from '@/lib/useApi';
|
||||||
|
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||||
|
import { RecipeList } from './RecipeList';
|
||||||
|
|
||||||
|
function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
marginBottom: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoHousehold() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '28px 32px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-elev)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-md)',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||||
|
You need to create or join a household before managing recipes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipesPage() {
|
||||||
|
const { householdId, isLoading } = useApi();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||||
|
<PageSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!householdId) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||||
|
<NoHousehold />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||||
|
<RecipeList householdId={householdId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
const { mockUsePathname } = vi.hoisted(() => ({
|
||||||
|
mockUsePathname: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockUseApi } = vi.hoisted(() => ({
|
||||||
|
mockUseApi: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: mockUsePathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||||
|
|
||||||
|
import { Sidebar } from '../layout/Sidebar';
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUsePathname.mockReturnValue('/dashboard');
|
||||||
|
mockUseApi.mockReturnValue({ profile: { displayName: 'Alice' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders brand name', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders navigation sections', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Medicines')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medicines nav items', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Cabinet')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Library')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders food nav items', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active route', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/medicines/cabinet');
|
||||||
|
render(<Sidebar />);
|
||||||
|
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||||
|
expect(cabinetLink).toHaveAttribute('href', '/medicines/cabinet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows user avatar with display name', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback name when no profile', () => {
|
||||||
|
mockUseApi.mockReturnValue({ profile: null });
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByLabelText('User')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights nested route', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/medicines/cabinet/some-id');
|
||||||
|
render(<Sidebar />);
|
||||||
|
// Cabinet link should still match via startsWith
|
||||||
|
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||||
|
expect(cabinetLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue