Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 055c0dfe10 | |||
| 318f63657b | |||
| 718157e472 | |||
| f00f11aa55 | |||
| 4081e03970 | |||
| 9cfbf89d46 | |||
| ffab9ef4da | |||
| ed707444a2 | |||
| d0a40bde88 | |||
| e754729e08 | |||
| f41f6df558 | |||
| 1a1931fd92 | |||
| 935d561d1a | |||
| e5dc9d8a04 | |||
| 271db4557d | |||
| eb42d67214 | |||
| 23759f1935 | |||
| 1cb8dbdb95 | |||
| 653e9e7fa8 | |||
| b6d7470fb1 | |||
| 3aeaf8f3b9 | |||
| f45e904f2f | |||
| 31c5437859 | |||
| 316d976349 | |||
| 12d5aeb0fb | |||
| 2d17fde8f1 | |||
| fa15650f52 | |||
| dd716daa11 | |||
| a80cc43b06 | |||
| d405ff4b2b | |||
| 9c70eead9b | |||
| 273d84e26c | |||
| 6b54ecef4f | |||
| b8d5647980 | |||
| cb1810586d | |||
| b5e12c7a95 | |||
| 3364f23196 | |||
| e5038e9843 | |||
| d80b5243b3 | |||
| 2b16e2c7dc | |||
| ba3ebd27f4 | |||
| fe9310d3d4 | |||
| f2b20a8ffc | |||
| 093aa419af | |||
| 8132da3c3d |
+41
-1
@@ -78,4 +78,44 @@ REMINDER_DAYS_BEFORE=7
|
||||
# Admin settings (not editable in UI)
|
||||
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
|
||||
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
|
||||
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||
|
||||
# =============================================================================
|
||||
# Default User Settings (applied when new user is created)
|
||||
# =============================================================================
|
||||
# These ENV values are only used as DEFAULTS when a new user is created.
|
||||
# Once a user saves their settings in the app, these ENV values are ignored
|
||||
# for that user - their saved preferences take precedence.
|
||||
#
|
||||
# Useful for server admins to pre-configure settings for all new users.
|
||||
# =============================================================================
|
||||
|
||||
# Email notifications (requires SMTP config above)
|
||||
# DEFAULT_EMAIL_ENABLED=false
|
||||
# DEFAULT_NOTIFICATION_EMAIL=
|
||||
# DEFAULT_EMAIL_STOCK_REMINDERS=true
|
||||
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
||||
|
||||
# Push notifications (ntfy/gotify via Shoutrrr)
|
||||
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||
# DEFAULT_SHOUTRRR_URL=
|
||||
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
|
||||
|
||||
# Repeat/nagging reminders for missed doses
|
||||
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
|
||||
# DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES=30
|
||||
# DEFAULT_MAX_NAGGING_REMINDERS=5
|
||||
# DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES=false
|
||||
|
||||
# Stock reminder settings
|
||||
# DEFAULT_REPEAT_DAILY_REMINDERS=false
|
||||
|
||||
# Stock thresholds (days of supply)
|
||||
# DEFAULT_LOW_STOCK_DAYS=30
|
||||
# DEFAULT_NORMAL_STOCK_DAYS=90
|
||||
# DEFAULT_HIGH_STOCK_DAYS=180
|
||||
|
||||
# UI defaults
|
||||
# DEFAULT_LANGUAGE=en # en or de
|
||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||
@@ -0,0 +1,78 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this issue?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: What should have happened instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop images here
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running MedAssist?
|
||||
options:
|
||||
- Docker Compose (Production)
|
||||
- Docker Compose (Development)
|
||||
- Local development (npm run dev)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: What browser are you using?
|
||||
placeholder: e.g. Chrome 120, Firefox 121, Safari 17
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Log Output
|
||||
description: Please copy and paste any relevant log output (backend or browser console).
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/DanielVolz/medassist-ng/discussions
|
||||
about: Ask questions or share ideas in Discussions
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/DanielVolz/medassist-ng#readme
|
||||
about: Check the README for setup and usage instructions
|
||||
@@ -0,0 +1,77 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting an improvement! Please fill out the sections below.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or Motivation
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see.
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered.
|
||||
placeholder: Other approaches you thought about
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected Area
|
||||
description: Which part of the app does this affect?
|
||||
options:
|
||||
- Dashboard
|
||||
- Medications
|
||||
- Schedule / Timeline
|
||||
- Planner
|
||||
- Settings
|
||||
- Notifications (Email/Push)
|
||||
- Authentication
|
||||
- Share functionality
|
||||
- Mobile experience
|
||||
- API / Backend
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority (your opinion)
|
||||
description: How important is this feature to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would be helpful
|
||||
- Important for my use case
|
||||
- Critical / Blocking
|
||||
|
||||
- type: textarea
|
||||
id: mockups
|
||||
attributes:
|
||||
label: Mockups / Examples
|
||||
description: If you have any mockups, screenshots, or examples from other apps, add them here.
|
||||
placeholder: Drag and drop images here
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the feature request here.
|
||||
@@ -0,0 +1,17 @@
|
||||
name: "MedAssist CodeQL Config"
|
||||
|
||||
# Paths to ignore in CodeQL analysis
|
||||
paths-ignore:
|
||||
- "**/node_modules/**"
|
||||
- "**/dist/**"
|
||||
- "**/*.test.ts"
|
||||
- "**/test/**"
|
||||
|
||||
# Query filters to suppress false positives
|
||||
query-filters:
|
||||
# Rate limiting IS implemented via @fastify/rate-limit plugin (registered in index.ts)
|
||||
# Route-specific limits are applied via config.rateLimit option
|
||||
# CodeQL doesn't recognize this Fastify-specific pattern
|
||||
- exclude:
|
||||
id: js/missing-rate-limiting
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# MedAssist-ng - AI Coding Instructions
|
||||
|
||||
## General Rules
|
||||
|
||||
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
|
||||
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
||||
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
||||
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
||||
@@ -33,8 +40,228 @@ docker compose up -d
|
||||
|
||||
# Database migrations
|
||||
cd backend && npm run migrate
|
||||
|
||||
# Run tests
|
||||
cd backend && npm test # Run all tests
|
||||
cd backend && npm run test:coverage # Run with coverage report
|
||||
```
|
||||
|
||||
## Testing (MANDATORY)
|
||||
|
||||
> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
|
||||
> Pull Requests without tests for new features will not be accepted.
|
||||
|
||||
### Test Framework
|
||||
- **Vitest 2.1** with v8 Coverage
|
||||
- Tests in `backend/src/test/*.test.ts`
|
||||
- Coverage goal: At least equal or better coverage after changes
|
||||
|
||||
### Test Structure
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
|
||||
| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
|
||||
| `db.test.ts` | Database schema and operations |
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```typescript
|
||||
// Backend Test Example (backend/src/test/example.test.ts)
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
||||
|
||||
describe('Feature Name', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
const user = await createTestUser(app);
|
||||
authToken = user.token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('should do something specific', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/endpoint',
|
||||
headers: { Authorization: `Bearer ${authToken}` }
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toHaveProperty('expectedField');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
cd backend
|
||||
CI=true npm test # Run tests once (ALWAYS run this way!)
|
||||
CI=true npm run test:coverage # With coverage report
|
||||
npm test -- --watch # Watch mode for manual development
|
||||
npm test -- -t "test name" # Run single test
|
||||
```
|
||||
|
||||
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
|
||||
> Without `CI=true`, Vitest runs in watch mode and waits for input.
|
||||
|
||||
## CI/CD Pipeline (GitHub Actions)
|
||||
|
||||
### Workflow Overview
|
||||
|
||||
```
|
||||
Pull Request created
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ test.yml │
|
||||
│ ├─ backend-test (parallel) │
|
||||
│ │ ├─ npm ci │
|
||||
│ │ ├─ tsc --noEmit (Type-Check) │
|
||||
│ │ └─ npm run test:coverage │
|
||||
│ └─ frontend-build (parallel) │
|
||||
│ ├─ npm ci │
|
||||
│ └─ npm run build │
|
||||
└─────────────────────────────────────┘
|
||||
↓ Tests must pass
|
||||
PR can be merged
|
||||
↓
|
||||
Push to main / Tag created
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ docker-build.yml │
|
||||
│ ├─ backend-test (parallel) │
|
||||
│ ├─ frontend-build (parallel) │
|
||||
│ └─ build-and-push (after tests) │
|
||||
│ ├─ Build Docker images │
|
||||
│ └─ Push to GHCR │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Branch Protection
|
||||
|
||||
> ⚠️ **IMPORTANT**: The `main` branch is protected!
|
||||
> Direct pushing to `main` is **not possible** - GitHub will reject the push.
|
||||
> All changes must go through Pull Requests.
|
||||
|
||||
- **main** branch is protected (Repository Rules)
|
||||
- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
|
||||
- PRs require:
|
||||
- ✅ `backend-test` Status Check passed
|
||||
- ✅ `frontend-build` Status Check passed
|
||||
- After successful merge, the feature branch is automatically deleted
|
||||
|
||||
**Workflow for changes:**
|
||||
```bash
|
||||
# 1. Create feature branch
|
||||
git checkout -b feat/my-feature
|
||||
|
||||
# 2. Commit and push changes
|
||||
git add . && git commit -m "feat: Description"
|
||||
git push -u origin feat/my-feature
|
||||
|
||||
# 3. Create PR (via GitHub CLI or Web)
|
||||
gh pr create --title "My Feature" --body "Description"
|
||||
|
||||
# 4. Wait until CI is green, then merge
|
||||
gh pr merge --squash --delete-branch
|
||||
```
|
||||
|
||||
### Workflow Files
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|--------|
|
||||
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
|
||||
|
||||
### Adding New Code - Checklist
|
||||
1. ✅ Implement feature
|
||||
2. ✅ Write tests for the feature
|
||||
3. ✅ Run `npm run test:coverage` locally
|
||||
4. ✅ Coverage must not decrease
|
||||
5. ✅ Create and push feature branch
|
||||
6. ✅ Create Pull Request
|
||||
7. ✅ Wait until CI is green
|
||||
8. ✅ Merge PR (branch is automatically deleted)
|
||||
|
||||
## GitHub Releases
|
||||
|
||||
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
|
||||
|
||||
### Creating Release Notes
|
||||
|
||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||
|
||||
**Structure of a release text:**
|
||||
|
||||
1. **Intro** (1-2 sentences): What's new, what was improved?
|
||||
2. **Features & Changes**: Brief list of key changes
|
||||
3. **Breaking Changes Warning** (if applicable): See below
|
||||
4. **Optional**: Acknowledgements, documentation links
|
||||
|
||||
**Example of good release notes:**
|
||||
|
||||
```markdown
|
||||
## What's New
|
||||
|
||||
This release adds intake reminder notifications and improves medication stock tracking. Users can now configure nagging reminders for missed doses and receive alerts when medication stock runs low.
|
||||
|
||||
### New Features
|
||||
- 🔔 Intake reminder notifications with configurable nagging intervals
|
||||
- 📊 Enhanced stock calculation with blister tracking
|
||||
- 🌐 German translation improvements
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed timezone handling in dose scheduling
|
||||
- Improved image upload validation
|
||||
|
||||
### Full Changelog
|
||||
[All commits since v1.2.0](link)
|
||||
```
|
||||
|
||||
### Breaking Changes Warning (CRITICAL!)
|
||||
|
||||
> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes!
|
||||
|
||||
**Breaking Changes include:**
|
||||
- Database schema changes without automatic migration
|
||||
- Removed or renamed ENV variables
|
||||
- Changed API endpoints
|
||||
- Incompatible `.env` format changes
|
||||
- Loss of stored data after update
|
||||
|
||||
**Format for Breaking Changes:**
|
||||
|
||||
```markdown
|
||||
## ⚠️ BREAKING CHANGES - Please read before updating!
|
||||
|
||||
**Database migration required**: This update changes the database schema.
|
||||
Existing installations need to:
|
||||
1. Create backup of `data/` folder
|
||||
2. Stop containers
|
||||
3. Perform update
|
||||
4. If issues occur: Rollback using backup
|
||||
|
||||
**ENV variables changed**:
|
||||
- `OLD_VAR` was renamed to `NEW_VAR`
|
||||
- `REMOVED_VAR` is no longer supported
|
||||
|
||||
**Medication data**: Intake schedules with only one time entry will be automatically
|
||||
migrated. Please verify all times are correct after update.
|
||||
```
|
||||
|
||||
**What is NOT a Breaking Change:**
|
||||
- ✅ New optional columns with DEFAULT values
|
||||
- ✅ New ENV variables (with sensible defaults)
|
||||
- ✅ New features that don't affect existing data
|
||||
- ✅ Bug fixes that correct behavior
|
||||
|
||||
**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d`
|
||||
without adjusting anything → Not a Breaking Change.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Backend Routes (`backend/src/routes/`)
|
||||
@@ -142,9 +369,25 @@ Each blister defines a recurring intake:
|
||||
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
||||
| Email Notifications | Enable, email address, stock/intake toggles |
|
||||
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
|
||||
| Reminder Settings | Days before, repeat daily |
|
||||
| Reminder Settings | Days before, repeat daily, skip for taken, repeat/nagging |
|
||||
| SMTP | Email config (read-only from .env) |
|
||||
|
||||
### Settings ENV Defaults
|
||||
All user settings can be pre-configured via ENV variables (see `.env.example`).
|
||||
These are only used as **defaults when a new user is created**.
|
||||
Once a user saves settings in the app, their saved values take precedence over ENV.
|
||||
|
||||
| ENV Variable | Setting | Default |
|
||||
|--------------|---------|---------|
|
||||
| `DEFAULT_EMAIL_ENABLED` | Email notifications | false |
|
||||
| `DEFAULT_SHOUTRRR_ENABLED` | Push notifications | false |
|
||||
| `DEFAULT_SHOUTRRR_URL` | ntfy/gotify URL | (empty) |
|
||||
| `DEFAULT_REPEAT_REMINDERS_ENABLED` | Nagging reminders | false |
|
||||
| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | Nag interval | 30 |
|
||||
| `DEFAULT_MAX_NAGGING_REMINDERS` | Max nags | 5 |
|
||||
| `DEFAULT_LOW_STOCK_DAYS` | Low stock threshold | 30 |
|
||||
| `DEFAULT_LANGUAGE` | UI language | en |
|
||||
|
||||
## Database Schema (`backend/src/db/schema.ts`)
|
||||
|
||||
| Table | Description |
|
||||
@@ -206,14 +449,54 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
||||
|
||||
## Database Schema Changes
|
||||
## Database Schema Changes (IMPORTANT: Backward Compatibility!)
|
||||
|
||||
When adding new database columns:
|
||||
> ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases!
|
||||
> Users upgrade their Docker containers but keep their existing DB.
|
||||
> The app must NOT crash if old columns are missing.
|
||||
|
||||
1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition
|
||||
2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS`
|
||||
3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts
|
||||
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
|
||||
### Rules for New Columns
|
||||
|
||||
1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT <value>`
|
||||
2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false`
|
||||
3. **Update schema SQL**: Add to these files:
|
||||
- `backend/src/db/schema.ts` - Drizzle Schema
|
||||
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs
|
||||
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration
|
||||
4. **Update test schemas**: All test files with their own schema:
|
||||
- `backend/src/test/e2e-routes.test.ts`
|
||||
- `backend/src/test/integration.test.ts`
|
||||
- `backend/src/test/planner.test.ts`
|
||||
|
||||
### Example: Adding a New Column
|
||||
|
||||
```typescript
|
||||
// 1. schema.ts - Drizzle definition
|
||||
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||
|
||||
// 2. schema-sql.ts - For new databases
|
||||
"max_nagging_reminders integer NOT NULL DEFAULT 5,"
|
||||
|
||||
// 3. client.ts - Migration for existing DBs (IN ensureTablesExist())
|
||||
await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {});
|
||||
|
||||
// 4. Routes - NULL-safe reading
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
```
|
||||
|
||||
### What is NOT Allowed
|
||||
|
||||
- ❌ Deleting or renaming columns (breaks old DBs)
|
||||
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
|
||||
- ❌ Reading columns without fallback in code
|
||||
- ❌ Documenting "delete DB" as a solution
|
||||
|
||||
### When Backward Compatibility is NOT Possible
|
||||
|
||||
If a breaking change is unavoidable:
|
||||
1. **Explicitly communicate**: Document in release notes
|
||||
2. **Migration script**: Provide automatic upgrade script
|
||||
3. **Version check**: App should check DB version and warn
|
||||
|
||||
## File Locations
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -4,18 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'docker-compose*.yml'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'docker-compose*.yml'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -23,11 +11,59 @@ on:
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Default minimal permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
# =============================================================================
|
||||
# Run Tests First
|
||||
# =============================================================================
|
||||
backend-test:
|
||||
name: Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run test:run
|
||||
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
# =============================================================================
|
||||
# Build and Push Docker Images (only after tests pass)
|
||||
# =============================================================================
|
||||
build-and-push:
|
||||
needs: [backend-test, frontend-build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -16,11 +16,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
# Get all tags sorted by version, find the one before current
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||
|
||||
# If no previous tag found (first release), use empty
|
||||
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||
PREV_TAG=""
|
||||
fi
|
||||
|
||||
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# First release - get all commits
|
||||
@@ -37,6 +51,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
generate_release_notes: true
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Minimal permissions for security
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# =============================================================================
|
||||
# Backend Tests
|
||||
# =============================================================================
|
||||
backend-test:
|
||||
name: Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: TypeScript type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: backend/coverage/
|
||||
retention-days: 7
|
||||
|
||||
# =============================================================================
|
||||
# Frontend Build Validation
|
||||
# =============================================================================
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: TypeScript type check & build
|
||||
run: npm run build
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
### Notifications
|
||||
- Email via SMTP
|
||||
- Push notifications via ntfy, Gotify, Telegram, Discord (Shoutrrr)
|
||||
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||
- Supports both stock warnings and intake reminders
|
||||
|
||||
### Privacy & Security
|
||||
@@ -148,6 +148,54 @@ Generate secrets with: `openssl rand -hex 32`
|
||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||
|
||||
### Push Notifications (Shoutrrr)
|
||||
|
||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||
|
||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
|
||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
||||
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
||||
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
||||
|
||||
#### URL Examples
|
||||
|
||||
**ntfy** (free, self-hostable):
|
||||
```
|
||||
ntfy://ntfy.sh/your-topic
|
||||
ntfy://user:password@your-server.com/topic
|
||||
```
|
||||
|
||||
**Pushover** (free app for iOS/Android):
|
||||
```
|
||||
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||
```
|
||||
Get your keys at [pushover.net](https://pushover.net/):
|
||||
- **User Key**: Shown on your dashboard (top right)
|
||||
- **API Token**: Create an application → copy the API Token
|
||||
|
||||
**Gotify** (self-hosted):
|
||||
```
|
||||
gotify://your-server.com/TOKEN
|
||||
```
|
||||
|
||||
**Discord**:
|
||||
```
|
||||
discord://TOKEN@WEBHOOK_ID
|
||||
```
|
||||
|
||||
**Telegram**:
|
||||
```
|
||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||
```
|
||||
|
||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1768
-4
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "tsx src/db/migrate.ts"
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
@@ -15,7 +18,7 @@
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/rate-limit": "^10.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@libsql/client": "^0.10.0",
|
||||
@@ -30,7 +33,11 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
+119
-132
@@ -3,46 +3,136 @@ import { drizzle } from "drizzle-orm/libsql";
|
||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import dotenv from "dotenv";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = resolve(cwd, "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL } from "./schema-sql.js";
|
||||
|
||||
/** Run table creation migrations on a client */
|
||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility with older databases
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Database initialization (runs on import)
|
||||
// =============================================================================
|
||||
|
||||
// Use absolute path to ensure it works in Docker
|
||||
const dataDir = resolve(process.cwd(), "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = `file:${dbPath}`;
|
||||
const { dataDir, dbPath, url } = getDbPaths();
|
||||
|
||||
console.log(`[DB] Data directory: ${dataDir}`);
|
||||
console.log(`[DB] Database path: ${dbPath}`);
|
||||
console.log(`[DB] Database URL: ${url}`);
|
||||
|
||||
// Ensure data directory exists and is writable
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
console.log(`[DB] Created data directory: ${dataDir}`);
|
||||
} else {
|
||||
console.log(`[DB] Data directory exists: ${dataDir}`);
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
const dirResult = ensureDataDirectory(dataDir);
|
||||
if (!dirResult.success) {
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[DB] Data directory is writable`);
|
||||
|
||||
// Log directory stats
|
||||
const stats = statSync(dataDir);
|
||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
console.log(`[DB] Write test successful`);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${err.message}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
@@ -59,120 +149,17 @@ export const db = drizzle(client);
|
||||
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
// First, ensure all tables exist (for fresh databases)
|
||||
const tableCreations = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
rotated_at integer,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Table creation error:`, e.message);
|
||||
}
|
||||
const result = await runTableMigrations(client);
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
|
||||
// If auth is disabled, ensure a default user exists (ID=1)
|
||||
const authEnabled = process.env.AUTH_ENABLED === "true";
|
||||
if (!authEnabled) {
|
||||
try {
|
||||
// Check if default user exists
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
}
|
||||
const created = await ensureDefaultUser(client, authEnabled);
|
||||
if (created) {
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-102
@@ -1,10 +1,54 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
/** Get the full migration SQL string (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL };
|
||||
|
||||
/** Split SQL string into individual statements */
|
||||
export function splitSQLStatements(sql: string): string[] {
|
||||
return sql.split(';').filter(s => s.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Execute migration statements on a client */
|
||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const statements = getTableCreationSQL();
|
||||
const errors: string[] = [];
|
||||
let executed = 0;
|
||||
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await client.execute(stmt);
|
||||
executed++;
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, executed, errors };
|
||||
}
|
||||
|
||||
/** Get a preview of statement (first N characters) */
|
||||
export function getStatementPreview(stmt: string, maxLength: number = 50): string {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLI execution (only runs when called directly)
|
||||
// =============================================================================
|
||||
|
||||
const url = "file:./data/medassist-ng.db";
|
||||
|
||||
async function main() {
|
||||
@@ -13,105 +57,10 @@ async function main() {
|
||||
|
||||
const client = createClient({ url });
|
||||
|
||||
// Create tables - fresh schema without roles, with per-user settings
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
rotated_at integer,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
// Execute each statement separately
|
||||
const statements = sql.split(';').filter(s => s.trim().length > 0);
|
||||
const statements = getTableCreationSQL();
|
||||
|
||||
for (const stmt of statements) {
|
||||
console.log("Executing:", stmt.trim().substring(0, 50) + "...");
|
||||
console.log("Executing:", getStatementPreview(stmt));
|
||||
await client.execute(stmt);
|
||||
}
|
||||
|
||||
@@ -119,7 +68,11 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
// Only run main() if this file is executed directly (not imported)
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Shared SQL table creation statements for database initialization.
|
||||
* Used by client.ts, migrate.ts, and test setup to avoid duplication.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all SQL table creation statements as an array.
|
||||
* Each statement creates a table if it doesn't exist.
|
||||
*/
|
||||
export function getTableCreationSQL(): string[] {
|
||||
return [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
rotated_at integer,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
}
|
||||
@@ -60,6 +60,10 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
// Reminder settings
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
||||
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
|
||||
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
|
||||
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||
// Stock thresholds (days)
|
||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||
@@ -109,6 +113,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
+111
-32
@@ -1,14 +1,14 @@
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import helmet from "@fastify/helmet";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import sensible from "@fastify/sensible";
|
||||
import cookie, { CookieSerializeOptions } from "@fastify/cookie";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { resolve } from "path";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { existsSync } from "fs";
|
||||
import { env } from "./plugins/env.js";
|
||||
import { migrationsReady } from "./db/client.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
@@ -19,18 +19,116 @@ import { settingsRoutes } from "./routes/settings.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
|
||||
// Re-export utilities from server-config for external use
|
||||
export {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
import {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
/** Create and configure Fastify app (without starting) */
|
||||
export async function createApp(options?: {
|
||||
logLevel?: string;
|
||||
corsOrigins?: string[];
|
||||
authEnabled?: boolean;
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
cookieSecret?: string;
|
||||
accessTtlMinutes?: number;
|
||||
refreshTtlDays?: number;
|
||||
isProduction?: boolean;
|
||||
imagesDir?: string;
|
||||
}): Promise<FastifyInstance> {
|
||||
const opts = {
|
||||
logLevel: options?.logLevel ?? "info",
|
||||
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
|
||||
authEnabled: options?.authEnabled ?? false,
|
||||
jwtSecret: options?.jwtSecret,
|
||||
refreshSecret: options?.refreshSecret,
|
||||
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
|
||||
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
|
||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||
isProduction: options?.isProduction ?? false,
|
||||
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
});
|
||||
|
||||
// Build config
|
||||
const appConfig = buildAppConfig({
|
||||
jwtSecret: opts.jwtSecret,
|
||||
refreshSecret: opts.refreshSecret,
|
||||
accessTtlMinutes: opts.accessTtlMinutes,
|
||||
refreshTtlDays: opts.refreshTtlDays,
|
||||
isProduction: opts.isProduction,
|
||||
});
|
||||
|
||||
app.decorate("config", appConfig);
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
// JWT plugin
|
||||
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
||||
await app.register(jwt, jwtConfig);
|
||||
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// Only register static if directory exists
|
||||
if (existsSync(opts.imagesDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: opts.imagesDir,
|
||||
prefix: "/images/",
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Register routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Server initialization (runs on import)
|
||||
// =============================================================================
|
||||
|
||||
// Wait for database migrations before anything else
|
||||
await migrationsReady;
|
||||
console.log("[DB] Migrations complete, starting server...");
|
||||
|
||||
// Ensure images directory exists
|
||||
const imagesDir = resolve(process.cwd(), "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
const imagesDir = ensureImagesDirectory();
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -38,24 +136,14 @@ const app = Fastify({
|
||||
},
|
||||
});
|
||||
|
||||
const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
|
||||
// Auth token TTLs (hardcoded - no need for user configuration)
|
||||
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
|
||||
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
|
||||
|
||||
const baseCookieOptions: CookieSerializeOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60,
|
||||
};
|
||||
|
||||
const refreshCookieOptions: CookieSerializeOptions = {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60,
|
||||
};
|
||||
const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production");
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays);
|
||||
|
||||
// Config decorator - only include secrets if auth is enabled
|
||||
app.decorate("config", {
|
||||
@@ -77,18 +165,8 @@ await app.register(rateLimit, {
|
||||
await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" });
|
||||
|
||||
// JWT plugin - only register with valid secret if auth is enabled
|
||||
if (env.AUTH_ENABLED && env.JWT_SECRET) {
|
||||
await app.register(jwt, {
|
||||
secret: env.JWT_SECRET,
|
||||
cookie: { cookieName: "access_token", signed: false }
|
||||
});
|
||||
} else {
|
||||
// Dummy JWT for when auth is disabled - prevents errors
|
||||
await app.register(jwt, {
|
||||
secret: "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false }
|
||||
});
|
||||
}
|
||||
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
|
||||
await app.register(jwt, jwtConfig);
|
||||
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
||||
await app.register(fastifyStatic, {
|
||||
@@ -105,6 +183,7 @@ await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,33 @@ const ARGON2_OPTIONS: argon2.Options = {
|
||||
hashLength: 32, // 256-bit hash
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Rate Limiting Configuration for Auth Routes
|
||||
// =============================================================================
|
||||
// Stricter rate limits for authentication endpoints to prevent brute-force attacks
|
||||
// Note: Rate limiting is implemented via @fastify/rate-limit plugin registered in index.ts
|
||||
// and route-specific limits are applied via the 'config.rateLimit' option below.
|
||||
// CodeQL may not recognize this pattern - see: https://github.com/github/codeql/issues
|
||||
// lgtm[js/missing-rate-limiting]
|
||||
const authRateLimitConfig = {
|
||||
max: 10, // 10 requests
|
||||
timeWindow: "1 minute", // per minute
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many requests. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
};
|
||||
|
||||
// lgtm[js/missing-rate-limiting]
|
||||
const sensitiveRateLimitConfig = {
|
||||
max: 5, // 5 requests
|
||||
timeWindow: "15 minutes", // per 15 minutes (for login/register)
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many attempts. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
@@ -65,7 +92,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/register - User registration
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof registerSchema> }>("/auth/register", async (request, reply) => {
|
||||
app.post<{ Body: z.infer<typeof registerSchema> }>("/auth/register", {
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
// Check auth state
|
||||
const state = await getAuthState();
|
||||
|
||||
@@ -123,7 +152,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/login - User login
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof loginSchema> }>("/auth/login", async (request, reply) => {
|
||||
app.post<{ Body: z.infer<typeof loginSchema> }>("/auth/login", {
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const state = await getAuthState();
|
||||
|
||||
if (!state.authEnabled) {
|
||||
@@ -223,7 +254,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/refresh - Refresh access token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/refresh", async (request, reply) => {
|
||||
app.post("/auth/refresh", {
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
if (!refreshTokenCookie) {
|
||||
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
|
||||
@@ -288,7 +321,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/logout - Logout (revoke refresh token)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/logout", async (request, reply) => {
|
||||
app.post("/auth/logout", {
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
|
||||
if (refreshTokenCookie) {
|
||||
@@ -340,7 +375,10 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /auth/me - Update current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put<{ Body: z.infer<typeof updateProfileSchema> }>("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
|
||||
app.put<{ Body: z.infer<typeof updateProfileSchema> }>("/auth/me", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
@@ -391,7 +429,10 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/avatar - Upload user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => {
|
||||
app.post("/auth/avatar", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
@@ -440,7 +481,10 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/avatar - Delete user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => {
|
||||
app.delete("/auth/avatar", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
|
||||
+104
-1
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
|
||||
const { doseIds } = parsed.data;
|
||||
|
||||
// Insert dismissed records for each dose that doesn't exist yet
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists (taken or dismissed)
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Already exists - update to dismissed if not already
|
||||
if (!existing.dismissed) {
|
||||
await db.update(doseTracking)
|
||||
.set({ dismissed: true })
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Create new dismissed record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.dismissed, true)
|
||||
)
|
||||
);
|
||||
|
||||
for (const d of dismissed) {
|
||||
if (d.markedBy !== null || d.takenAt) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking)
|
||||
.set({ dismissed: false })
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking)
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, userSettings, doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { resolve, extname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
||||
|
||||
const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
||||
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.0";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
// =============================================================================
|
||||
|
||||
const scheduleSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string(), // ISO datetime string
|
||||
remind: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const inventorySchema = z.object({
|
||||
packCount: z.number().int().min(0).default(1),
|
||||
blistersPerPack: z.number().int().min(1).default(1),
|
||||
pillsPerBlister: z.number().int().min(1).default(1),
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
const medicationExportSchema = z.object({
|
||||
_exportId: z.string(),
|
||||
name: z.string().min(1),
|
||||
genericName: z.string().nullable().optional(),
|
||||
takenBy: z.array(z.string()).default([]),
|
||||
inventory: inventorySchema,
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
image: z.string().nullable().optional(), // base64 data URL or null
|
||||
});
|
||||
|
||||
const doseHistorySchema = z.object({
|
||||
medicationRef: z.string(), // References _exportId
|
||||
scheduleIndex: z.number().int().min(0),
|
||||
scheduledTime: z.string(), // ISO datetime
|
||||
takenAt: z.string(), // ISO datetime
|
||||
markedBy: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const shareLinkSchema = z.object({
|
||||
takenBy: z.string().min(1),
|
||||
scheduleDays: z.number().int().min(1).default(30),
|
||||
expiresAt: z.string().nullable().optional(), // ISO datetime
|
||||
regenerateToken: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const settingsExportSchema = z.object({
|
||||
// Email notifications
|
||||
emailEnabled: z.boolean().default(false),
|
||||
notificationEmail: z.string().nullable().optional(),
|
||||
emailStockReminders: z.boolean().default(true),
|
||||
emailIntakeReminders: z.boolean().default(true),
|
||||
// Push notifications
|
||||
shoutrrrEnabled: z.boolean().optional(),
|
||||
shoutrrrUrl: z.string().nullable().optional(),
|
||||
shoutrrrStockReminders: z.boolean().default(true),
|
||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: z.number().int().default(7),
|
||||
repeatDailyReminders: z.boolean().default(false),
|
||||
skipRemindersForTakenDoses: z.boolean().default(false),
|
||||
repeatRemindersEnabled: z.boolean().default(false),
|
||||
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
||||
maxNaggingReminders: z.number().int().default(5),
|
||||
// Stock thresholds
|
||||
lowStockDays: z.number().int().default(30),
|
||||
normalStockDays: z.number().int().default(90),
|
||||
highStockDays: z.number().int().default(180),
|
||||
// UI preferences
|
||||
language: z.string().default("en"),
|
||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
}).optional();
|
||||
|
||||
const importDataSchema = z.object({
|
||||
version: z.string(),
|
||||
exportedAt: z.string(),
|
||||
includeSensitiveData: z.boolean().default(false),
|
||||
medications: z.array(medicationExportSchema).default([]),
|
||||
doseHistory: z.array(doseHistorySchema).default([]),
|
||||
settings: settingsExportSchema,
|
||||
shareLinks: z.array(shareLinkSchema).default([]),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// Parse takenByJson safely
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse blisters from DB format to export format
|
||||
function parseBlistersForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson || "[]") as number[];
|
||||
const every = JSON.parse(row.everyJson || "[]") as number[];
|
||||
const start = JSON.parse(row.startJson || "[]") as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
schedules.push({
|
||||
usage: usage[i],
|
||||
every: every[i],
|
||||
start: start[i],
|
||||
remind: row.intakeRemindersEnabled ?? false,
|
||||
});
|
||||
}
|
||||
return schedules;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Read image file and convert to base64 data URL
|
||||
function imageToBase64(imageUrl: string | null): string | null {
|
||||
if (!imageUrl) return null;
|
||||
const imagePath = resolve(IMAGES_DIR, imageUrl);
|
||||
if (!existsSync(imagePath)) return null;
|
||||
|
||||
try {
|
||||
const imageBuffer = readFileSync(imagePath);
|
||||
const ext = extname(imageUrl).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
};
|
||||
const mimeType = mimeTypes[ext] || "image/jpeg";
|
||||
return `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save base64 image to file and return filename
|
||||
function base64ToImage(base64: string, medicationId: number): string | null {
|
||||
if (!base64 || !base64.startsWith("data:")) return null;
|
||||
|
||||
try {
|
||||
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||
const matches = base64.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
if (!matches) return null;
|
||||
|
||||
const ext = matches[1] === "jpeg" ? "jpg" : matches[1];
|
||||
const data = matches[2];
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
const filename = `med-${medicationId}-${Date.now()}.${ext}`;
|
||||
const filepath = resolve(IMAGES_DIR, filename);
|
||||
|
||||
// Ensure images directory exists
|
||||
if (!existsSync(IMAGES_DIR)) {
|
||||
mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(filepath, buffer);
|
||||
return filename;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse dose ID to extract medication ID and timestamp
|
||||
// Format: "{medicationId}-{blisterIndex}-{timestampMs}"
|
||||
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
const medicationId = parseInt(parts[0], 10);
|
||||
const blisterIndex = parseInt(parts[1], 10);
|
||||
const timestampMs = parseInt(parts[2], 10);
|
||||
|
||||
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
|
||||
|
||||
return { medicationId, blisterIndex, timestampMs };
|
||||
}
|
||||
|
||||
// Build dose ID from parts
|
||||
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
|
||||
return `${medicationId}-${blisterIndex}-${timestampMs}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export Routes
|
||||
// =============================================================================
|
||||
export async function exportRoutes(app: FastifyInstance) {
|
||||
// All export routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /export - Export all user data
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Querystring: { includeSensitive?: string } }>(
|
||||
"/export",
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const includeSensitive = request.query.includeSensitive === "true";
|
||||
|
||||
// 1. Load all medications
|
||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
|
||||
// Build medication ID to export ID mapping
|
||||
const medIdToExportId = new Map<number, string>();
|
||||
const exportMedications = meds.map((med, index) => {
|
||||
const exportId = `med-${index + 1}`;
|
||||
medIdToExportId.set(med.id, exportId);
|
||||
|
||||
return {
|
||||
_exportId: exportId,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
takenBy: parseTakenByJson(med.takenByJson),
|
||||
inventory: {
|
||||
packCount: med.packCount ?? 1,
|
||||
blistersPerPack: med.blistersPerPack ?? 1,
|
||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||
looseTablets: med.looseTablets ?? 0,
|
||||
},
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
schedules: parseBlistersForExport(med),
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
image: imageToBase64(med.imageUrl),
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Load all dose tracking entries
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
|
||||
const exportDoseHistory = doses.map((dose) => {
|
||||
const parsed = parseDoseId(dose.doseId);
|
||||
if (!parsed) return null;
|
||||
|
||||
const exportId = medIdToExportId.get(parsed.medicationId);
|
||||
if (!exportId) return null; // Orphaned dose, skip
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
scheduleIndex: parsed.blisterIndex,
|
||||
scheduledTime: new Date(parsed.timestampMs).toISOString(),
|
||||
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
|
||||
markedBy: dose.markedBy,
|
||||
};
|
||||
}).filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
|
||||
// 3. Load user settings
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const exportSettings = settings ? {
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
// Only include sensitive data if requested
|
||||
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
||||
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
} : undefined;
|
||||
|
||||
// 4. Load share links
|
||||
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
|
||||
const exportShareLinks = shares.map((share) => ({
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
expiresAt: share.expiresAt?.toISOString() ?? null,
|
||||
regenerateToken: true, // Always regenerate tokens on import for security
|
||||
}));
|
||||
|
||||
// Build export object
|
||||
const exportData = {
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications: exportMedications,
|
||||
doseHistory: exportDoseHistory,
|
||||
settings: exportSettings,
|
||||
shareLinks: exportShareLinks,
|
||||
};
|
||||
|
||||
// Set download headers
|
||||
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
reply.header("Content-Type", "application/json");
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
|
||||
return exportData;
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /import - Import user data (replaces all existing data!)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post(
|
||||
"/import",
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// 1. Parse and validate import data
|
||||
const parsed = importDataSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid import data format",
|
||||
details: parsed.error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
const importData = parsed.data;
|
||||
|
||||
// 2. Delete all existing user data (in correct order to respect foreign keys)
|
||||
// Note: CASCADE delete should handle this, but let's be explicit
|
||||
|
||||
// First, delete images for existing medications
|
||||
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
for (const med of existingMeds) {
|
||||
if (med.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
|
||||
if (existsSync(imagePath)) {
|
||||
try { unlinkSync(imagePath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in order: doses, share tokens, medications, settings
|
||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await db.delete(medications).where(eq(medications.userId, userId));
|
||||
await db.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
// 3. Import medications and build ID mapping
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
// Convert schedules back to JSON arrays
|
||||
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
||||
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await db.insert(medications).values({
|
||||
userId,
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
imageUrl: null, // Will be set after image is saved
|
||||
}).returning();
|
||||
|
||||
// Save mapping
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
|
||||
// Save image if present
|
||||
if (med.image) {
|
||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||
if (imageUrl) {
|
||||
await db.update(medications)
|
||||
.set({ imageUrl })
|
||||
.where(eq(medications.id, inserted.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Import dose history with remapped medication IDs
|
||||
for (const dose of importData.doseHistory) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned doses
|
||||
|
||||
// Convert ISO timestamp back to milliseconds for dose ID
|
||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Import settings
|
||||
if (importData.settings) {
|
||||
await db.insert(userSettings).values({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Import share links (with new tokens)
|
||||
for (const share of importData.shareLinks) {
|
||||
// Always generate new token for security
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
await db.insert(shareTokens).values({
|
||||
userId,
|
||||
token,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: {
|
||||
medications: importData.medications.length,
|
||||
doseHistory: importData.doseHistory.length,
|
||||
settings: importData.settings ? 1 : 0,
|
||||
shareLinks: importData.shareLinks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||
const stripsNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
||||
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
||||
|
||||
// Calculate current stock using realistic consumption order (loose first, then blisters)
|
||||
const consumed = originalTotalPills - currentPills;
|
||||
@@ -373,8 +373,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
medicationName: row.name,
|
||||
totalPills: currentPills,
|
||||
plannerUsage: usageTotal,
|
||||
stripSize: pillsPerBlister,
|
||||
stripsNeeded,
|
||||
blisterSize: pillsPerBlister,
|
||||
blistersNeeded,
|
||||
fullBlisters,
|
||||
loosePills,
|
||||
enough,
|
||||
|
||||
@@ -7,14 +7,27 @@ import type { AuthUser } from "../types/fastify.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
|
||||
// Escape HTML to prevent XSS in email templates
|
||||
function escapeHtml(text: string): string {
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, char => htmlEscapes[char] || char);
|
||||
}
|
||||
|
||||
type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
stripSize: number;
|
||||
stripsNeeded: number;
|
||||
stripsAvailable: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
};
|
||||
|
||||
@@ -99,11 +112,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.medicationName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${escapeHtml(row.medicationName)}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.totalPills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsNeeded} × ${row.stripSize}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsAvailable}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.blistersNeeded} × ${row.blisterSize}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.fullBlisters}${row.loosePills > 0 ? ` (+${row.loosePills})` : ""}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
row.enough
|
||||
@@ -169,7 +182,7 @@ Supply overview from ${fromDate} to ${untilDate}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.stripsAvailable} blisters available (${r.stripsNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")}
|
||||
${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.fullBlisters} blisters available${r.loosePills > 0 ? ` (+${r.loosePills} loose)` : ""} (${r.blistersNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")}
|
||||
|
||||
---
|
||||
Sent from MedAssist-ng Medication Planner`;
|
||||
@@ -280,7 +293,7 @@ Sent from MedAssist-ng Medication Planner`;
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${escapeHtml(row.name)}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
|
||||
|
||||
+120
-23
@@ -21,6 +21,10 @@ export type UserSettings = {
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
@@ -45,6 +49,10 @@ type SettingsBody = {
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
};
|
||||
@@ -57,37 +65,58 @@ type TestShoutrrrBody = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Default settings for new users
|
||||
const defaultSettings = {
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic" as const,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
// Helper to parse boolean env vars
|
||||
function envBool(key: string, defaultVal: boolean): boolean {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
return val === "true" || val === "1";
|
||||
}
|
||||
|
||||
// Helper to parse integer env vars
|
||||
function envInt(key: string, defaultVal: number): number {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
const parsed = parseInt(val, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
|
||||
// Default settings for new users - read from ENV with fallbacks
|
||||
function getDefaultSettings() {
|
||||
return {
|
||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||||
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||||
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||||
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||||
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||||
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get or create user settings
|
||||
async function getOrCreateUserSettings(userId: number) {
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings for user
|
||||
// Create default settings for user (using ENV defaults)
|
||||
[settings] = await db.insert(userSettings).values({
|
||||
userId,
|
||||
...defaultSettings,
|
||||
...getDefaultSettings(),
|
||||
}).returning();
|
||||
}
|
||||
|
||||
@@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
@@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
@@ -327,9 +372,61 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL to prevent SSRF attacks
|
||||
function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: string } {
|
||||
try {
|
||||
// Convert ntfy:// to https:// for parsing
|
||||
const normalizedUrl = urlStr.startsWith("ntfy://")
|
||||
? urlStr.replace("ntfy://", "https://")
|
||||
: urlStr;
|
||||
|
||||
const parsed = new URL(normalizedUrl);
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return { allowed: false, error: "Only HTTP/HTTPS protocols are allowed" };
|
||||
}
|
||||
|
||||
// Block private/internal IP addresses
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
return { allowed: false, error: "Localhost URLs are not allowed" };
|
||||
}
|
||||
|
||||
// Block private IP ranges (basic check)
|
||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipMatch) {
|
||||
const [, a, b] = ipMatch.map(Number);
|
||||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
||||
if (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) || (a === 169 && b === 254)) {
|
||||
return { allowed: false, error: "Private IP addresses are not allowed" };
|
||||
}
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (hostname.endsWith('.local') || hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.lan') || hostname === 'metadata.google.internal') {
|
||||
return { allowed: false, error: "Internal hostnames are not allowed" };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch {
|
||||
return { allowed: false, error: "Invalid URL format" };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Validate URL to prevent SSRF
|
||||
const validation = isAllowedNotificationUrl(urlStr);
|
||||
if (!validation.allowed) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
let targetUrl: string;
|
||||
let method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
|
||||
@@ -1,145 +1,59 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, gte, lte } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import { medications, doseTracking } from "../db/schema.js";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
||||
|
||||
type Blister = { usage: number; every: number; start: string };
|
||||
|
||||
type IntakeReminderState = {
|
||||
sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders
|
||||
};
|
||||
// Import shared utilities
|
||||
import {
|
||||
getTimezone,
|
||||
parseBlisters,
|
||||
parseTakenByJson,
|
||||
getUpcomingIntakes,
|
||||
getTodaysIntakes,
|
||||
parseIntakeReminderState,
|
||||
createDefaultIntakeReminderState,
|
||||
cleanOldIntakeReminders,
|
||||
type Blister,
|
||||
type IntakeReminderState,
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
|
||||
// Get current timezone from TZ env variable or default to UTC
|
||||
function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
// Parse takenByJson to array of strings
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
|
||||
|
||||
function loadIntakeReminderState(): IntakeReminderState {
|
||||
try {
|
||||
if (existsSync(intakeReminderStateFile)) {
|
||||
const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||
return {
|
||||
sentReminders: saved.sentReminders ?? [],
|
||||
};
|
||||
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { sentReminders: [] };
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
|
||||
function saveIntakeReminderState(state: IntakeReminderState): void {
|
||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type UpcomingIntake = {
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string[]; // Changed to array
|
||||
pillWeightMg: number | null;
|
||||
};
|
||||
|
||||
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string[], pillWeightMg: number | null, locale: string): UpcomingIntake[] {
|
||||
const now = Date.now();
|
||||
// Window to detect if "now" is the right time to send reminder
|
||||
// We check if the notify time (intake - 15min) falls within current minute ±1
|
||||
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
|
||||
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence is within the reminder window, use it
|
||||
// (intake hasn't happened yet, we should remind)
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: getTimezone()
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
|
||||
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> {
|
||||
async function sendIntakeReminderEmail(
|
||||
email: string,
|
||||
intakes: UpcomingIntake[],
|
||||
language: Language,
|
||||
isRepeat: boolean = false,
|
||||
repeatIntervalMinutes?: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
@@ -189,11 +103,16 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
||||
? tr.intakeReminder.alertSingle
|
||||
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
|
||||
|
||||
// Different description for repeat reminders
|
||||
const description = isRepeat && repeatIntervalMinutes
|
||||
? `⚠️ Don't forget your medication! This reminder will be sent every ${repeatIntervalMinutes} minutes until you mark it as taken.`
|
||||
: t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE });
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${description}</p>
|
||||
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
|
||||
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
|
||||
@@ -235,7 +154,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
||||
|
||||
const plainText = `${tr.intakeReminder.title}
|
||||
|
||||
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
|
||||
${description}
|
||||
|
||||
${intakes.map((i) => {
|
||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||
@@ -245,7 +164,9 @@ ${intakes.map((i) => {
|
||||
---
|
||||
${tr.intakeReminder.footer}`;
|
||||
|
||||
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
|
||||
const subject = isRepeat
|
||||
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") })}`
|
||||
: t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -274,13 +195,18 @@ ${tr.intakeReminder.footer}`;
|
||||
}
|
||||
|
||||
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
||||
logger.info(`[IntakeReminder] Checking for intake reminders...`);
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.info(`[IntakeReminder] No users with settings found`);
|
||||
return; // No users with settings
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||
}
|
||||
@@ -293,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const language = settings.language;
|
||||
const tr = getTranslations(language);
|
||||
|
||||
logger.info(`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`);
|
||||
|
||||
// Check if any intake reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
|
||||
return; // No intake reminder notifications enabled for this user
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
|
||||
|
||||
// Get all medications with intake reminders enabled for this user
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
|
||||
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
||||
|
||||
if (medsWithReminders.length === 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||
return; // No medications have reminders enabled for this user
|
||||
}
|
||||
|
||||
const state = loadIntakeReminderState();
|
||||
const allUpcoming: UpcomingIntake[] = [];
|
||||
const locale = getDateLocale(language);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
|
||||
|
||||
// Find all upcoming intakes across all medications for this user
|
||||
const state = loadIntakeReminderState();
|
||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
|
||||
|
||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||
for (const med of medsWithReminders) {
|
||||
const blisters = parseBlisters(med);
|
||||
const blisters = parseBlistersFromRow(med);
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale);
|
||||
allUpcoming.push(...upcoming);
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`);
|
||||
|
||||
// Process each blister separately to track blisterIndex
|
||||
blisters.forEach((blister, blisterIndex) => {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}`);
|
||||
|
||||
// Always get upcoming intakes (15 min before) for first reminders
|
||||
const upcomingIntakes = getUpcomingIntakes(med.name, [blister], REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale, tz);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`);
|
||||
|
||||
// Add upcoming intakes for first reminders
|
||||
allUpcoming.push(...upcomingIntakes.map(intake => ({
|
||||
...intake,
|
||||
medicationId: med.id,
|
||||
blisterIndex,
|
||||
})));
|
||||
|
||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||
if (settings.repeatRemindersEnabled) {
|
||||
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map(i => i.intakeTime.toISOString()).join(', ')}`);
|
||||
const missedIntakes = allTodaysIntakes.filter(intake => intake.intakeTime.getTime() < now.getTime());
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)`);
|
||||
|
||||
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||
const upcomingTimes = new Set(upcomingIntakes.map(i => i.intakeTime.getTime()));
|
||||
allUpcoming.push(...missedIntakes
|
||||
.filter(intake => !upcomingTimes.has(intake.intakeTime.getTime()))
|
||||
.map(intake => ({
|
||||
...intake,
|
||||
medicationId: med.id,
|
||||
blisterIndex,
|
||||
})));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
|
||||
|
||||
if (allUpcoming.length === 0) {
|
||||
return; // No upcoming intakes in the window
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
|
||||
return; // No upcoming intakes for today
|
||||
}
|
||||
|
||||
// Filter out already-sent reminders (keyed by user)
|
||||
const newReminders = allUpcoming.filter(intake => {
|
||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||
return !state.sentReminders.includes(key);
|
||||
});
|
||||
// Determine which doses need reminders (new or repeated)
|
||||
const nowMs = Date.now();
|
||||
let remindersToSend: typeof allUpcoming = [];
|
||||
|
||||
if (newReminders.length === 0) {
|
||||
return; // All reminders already sent
|
||||
for (const intake of allUpcoming) {
|
||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||
const existingEntry = state.reminders[key];
|
||||
const intakeTimeMs = intake.intakeTime.getTime();
|
||||
const isIntakePast = intakeTimeMs < nowMs;
|
||||
|
||||
if (!existingEntry) {
|
||||
// New dose - always send first reminder (upcoming or already missed)
|
||||
remindersToSend.push(intake);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: First reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${isIntakePast ? 'missed' : 'upcoming'})`);
|
||||
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
||||
// Repeat reminder - only for intakes that are already past (missed)
|
||||
const intervalMs = settings.reminderRepeatIntervalMinutes * 60 * 1000;
|
||||
const timeSinceLastReminder = nowMs - existingEntry.lastSentAt;
|
||||
const maxReminders = settings.maxNaggingReminders ?? 5;
|
||||
|
||||
if (existingEntry.sendCount >= maxReminders) {
|
||||
// Max reminders reached - stop nagging
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Max reminders (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`);
|
||||
} else if (timeSinceLastReminder >= intervalMs) {
|
||||
remindersToSend.push(intake);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Repeat reminder for missed "${intake.medName}" at ${intake.intakeTimeStr} (${existingEntry.sendCount + 1}/${maxReminders})`);
|
||||
}
|
||||
}
|
||||
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
||||
}
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
return; // All reminders already sent and no repeats needed
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`);
|
||||
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
|
||||
if (settings.skipRemindersForTakenDoses) {
|
||||
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
|
||||
const takenToday = await db.select().from(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, settings.userId),
|
||||
gte(doseTracking.takenAt, todayStart),
|
||||
lte(doseTracking.takenAt, todayEnd)
|
||||
)
|
||||
);
|
||||
|
||||
const takenDoseIds = new Set(takenToday.map(d => d.doseId));
|
||||
|
||||
// Filter out reminders for doses that were already taken
|
||||
remindersToSend = remindersToSend.filter(intake => {
|
||||
const timestamp = intake.intakeTime.getTime();
|
||||
|
||||
// Check both with and without person suffix
|
||||
if (intake.takenBy.length > 0) {
|
||||
// For multi-person medications, check if any person has taken it
|
||||
const anyTaken = intake.takenBy.some(person => {
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
|
||||
return takenDoseIds.has(doseId);
|
||||
});
|
||||
return !anyTaken; // Skip if any person has taken it
|
||||
} else {
|
||||
// For non-person-specific medications
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
|
||||
return !takenDoseIds.has(doseId);
|
||||
}
|
||||
});
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
|
||||
|
||||
// Determine if this is a repeat reminder:
|
||||
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||
// - OR intake is past even without state entry (missed the 15-min window)
|
||||
const isRepeatReminder = remindersToSend.some(intake => {
|
||||
const intakeTimeMs = intake.intakeTime.getTime();
|
||||
const isIntakePast = intakeTimeMs < nowMs;
|
||||
return isIntakePast; // Use repeat message for ANY missed intake
|
||||
});
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
// Send email if enabled for intake reminders
|
||||
if (emailEnabled) {
|
||||
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language);
|
||||
const result = await sendIntakeReminderEmail(
|
||||
settings.notificationEmail!,
|
||||
remindersToSend,
|
||||
language,
|
||||
isRepeatReminder,
|
||||
settings.reminderRepeatIntervalMinutes
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
||||
@@ -353,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// Send Shoutrrr notification if enabled for intake reminders
|
||||
if (shoutrrrEnabled) {
|
||||
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
||||
const message = newReminders
|
||||
const title = isRepeatReminder
|
||||
? (language === 'de' ? '⚠️ Medikamenten-Erinnerung' : '⚠️ Medication Reminder')
|
||||
: t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
||||
|
||||
const repeatNote = isRepeatReminder && settings.reminderRepeatIntervalMinutes
|
||||
? `\n\n⚠️ This reminder will be sent every ${settings.reminderRepeatIntervalMinutes} minutes until marked as taken.`
|
||||
: '';
|
||||
|
||||
const message = remindersToSend
|
||||
.map((i) => {
|
||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
||||
@@ -364,7 +431,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
}
|
||||
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
|
||||
})
|
||||
.join("\n");
|
||||
.join("\n") + repeatNote;
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
@@ -377,18 +444,32 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// Update state if any notification was sent successfully
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`);
|
||||
// Update or create entries for sent reminders
|
||||
for (const intake of remindersToSend) {
|
||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||
const existing = state.reminders[key];
|
||||
|
||||
if (existing) {
|
||||
// Update existing entry (repeat)
|
||||
state.reminders[key] = {
|
||||
firstSentAt: existing.firstSentAt,
|
||||
lastSentAt: nowMs,
|
||||
sendCount: existing.sendCount + 1,
|
||||
};
|
||||
} else {
|
||||
// Create new entry (first send)
|
||||
state.reminders[key] = {
|
||||
firstSentAt: nowMs,
|
||||
lastSentAt: nowMs,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old entries (older than 24 hours)
|
||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const cleanedReminders = state.sentReminders.filter(key => {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
return timestamp > oneDayAgo;
|
||||
});
|
||||
// Clean up old entries (remove doses from past days)
|
||||
state.reminders = cleanOldIntakeReminders(state.reminders, tz);
|
||||
|
||||
saveIntakeReminderState({
|
||||
sentReminders: [...cleanedReminders, ...newKeys],
|
||||
});
|
||||
saveIntakeReminderState(state);
|
||||
|
||||
// Update global reminder state for UI display
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
|
||||
@@ -1,155 +1,42 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, users, userSettings } from "../db/schema.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||
import { getTranslations, t, type Language } from "../i18n/translations.js";
|
||||
|
||||
type Blister = { usage: number; every: number; start: string };
|
||||
|
||||
type ReminderState = {
|
||||
lastAutoEmailSent: string | null; // ISO date string
|
||||
lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today
|
||||
notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked)
|
||||
nextScheduledCheck: string | null; // ISO date string for when the next check is scheduled
|
||||
lastNotificationType: "stock" | "intake" | null; // Type of last notification
|
||||
lastNotificationChannel: "email" | "push" | "both" | null; // Channel used for last notification
|
||||
};
|
||||
// Import shared utilities
|
||||
import {
|
||||
getTimezone,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getTodayInTimezone,
|
||||
getNextScheduledTime,
|
||||
getMsUntilNextCheck,
|
||||
parseBlisters,
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
parseReminderState,
|
||||
createDefaultReminderState,
|
||||
type Blister,
|
||||
type ReminderState,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||
|
||||
// Get current timezone from TZ env variable or default to UTC
|
||||
function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
// Format a date in the configured timezone
|
||||
function formatInTimezone(date: Date): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
// Get current hour in the configured timezone
|
||||
function getCurrentHourInTimezone(): number {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
}
|
||||
|
||||
// Get today's date string in the configured timezone (YYYY-MM-DD)
|
||||
function getTodayInTimezone(): string {
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
function getNextScheduledTime(): Date {
|
||||
const now = new Date();
|
||||
const tz = getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > REMINDER_HOUR || (currentHour === REMINDER_HOUR && currentMinute > 0);
|
||||
|
||||
// Get the date we want to schedule for
|
||||
const year = parseInt(getPart("year"), 10);
|
||||
const month = parseInt(getPart("month"), 10);
|
||||
let day = parseInt(getPart("day"), 10);
|
||||
|
||||
if (needTomorrow) {
|
||||
day += 1;
|
||||
}
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to REMINDER_HOUR:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, REMINDER_HOUR, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - REMINDER_HOUR;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
}
|
||||
|
||||
function getMsUntilNextCheck(): number {
|
||||
const next = getNextScheduledTime();
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
const saved = JSON.parse(readFileSync(reminderStateFile, "utf-8"));
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null, lastNotificationType: null, lastNotificationChannel: null };
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
|
||||
function saveReminderState(state: ReminderState): void {
|
||||
@@ -188,39 +75,8 @@ export async function updateUserReminderSentTime(
|
||||
.where(eq(userSettings.userId, userId));
|
||||
}
|
||||
|
||||
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDailyUsage(blisters: Blister[]): number {
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
}
|
||||
|
||||
function calculateDepletionInfo(med: { count: number; blisters: Blister[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } {
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
@@ -236,7 +92,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlisters(row);
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
@@ -486,8 +342,8 @@ async function checkAndSendReminderForUser(
|
||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
||||
const msUntilNext = getMsUntilNextCheck();
|
||||
const nextTime = getNextScheduledTime();
|
||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||
const nextTime = getNextScheduledTime(REMINDER_HOUR);
|
||||
|
||||
// Save next scheduled time to state
|
||||
const state = loadReminderState();
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* E2E Tests for auth routes with AUTH_ENABLED=true
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
|
||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return { testClient: client, testDb: db };
|
||||
});
|
||||
|
||||
// Mock modules using the hoisted db
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
// Enable auth for these tests
|
||||
vi.mock("../plugins/env.js", () => ({
|
||||
env: {
|
||||
AUTH_ENABLED: true,
|
||||
LOCAL_AUTH_ENABLED: true,
|
||||
REGISTRATION_ENABLED: true,
|
||||
OIDC_ENABLED: false,
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
JWT_SECRET: "test-jwt-secret-12345",
|
||||
REFRESH_SECRET: "test-refresh-secret-12345",
|
||||
COOKIE_SECRET: "test-cookie-secret-12345",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
},
|
||||
}));
|
||||
|
||||
// Import real auth plugin and routes
|
||||
const { authRoutes } = await import("../routes/auth.js");
|
||||
|
||||
// =============================================================================
|
||||
// Test Setup
|
||||
// =============================================================================
|
||||
|
||||
async function createSchema(client: Client) {
|
||||
const tableCreations = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
rotated_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
await client.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM refresh_tokens");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await createSchema(testClient);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret-12345" });
|
||||
await app.register(jwt, {
|
||||
secret: "test-jwt-secret-12345",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
|
||||
// Decorate with config needed by auth routes
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret-12345",
|
||||
refreshSecret: "test-refresh-secret-12345",
|
||||
accessTtl: 15,
|
||||
refreshTtl: 7,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/", maxAge: 15 * 60 },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth", maxAge: 7 * 24 * 60 * 60 },
|
||||
});
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearData(testClient);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth State Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /auth/state", () => {
|
||||
it("should return auth state", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/state",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.authEnabled).toBe(true);
|
||||
expect(data.registrationEnabled).toBe(true);
|
||||
expect(data.localAuthEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /auth/register", () => {
|
||||
it("should register a new user", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "testuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const data = response.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.user.username).toBe("testuser");
|
||||
});
|
||||
|
||||
it("should reject duplicate username", async () => {
|
||||
// First registration
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "duplicate",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
// Second registration with same username
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "duplicate",
|
||||
password: "AnotherPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject short password", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "testuser",
|
||||
password: "short",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should reject short username", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "ab",
|
||||
password: "ValidPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should reject invalid username characters", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "test@user",
|
||||
password: "ValidPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /auth/login", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "loginuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should login with valid credentials", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "loginuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.user.username).toBe("loginuser");
|
||||
|
||||
// Should set cookies
|
||||
const cookies = response.cookies;
|
||||
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
||||
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject invalid password", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "loginuser",
|
||||
password: "WrongPassword",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||
});
|
||||
|
||||
it("should reject non-existent user", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "nonexistent",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||
});
|
||||
|
||||
it("should support rememberMe option", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "loginuser",
|
||||
password: "TestPassword123",
|
||||
rememberMe: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Refresh Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /auth/refresh", () => {
|
||||
it("should refresh access token with valid refresh token", async () => {
|
||||
// Login first to get tokens
|
||||
const loginResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "loginuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
// Need to create user first
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "refreshuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "refreshuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/refresh",
|
||||
cookies: {
|
||||
refresh_token: refreshToken?.value ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject without refresh token", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/refresh",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("NO_REFRESH_TOKEN");
|
||||
});
|
||||
|
||||
it("should reject invalid refresh token", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/refresh",
|
||||
cookies: {
|
||||
refresh_token: "invalid-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("INVALID_REFRESH_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /auth/logout", () => {
|
||||
it("should logout and clear cookies", async () => {
|
||||
// Register and login first
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "logoutuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "logoutuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
cookies: {
|
||||
refresh_token: refreshToken?.value ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed even without refresh token", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Me Endpoint Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /auth/me", () => {
|
||||
it("should return user info with valid access token", async () => {
|
||||
// Register and login
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "meuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "meuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/me",
|
||||
cookies: {
|
||||
access_token: accessToken?.value ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.username).toBe("meuser");
|
||||
});
|
||||
|
||||
it("should reject without access token", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/me",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject with invalid access token", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/me",
|
||||
cookies: {
|
||||
access_token: "invalid.jwt.token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inactive User Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Inactive user handling", () => {
|
||||
it("should reject login for inactive user", async () => {
|
||||
// Create user
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "inactiveuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
// Manually deactivate user in DB
|
||||
await testClient.execute({
|
||||
sql: "UPDATE users SET is_active = 0 WHERE username = ?",
|
||||
args: ["inactiveuser"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "inactiveuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("ACCOUNT_DISABLED");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile Update Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PUT /auth/me (profile update)", () => {
|
||||
it("should update password with valid current password", async () => {
|
||||
// Register and login
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "profileuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "profileuser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/auth/me",
|
||||
cookies: {
|
||||
access_token: accessToken?.value ?? "",
|
||||
},
|
||||
payload: {
|
||||
currentPassword: "TestPassword123",
|
||||
newPassword: "NewPassword456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
|
||||
// Verify can login with new password
|
||||
const newLogin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "profileuser",
|
||||
password: "NewPassword456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(newLogin.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject password change without current password", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "profileuser2",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "profileuser2",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/auth/me",
|
||||
cookies: {
|
||||
access_token: accessToken?.value ?? "",
|
||||
},
|
||||
payload: {
|
||||
newPassword: "NewPassword456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("CURRENT_PASSWORD_REQUIRED");
|
||||
});
|
||||
|
||||
it("should reject password change with wrong current password", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "profileuser3",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "profileuser3",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/auth/me",
|
||||
cookies: {
|
||||
access_token: accessToken?.value ?? "",
|
||||
},
|
||||
payload: {
|
||||
currentPassword: "WrongPassword",
|
||||
newPassword: "NewPassword456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().code).toBe("INVALID_PASSWORD");
|
||||
});
|
||||
|
||||
it("should reject profile update without auth", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/auth/me",
|
||||
payload: {
|
||||
currentPassword: "Test123",
|
||||
newPassword: "NewPassword456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,897 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
// Import the exported utility functions from client.ts
|
||||
import {
|
||||
buildDbUrl,
|
||||
getDbPaths,
|
||||
ensureDataDirectory,
|
||||
getTableCreationSQL,
|
||||
runTableMigrations,
|
||||
ensureDefaultUser,
|
||||
} from "../db/client.js";
|
||||
|
||||
// Import the exported utility functions from migrate.ts
|
||||
import {
|
||||
getTableCreationSQL as getTableCreationSQLFromMigrate,
|
||||
splitSQLStatements,
|
||||
executeMigration,
|
||||
getStatementPreview,
|
||||
} from "../db/migrate.js";
|
||||
|
||||
describe("Migration Script Utilities", () => {
|
||||
describe("getTableCreationSQL", () => {
|
||||
it("should return a non-empty array of SQL statements", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(Array.isArray(statements)).toBe(true);
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should contain all table definitions", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const allSQL = statements.join(" ");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitSQLStatements", () => {
|
||||
it("should split SQL by semicolons", () => {
|
||||
const sql = "SELECT 1; SELECT 2; SELECT 3;";
|
||||
const statements = splitSQLStatements(sql);
|
||||
expect(statements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should filter out empty statements", () => {
|
||||
const sql = "SELECT 1;; ; SELECT 2;";
|
||||
const statements = splitSQLStatements(sql);
|
||||
expect(statements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle statements without trailing semicolon", () => {
|
||||
const sql = "SELECT 1; SELECT 2";
|
||||
const statements = splitSQLStatements(sql);
|
||||
expect(statements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle getTableCreationSQL output correctly", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(statements).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should preserve whitespace within statements", () => {
|
||||
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
||||
const statements = splitSQLStatements(sql);
|
||||
expect(statements[0]).toContain("\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatementPreview", () => {
|
||||
it("should return full string if shorter than maxLength", () => {
|
||||
const preview = getStatementPreview("SELECT 1", 50);
|
||||
expect(preview).toBe("SELECT 1");
|
||||
});
|
||||
|
||||
it("should truncate and add ellipsis if longer than maxLength", () => {
|
||||
const preview = getStatementPreview("SELECT * FROM very_long_table_name WHERE condition = true", 20);
|
||||
expect(preview).toBe("SELECT * FROM very_l...");
|
||||
expect(preview.length).toBe(23); // 20 + "..."
|
||||
});
|
||||
|
||||
it("should use default maxLength of 50", () => {
|
||||
const longStmt = "A".repeat(100);
|
||||
const preview = getStatementPreview(longStmt);
|
||||
expect(preview).toBe("A".repeat(50) + "...");
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const preview = getStatementPreview(" SELECT 1 ", 50);
|
||||
expect(preview).toBe("SELECT 1");
|
||||
});
|
||||
|
||||
it("should handle CREATE TABLE statements", () => {
|
||||
const stmt = "CREATE TABLE IF NOT EXISTS users (id integer PRIMARY KEY)";
|
||||
const preview = getStatementPreview(stmt, 30);
|
||||
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeMigration", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
});
|
||||
|
||||
it("should execute all migrations successfully", async () => {
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executed).toBe(6);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create all tables", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
);
|
||||
|
||||
const tableNames = tables.rows.map(r => r.name);
|
||||
expect(tableNames).toContain("users");
|
||||
expect(tableNames).toContain("medications");
|
||||
expect(tableNames).toContain("user_settings");
|
||||
expect(tableNames).toContain("refresh_tokens");
|
||||
expect(tableNames).toContain("share_tokens");
|
||||
expect(tableNames).toContain("dose_tracking");
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await executeMigration(client);
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executed).toBe(6);
|
||||
});
|
||||
|
||||
it("should allow inserting data after migration", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
const result = await client.execute("SELECT * FROM users");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Database Client Utilities", () => {
|
||||
describe("buildDbUrl", () => {
|
||||
it("should build a file:// URL from path", () => {
|
||||
const url = buildDbUrl("/path/to/db.sqlite");
|
||||
expect(url).toBe("file:/path/to/db.sqlite");
|
||||
});
|
||||
|
||||
it("should handle relative paths", () => {
|
||||
const url = buildDbUrl("./data/test.db");
|
||||
expect(url).toBe("file:./data/test.db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDbPaths", () => {
|
||||
it("should return correct paths based on cwd", () => {
|
||||
const paths = getDbPaths("/app");
|
||||
expect(paths.dataDir).toBe("/app/data");
|
||||
expect(paths.dbPath).toBe("/app/data/medassist-ng.db");
|
||||
expect(paths.url).toBe("file:/app/data/medassist-ng.db");
|
||||
});
|
||||
|
||||
it("should use process.cwd() by default", () => {
|
||||
const paths = getDbPaths();
|
||||
expect(paths.dataDir).toContain("data");
|
||||
expect(paths.dbPath).toContain("medassist-ng.db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureDataDirectory", () => {
|
||||
const testDir = resolve(tmpdir(), `test-data-dir-${Date.now()}`);
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should create directory if it does not exist", () => {
|
||||
const result = ensureDataDirectory(testDir);
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(testDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed if directory already exists", () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const result = ensureDataDirectory(testDir);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should create .write-test file", () => {
|
||||
const result = ensureDataDirectory(testDir);
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(resolve(testDir, ".write-test"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error for invalid path", () => {
|
||||
// Try to create in a path that can't exist
|
||||
const result = ensureDataDirectory("/nonexistent/root/path/that/cannot/exist");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTableCreationSQL", () => {
|
||||
it("should return array of SQL statements", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(Array.isArray(statements)).toBe(true);
|
||||
expect(statements.length).toBe(6);
|
||||
});
|
||||
|
||||
it("should include users table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
|
||||
expect(usersSQL).toBeDefined();
|
||||
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
|
||||
expect(usersSQL).toContain("password_hash text");
|
||||
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
|
||||
});
|
||||
|
||||
it("should include medications table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
|
||||
expect(medsSQL).toBeDefined();
|
||||
expect(medsSQL).toContain("user_id integer NOT NULL");
|
||||
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
|
||||
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
|
||||
});
|
||||
|
||||
it("should include user_settings table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
|
||||
expect(settingsSQL).toBeDefined();
|
||||
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
|
||||
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
|
||||
});
|
||||
|
||||
it("should include refresh_tokens table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
|
||||
expect(tokensSQL).toBeDefined();
|
||||
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
|
||||
});
|
||||
|
||||
it("should include share_tokens table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
|
||||
expect(shareSQL).toBeDefined();
|
||||
expect(shareSQL).toContain("taken_by text NOT NULL");
|
||||
});
|
||||
|
||||
it("should include dose_tracking table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
|
||||
expect(doseSQL).toBeDefined();
|
||||
expect(doseSQL).toContain("dose_id text NOT NULL");
|
||||
expect(doseSQL).toContain("marked_by text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTableMigrations", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
});
|
||||
|
||||
it("should create all tables successfully", async () => {
|
||||
const result = await runTableMigrations(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should be idempotent (run twice without errors)", async () => {
|
||||
await runTableMigrations(client);
|
||||
const result = await runTableMigrations(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create all 6 tables", async () => {
|
||||
await runTableMigrations(client);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
);
|
||||
|
||||
const tableNames = tables.rows.map(r => r.name);
|
||||
expect(tableNames).toContain("users");
|
||||
expect(tableNames).toContain("medications");
|
||||
expect(tableNames).toContain("user_settings");
|
||||
expect(tableNames).toContain("refresh_tokens");
|
||||
expect(tableNames).toContain("share_tokens");
|
||||
expect(tableNames).toContain("dose_tracking");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureDefaultUser", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await runTableMigrations(client);
|
||||
});
|
||||
|
||||
it("should create default user when auth is disabled", async () => {
|
||||
const created = await ensureDefaultUser(client, false);
|
||||
expect(created).toBe(true);
|
||||
|
||||
const result = await client.execute("SELECT * FROM users WHERE id = 1");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].username).toBe("default");
|
||||
expect(result.rows[0].auth_provider).toBe("local");
|
||||
});
|
||||
|
||||
it("should not create user when auth is enabled", async () => {
|
||||
const created = await ensureDefaultUser(client, true);
|
||||
expect(created).toBe(false);
|
||||
|
||||
const result = await client.execute("SELECT * FROM users WHERE id = 1");
|
||||
expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not duplicate user if already exists", async () => {
|
||||
// First call creates the user
|
||||
await ensureDefaultUser(client, false);
|
||||
|
||||
// Second call should not create again
|
||||
const created = await ensureDefaultUser(client, false);
|
||||
expect(created).toBe(false);
|
||||
|
||||
// Should still have only one user
|
||||
const result = await client.execute("SELECT * FROM users");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Database Client", () => {
|
||||
describe("In-Memory Database Creation", () => {
|
||||
it("should create an in-memory SQLite client", () => {
|
||||
const client = createClient({ url: ":memory:" });
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a drizzle instance from client", () => {
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it("should execute SQL statements", async () => {
|
||||
const client = createClient({ url: ":memory:" });
|
||||
|
||||
// Create a simple test table
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS test_table (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert a row
|
||||
await client.execute("INSERT INTO test_table (name) VALUES ('test')");
|
||||
|
||||
// Query the row
|
||||
const result = await client.execute("SELECT * FROM test_table");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].name).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table Schema Creation", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
});
|
||||
|
||||
it("should create users table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Verify table exists
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create medications table with foreign key", async () => {
|
||||
// First create users table
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='medications'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create user_settings table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create refresh_tokens table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
rotated_at integer,
|
||||
revoked integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create share_tokens table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should create dose_tracking table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on username", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await expect(
|
||||
client.execute("INSERT INTO users (username) VALUES ('testuser')")
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on refresh token_id", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default Values", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should use default values for auth_provider", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
const result = await client.execute("SELECT auth_provider FROM users WHERE username = 'testuser'");
|
||||
expect(result.rows[0].auth_provider).toBe("local");
|
||||
});
|
||||
|
||||
it("should use default values for is_active", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
||||
expect(result.rows[0].is_active).toBe(1);
|
||||
});
|
||||
|
||||
it("should generate created_at timestamp", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
|
||||
expect(typeof result.rows[0].created_at).toBe("number");
|
||||
// Should be a reasonable Unix timestamp (after year 2020)
|
||||
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Settings Defaults", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should use default notification settings", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].email_enabled).toBe(0);
|
||||
expect(result.rows[0].shoutrrr_enabled).toBe(0);
|
||||
});
|
||||
|
||||
it("should use default stock threshold settings", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].low_stock_days).toBe(30);
|
||||
expect(result.rows[0].normal_stock_days).toBe(90);
|
||||
expect(result.rows[0].high_stock_days).toBe(180);
|
||||
expect(result.rows[0].expiry_warning_days).toBe(90);
|
||||
});
|
||||
|
||||
it("should use default language (en)", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT language FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].language).toBe("en");
|
||||
});
|
||||
|
||||
it("should use default stock_calculation_mode (automatic)", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT stock_calculation_mode FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].stock_calculation_mode).toBe("automatic");
|
||||
});
|
||||
|
||||
it("should use default reminder_days_before (7)", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT reminder_days_before FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].reminder_days_before).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Medication Defaults", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should use default inventory values", async () => {
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
||||
|
||||
const result = await client.execute("SELECT * FROM medications WHERE name = 'Test Med'");
|
||||
expect(result.rows[0].pack_count).toBe(1);
|
||||
expect(result.rows[0].blisters_per_pack).toBe(1);
|
||||
expect(result.rows[0].pills_per_blister).toBe(1);
|
||||
expect(result.rows[0].loose_tablets).toBe(0);
|
||||
});
|
||||
|
||||
it("should use default JSON arrays for schedules", async () => {
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
||||
|
||||
const result = await client.execute("SELECT * FROM medications WHERE name = 'Test Med'");
|
||||
expect(result.rows[0].taken_by_json).toBe("[]");
|
||||
expect(result.rows[0].usage_json).toBe("[]");
|
||||
expect(result.rows[0].every_json).toBe("[]");
|
||||
expect(result.rows[0].start_json).toBe("[]");
|
||||
});
|
||||
|
||||
it("should default intake_reminders_enabled to false (0)", async () => {
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
||||
|
||||
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
|
||||
expect(result.rows[0].intake_reminders_enabled).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Foreign Key Constraints", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
// Enable foreign keys
|
||||
await client.execute("PRAGMA foreign_keys = ON");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE
|
||||
)
|
||||
`);
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should cascade delete medications when user is deleted", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Med1')");
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Med2')");
|
||||
|
||||
// Verify medications exist
|
||||
let meds = await client.execute("SELECT * FROM medications");
|
||||
expect(meds.rows).toHaveLength(2);
|
||||
|
||||
// Delete user
|
||||
await client.execute("DELETE FROM users WHERE id = 1");
|
||||
|
||||
// Medications should be deleted too
|
||||
meds = await client.execute("SELECT * FROM medications");
|
||||
expect(meds.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default User Creation (Auth Disabled)", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should be able to create a default user with ID 1", async () => {
|
||||
// This mimics the auth-disabled mode behavior
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await client.execute("SELECT * FROM users WHERE id = 1");
|
||||
expect(user.rows).toHaveLength(1);
|
||||
expect(user.rows[0].username).toBe("default");
|
||||
expect(user.rows[0].auth_provider).toBe("local");
|
||||
});
|
||||
|
||||
it("should not duplicate default user if already exists", async () => {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
|
||||
// Check if exists before insert (mimics runtime behavior)
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
}
|
||||
|
||||
// Should still have only one user
|
||||
const users = await client.execute("SELECT * FROM users");
|
||||
expect(users.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Tests for /doses/taken API endpoints.
|
||||
* Tests marking doses as taken, listing taken doses, and unmarking.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// Since we can't easily import routes that depend on the global db,
|
||||
// we'll create simplified route handlers for testing the core logic.
|
||||
// =============================================================================
|
||||
|
||||
async function registerDoseRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// GET /doses/taken - List all taken doses
|
||||
app.get("/doses/taken", async (request, reply) => {
|
||||
// In test mode, use user ID 1 (will be created in tests)
|
||||
const userId = 1;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
return {
|
||||
doses: result.rows.map((d) => ({
|
||||
doseId: d.dose_id,
|
||||
takenAt: (d.taken_at as number) * 1000, // Convert to ms
|
||||
markedBy: d.marked_by,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// POST /doses/taken - Mark a dose as taken
|
||||
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseId } = request.body || {};
|
||||
|
||||
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
|
||||
return reply.status(400).send({ error: "doseId is required" });
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// DELETE /doses/taken/:doseId - Unmark a dose
|
||||
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseId } = request.params;
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /doses/dismiss - Dismiss missed doses without deducting stock
|
||||
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseIds } = request.body || {};
|
||||
|
||||
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
|
||||
return reply.status(400).send({ error: "doseIds array is required" });
|
||||
}
|
||||
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update to dismissed if not already
|
||||
if (!existing.rows[0].dismissed) {
|
||||
await client.execute({
|
||||
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
|
||||
args: [existing.rows[0].id],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Insert new dismissed record
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Dose Tracking API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerDoseRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Create test user - will get ID 1 since table is cleared
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
// Reset SQLite autoincrement so user gets ID 1
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
await clearTestData(ctx.client);
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/taken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /doses/taken", () => {
|
||||
it("should mark a dose as taken", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].dose_id).toBe(doseId);
|
||||
expect(result.rows[0].marked_by).toBeNull();
|
||||
});
|
||||
|
||||
it("should return idempotent response when dose already marked", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Mark once
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Mark again
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Already marked" });
|
||||
|
||||
// Should still only have one record
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it("should reject request without doseId", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
||||
});
|
||||
|
||||
it("should reject request with empty doseId", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: "" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /doses/taken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /doses/taken", () => {
|
||||
it("should return empty array when no doses taken", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ doses: [] });
|
||||
});
|
||||
|
||||
it("should return list of taken doses", async () => {
|
||||
const doseId1 = "1-0-1735344000000";
|
||||
const doseId2 = "1-0-1735430400000";
|
||||
|
||||
// Mark two doses
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: doseId1 },
|
||||
});
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: doseId2 },
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(2);
|
||||
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
// Each dose should have a takenAt timestamp
|
||||
for (const dose of data.doses) {
|
||||
expect(dose.takenAt).toBeTypeOf("number");
|
||||
expect(dose.takenAt).toBeGreaterThan(0);
|
||||
expect(dose.markedBy).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should include markedBy when present", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Insert directly with markedBy
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, "Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(1);
|
||||
expect(data.doses[0].markedBy).toBe("Daniel");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/taken/:doseId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DELETE /doses/taken/:doseId", () => {
|
||||
it("should unmark a dose", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Mark first
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Verify marked
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
// Unmark
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify unmarked
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it("should succeed even if dose was not marked", async () => {
|
||||
const doseId = "nonexistent-dose-id";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dose ID Format Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Dose ID Format", () => {
|
||||
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
|
||||
const doseId = "5-0-1735344000000";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
|
||||
const doseId = "5-0-1735344000000-Daniel";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should handle special characters in dose ID", async () => {
|
||||
// Dose ID with URL-unsafe characters (edge case)
|
||||
const doseId = "5-0-1735344000000-Max Müller";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Can retrieve it
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dismiss Doses Tests (POST /doses/dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /doses/dismiss", () => {
|
||||
it("should dismiss multiple doses", async () => {
|
||||
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should not double-count already dismissed doses", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Dismiss once
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
// Dismiss again
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||
});
|
||||
|
||||
it("should reject empty doseIds array", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should reject missing doseIds", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// First mark as taken
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Then dismiss it
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||
|
||||
// Verify it's now dismissed
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
// Mock process.exit to prevent tests from exiting
|
||||
const mockExit = vi.fn();
|
||||
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
||||
|
||||
// Re-create the schema from env.ts for testing
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
|
||||
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional(),
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
|
||||
// Validation functions from env.ts
|
||||
function validateAuthSecrets(parsed: z.infer<typeof EnvSchema>): string[] {
|
||||
const missing: string[] = [];
|
||||
if (parsed.AUTH_ENABLED) {
|
||||
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
|
||||
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
|
||||
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function validateOidcConfig(parsed: z.infer<typeof EnvSchema>): string[] {
|
||||
const missing: string[] = [];
|
||||
if (parsed.OIDC_ENABLED) {
|
||||
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
|
||||
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
|
||||
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
|
||||
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
describe("EnvSchema", () => {
|
||||
describe("default values", () => {
|
||||
it("should use default values when env vars are empty", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||
expect(result.LOG_LEVEL).toBe("info");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(7);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email");
|
||||
expect(result.OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("preferred_username");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("SSO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NODE_ENV validation", () => {
|
||||
it("should accept development", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "development" });
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
});
|
||||
|
||||
it("should accept production", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "production" });
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
});
|
||||
|
||||
it("should accept test", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "test" });
|
||||
expect(result.NODE_ENV).toBe("test");
|
||||
});
|
||||
|
||||
it("should reject invalid NODE_ENV values", () => {
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "staging" })).toThrow();
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PORT transformation", () => {
|
||||
it("should transform string PORT to number", () => {
|
||||
const result = EnvSchema.parse({ PORT: "8080" });
|
||||
expect(result.PORT).toBe(8080);
|
||||
});
|
||||
|
||||
it("should use default port when not provided", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.PORT).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boolean transformations", () => {
|
||||
it("should transform AUTH_ENABLED=true to boolean true", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("should transform AUTH_ENABLED=false to boolean false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat non-true string as false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "yes" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform REGISTRATION_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "true" }).REGISTRATION_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "false" }).REGISTRATION_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform OIDC_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "true" }).OIDC_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "false" }).OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform OIDC_AUTO_CREATE_USERS correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "true" }).OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "false" }).OIDC_AUTO_CREATE_USERS).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT secret validation", () => {
|
||||
it("should accept JWT_SECRET with 10+ characters", () => {
|
||||
const result = EnvSchema.parse({ JWT_SECRET: "1234567890" });
|
||||
expect(result.JWT_SECRET).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("should reject JWT_SECRET with less than 10 characters", () => {
|
||||
expect(() => EnvSchema.parse({ JWT_SECRET: "123456789" })).toThrow();
|
||||
});
|
||||
|
||||
it("should allow optional JWT_SECRET", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.JWT_SECRET).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL transformations", () => {
|
||||
it("should transform ACCESS_TOKEN_TTL_MINUTES to number", () => {
|
||||
const result = EnvSchema.parse({ ACCESS_TOKEN_TTL_MINUTES: "30" });
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
});
|
||||
|
||||
it("should transform REFRESH_TOKEN_TTL_DAYS to number", () => {
|
||||
const result = EnvSchema.parse({ REFRESH_TOKEN_TTL_DAYS: "14" });
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC URL validation", () => {
|
||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||
});
|
||||
|
||||
it("should reject invalid OIDC_ISSUER_URL", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_ISSUER_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
|
||||
it("should accept valid OIDC_REDIRECT_URI", () => {
|
||||
const result = EnvSchema.parse({ OIDC_REDIRECT_URI: "https://app.example.com/callback" });
|
||||
expect(result.OIDC_REDIRECT_URI).toBe("https://app.example.com/callback");
|
||||
});
|
||||
|
||||
it("should reject invalid OIDC_REDIRECT_URI", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_REDIRECT_URI: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORS_ORIGINS parsing", () => {
|
||||
it("should accept comma-separated origins", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://a.com,http://b.com" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://a.com,http://b.com");
|
||||
});
|
||||
|
||||
it("should accept single origin", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://localhost:3000" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auth validation", () => {
|
||||
it("should require secrets when AUTH_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toContain("JWT_SECRET");
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
expect(missing).toContain("COOKIE_SECRET");
|
||||
});
|
||||
|
||||
it("should not require secrets when AUTH_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should pass validation with all secrets provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
REFRESH_SECRET: "super-secret-refresh-key-12345",
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should identify which specific secrets are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
// REFRESH_SECRET missing
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC validation", () => {
|
||||
it("should require all OIDC settings when OIDC_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "true" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toContain("OIDC_ISSUER_URL");
|
||||
expect(missing).toContain("OIDC_CLIENT_ID");
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
|
||||
it("should not require OIDC settings when OIDC_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "false" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should pass validation with all OIDC settings provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
OIDC_CLIENT_SECRET: "my-client-secret",
|
||||
OIDC_REDIRECT_URI: "https://app.example.com/callback",
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should identify which specific OIDC settings are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
// OIDC_CLIENT_SECRET missing
|
||||
// OIDC_REDIRECT_URI missing
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(2);
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full configuration scenarios", () => {
|
||||
it("should parse minimal config (auth disabled)", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse full production config with auth enabled", () => {
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
PORT: "8080",
|
||||
CORS_ORIGINS: "https://myapp.com",
|
||||
LOG_LEVEL: "warn",
|
||||
AUTH_ENABLED: "true",
|
||||
REGISTRATION_ENABLED: "false",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
ACCESS_TOKEN_TTL_MINUTES: "30",
|
||||
REFRESH_TOKEN_TTL_DAYS: "14",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(8080);
|
||||
expect(result.CORS_ORIGINS).toBe("https://myapp.com");
|
||||
expect(result.LOG_LEVEL).toBe("warn");
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
|
||||
// Should pass auth validation
|
||||
const missing = validateAuthSecrets(result);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should parse config with OIDC SSO enabled", () => {
|
||||
const env = {
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://authelia.example.com",
|
||||
OIDC_CLIENT_ID: "medassist",
|
||||
OIDC_CLIENT_SECRET: "super-secret-oidc-secret",
|
||||
OIDC_REDIRECT_URI: "https://medassist.example.com/api/auth/oidc/callback",
|
||||
OIDC_SCOPES: "openid profile email groups",
|
||||
OIDC_USERNAME_CLAIM: "email",
|
||||
OIDC_PROVIDER_NAME: "Authelia",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.OIDC_ENABLED).toBe(true);
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://authelia.example.com");
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email groups");
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("email");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("Authelia");
|
||||
|
||||
// Should pass both validations
|
||||
expect(validateAuthSecrets(result)).toHaveLength(0);
|
||||
expect(validateOidcConfig(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should parse development config", () => {
|
||||
const env = {
|
||||
NODE_ENV: "development",
|
||||
PORT: "3000",
|
||||
LOG_LEVEL: "debug",
|
||||
AUTH_ENABLED: "false",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
expect(result.LOG_LEVEL).toBe("debug");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,851 @@
|
||||
/**
|
||||
* Tests for /export and /import API endpoints.
|
||||
* Tests export/import functionality with schema-independent format.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration (simplified test routes)
|
||||
// =============================================================================
|
||||
|
||||
async function registerExportRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
const userId = 1; // Test user ID
|
||||
|
||||
// Helper to parse blisters from DB
|
||||
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
const usage = JSON.parse(row.usage_json || "[]") as number[];
|
||||
const every = JSON.parse(row.every_json || "[]") as number[];
|
||||
const start = JSON.parse(row.start_json || "[]") as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
usage: usage[i],
|
||||
every: every[i],
|
||||
start: start[i],
|
||||
remind: Boolean(row.intake_reminders_enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
// GET /export
|
||||
app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, reply) => {
|
||||
const includeSensitive = request.query.includeSensitive === "true";
|
||||
|
||||
// Load medications
|
||||
const medsResult = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY id`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const medIdToExportId = new Map<number, string>();
|
||||
const medications = medsResult.rows.map((m, i) => {
|
||||
const exportId = `med-${i + 1}`;
|
||||
medIdToExportId.set(m.id as number, exportId);
|
||||
return {
|
||||
_exportId: exportId,
|
||||
name: m.name,
|
||||
genericName: m.generic_name,
|
||||
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
|
||||
inventory: {
|
||||
packCount: m.pack_count ?? 1,
|
||||
blistersPerPack: m.blisters_per_pack ?? 1,
|
||||
pillsPerBlister: m.pills_per_blister ?? 1,
|
||||
looseTablets: m.loose_tablets ?? 0,
|
||||
},
|
||||
pillWeightMg: m.pill_weight_mg,
|
||||
schedules: parseBlisters(m),
|
||||
expiryDate: m.expiry_date,
|
||||
notes: m.notes,
|
||||
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
|
||||
image: null, // Skip images in test
|
||||
};
|
||||
});
|
||||
|
||||
// Load dose tracking
|
||||
const dosesResult = await client.execute({
|
||||
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const doseHistory = dosesResult.rows
|
||||
.map((d) => {
|
||||
const parts = (d.dose_id as string).split("-");
|
||||
if (parts.length < 3) return null;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const exportId = medIdToExportId.get(medId);
|
||||
if (!exportId) return null;
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
scheduleIndex: parseInt(parts[1], 10),
|
||||
scheduledTime: new Date(parseInt(parts[2], 10)).toISOString(),
|
||||
takenAt: d.taken_at ? new Date(d.taken_at as number * 1000).toISOString() : new Date().toISOString(),
|
||||
markedBy: d.marked_by,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Load settings
|
||||
const settingsResult = await client.execute({
|
||||
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
let settings = undefined;
|
||||
if (settingsResult.rows.length > 0) {
|
||||
const s = settingsResult.rows[0];
|
||||
settings = {
|
||||
emailEnabled: Boolean(s.email_enabled),
|
||||
notificationEmail: s.notification_email,
|
||||
emailStockReminders: Boolean(s.email_stock_reminders ?? 1),
|
||||
emailIntakeReminders: Boolean(s.email_intake_reminders ?? 1),
|
||||
shoutrrrEnabled: includeSensitive ? Boolean(s.shoutrrr_enabled) : undefined,
|
||||
shoutrrrUrl: includeSensitive ? s.shoutrrr_url : undefined,
|
||||
shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders ?? 1),
|
||||
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders ?? 1),
|
||||
reminderDaysBefore: s.reminder_days_before ?? 7,
|
||||
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
|
||||
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses),
|
||||
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled),
|
||||
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
|
||||
maxNaggingReminders: s.max_nagging_reminders ?? 5,
|
||||
lowStockDays: s.low_stock_days ?? 30,
|
||||
normalStockDays: s.normal_stock_days ?? 90,
|
||||
highStockDays: s.high_stock_days ?? 180,
|
||||
language: s.language ?? "en",
|
||||
stockCalculationMode: s.stock_calculation_mode ?? "automatic",
|
||||
};
|
||||
}
|
||||
|
||||
// Load share links
|
||||
const sharesResult = await client.execute({
|
||||
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const shareLinks = sharesResult.rows.map((s) => ({
|
||||
takenBy: s.taken_by,
|
||||
scheduleDays: s.schedule_days ?? 30,
|
||||
expiresAt: s.expires_at ? new Date(s.expires_at as number * 1000).toISOString() : null,
|
||||
regenerateToken: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications,
|
||||
doseHistory,
|
||||
settings,
|
||||
shareLinks,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /import
|
||||
app.post<{ Body: any }>("/import", async (request, reply) => {
|
||||
const importData = request.body as any;
|
||||
|
||||
// Basic validation
|
||||
if (!importData.version) {
|
||||
return reply.status(400).send({ error: "Invalid import data format" });
|
||||
}
|
||||
|
||||
// Delete existing data
|
||||
await client.execute({ sql: `DELETE FROM dose_tracking WHERE user_id = ?`, args: [userId] });
|
||||
await client.execute({ sql: `DELETE FROM share_tokens WHERE user_id = ?`, args: [userId] });
|
||||
await client.execute({ sql: `DELETE FROM medications WHERE user_id = ?`, args: [userId] });
|
||||
await client.execute({ sql: `DELETE FROM user_settings WHERE user_id = ?`, args: [userId] });
|
||||
|
||||
// Import medications
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
for (const med of importData.medications || []) {
|
||||
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
|
||||
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
|
||||
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy || []);
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
pill_weight_mg, expiry_date, notes, intake_reminders_enabled,
|
||||
usage_json, every_json, start_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
userId,
|
||||
med.name,
|
||||
med.genericName || null,
|
||||
takenByJson,
|
||||
med.inventory?.packCount ?? 1,
|
||||
med.inventory?.blistersPerPack ?? 1,
|
||||
med.inventory?.pillsPerBlister ?? 1,
|
||||
med.inventory?.looseTablets ?? 0,
|
||||
med.pillWeightMg ?? null,
|
||||
med.expiryDate || null,
|
||||
med.notes || null,
|
||||
med.intakeRemindersEnabled ? 1 : 0,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
],
|
||||
});
|
||||
|
||||
exportIdToNewId.set(med._exportId, result.rows[0].id as number);
|
||||
}
|
||||
|
||||
// Import dose history
|
||||
for (const dose of importData.doseHistory || []) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue;
|
||||
|
||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||
const doseId = `${newMedId}-${dose.scheduleIndex}-${timestampMs}`;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
doseId,
|
||||
Math.floor(new Date(dose.takenAt).getTime() / 1000),
|
||||
dose.markedBy || null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Import settings
|
||||
if (importData.settings) {
|
||||
const s = importData.settings;
|
||||
await client.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email,
|
||||
email_stock_reminders, email_intake_reminders,
|
||||
shoutrrr_enabled, shoutrrr_url,
|
||||
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
|
||||
reminder_days_before, repeat_daily_reminders,
|
||||
skip_reminders_for_taken_doses, repeat_reminders_enabled,
|
||||
reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||
low_stock_days, normal_stock_days, high_stock_days,
|
||||
language, stock_calculation_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
s.emailEnabled ? 1 : 0,
|
||||
s.notificationEmail || null,
|
||||
s.emailStockReminders ?? 1,
|
||||
s.emailIntakeReminders ?? 1,
|
||||
s.shoutrrrEnabled ? 1 : 0,
|
||||
s.shoutrrrUrl || null,
|
||||
s.shoutrrrStockReminders ?? 1,
|
||||
s.shoutrrrIntakeReminders ?? 1,
|
||||
s.reminderDaysBefore ?? 7,
|
||||
s.repeatDailyReminders ? 1 : 0,
|
||||
s.skipRemindersForTakenDoses ? 1 : 0,
|
||||
s.repeatRemindersEnabled ? 1 : 0,
|
||||
s.reminderRepeatIntervalMinutes ?? 30,
|
||||
s.maxNaggingReminders ?? 5,
|
||||
s.lowStockDays ?? 30,
|
||||
s.normalStockDays ?? 90,
|
||||
s.highStockDays ?? 180,
|
||||
s.language ?? "en",
|
||||
s.stockCalculationMode ?? "automatic",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Import share links
|
||||
for (const share of importData.shareLinks || []) {
|
||||
const token = randomBytes(8).toString("hex");
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
token,
|
||||
share.takenBy,
|
||||
share.scheduleDays ?? 30,
|
||||
share.expiresAt ? Math.floor(new Date(share.expiresAt).getTime() / 1000) : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: {
|
||||
medications: (importData.medications || []).length,
|
||||
doseHistory: (importData.doseHistory || []).length,
|
||||
settings: importData.settings ? 1 : 0,
|
||||
shareLinks: (importData.shareLinks || []).length,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Export/Import API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerExportRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'");
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /export", () => {
|
||||
it("should export empty data for new user", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.version).toBe("1.0");
|
||||
expect(data.exportedAt).toBeDefined();
|
||||
expect(data.medications).toEqual([]);
|
||||
expect(data.doseHistory).toEqual([]);
|
||||
expect(data.shareLinks).toEqual([]);
|
||||
});
|
||||
|
||||
it("should export medications with correct format", async () => {
|
||||
const startDate = "2025-01-15T08:00:00.000Z";
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
takenBy: ["Daniel", "Maria"],
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
pillWeightMg: 500,
|
||||
expiryDate: "2027-06-30",
|
||||
notes: "Take with food",
|
||||
intakeRemindersEnabled: true,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: startDate },
|
||||
{ usage: 0.5, every: 7, start: startDate },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.medications).toHaveLength(1);
|
||||
|
||||
const med = data.medications[0];
|
||||
expect(med._exportId).toBe("med-1");
|
||||
expect(med.name).toBe("Aspirin");
|
||||
expect(med.genericName).toBe("Acetylsalicylic acid");
|
||||
expect(med.takenBy).toEqual(["Daniel", "Maria"]);
|
||||
expect(med.inventory).toEqual({
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
expect(med.pillWeightMg).toBe(500);
|
||||
expect(med.expiryDate).toBe("2027-06-30");
|
||||
expect(med.notes).toBe("Take with food");
|
||||
expect(med.intakeRemindersEnabled).toBe(true);
|
||||
expect(med.schedules).toHaveLength(2);
|
||||
expect(med.schedules[0]).toEqual({
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: startDate,
|
||||
remind: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should export settings", async () => {
|
||||
// Create settings
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email, language, low_stock_days
|
||||
) VALUES (?, 1, 'test@example.com', 'de', 14)`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.settings).toBeDefined();
|
||||
expect(data.settings.emailEnabled).toBe(true);
|
||||
expect(data.settings.notificationEmail).toBe("test@example.com");
|
||||
expect(data.settings.language).toBe("de");
|
||||
expect(data.settings.lowStockDays).toBe(14);
|
||||
});
|
||||
|
||||
it("should exclude sensitive data by default", async () => {
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, shoutrrr_enabled, shoutrrr_url
|
||||
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.includeSensitiveData).toBe(false);
|
||||
expect(data.settings.shoutrrrEnabled).toBeUndefined();
|
||||
expect(data.settings.shoutrrrUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include sensitive data when requested", async () => {
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, shoutrrr_enabled, shoutrrr_url
|
||||
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export?includeSensitive=true",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.includeSensitiveData).toBe(true);
|
||||
expect(data.settings.shoutrrrEnabled).toBe(true);
|
||||
expect(data.settings.shoutrrrUrl).toBe("ntfy://user:pass@ntfy.sh/topic");
|
||||
});
|
||||
|
||||
it("should export dose history with medication references", async () => {
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Test Med",
|
||||
});
|
||||
|
||||
// Create dose tracking entry
|
||||
const timestampMs = Date.now();
|
||||
const doseId = `${medId}-0-${timestampMs}`;
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, Math.floor(Date.now() / 1000)],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doseHistory).toHaveLength(1);
|
||||
expect(data.doseHistory[0].medicationRef).toBe("med-1");
|
||||
expect(data.doseHistory[0].scheduleIndex).toBe(0);
|
||||
expect(data.doseHistory[0].scheduledTime).toBeDefined();
|
||||
expect(data.doseHistory[0].takenAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should export share links", async () => {
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
|
||||
args: [userId, "abc123", "Daniel", 30],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.shareLinks).toHaveLength(1);
|
||||
expect(data.shareLinks[0].takenBy).toBe("Daniel");
|
||||
expect(data.shareLinks[0].scheduleDays).toBe(30);
|
||||
expect(data.shareLinks[0].regenerateToken).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /import", () => {
|
||||
it("should import medications", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Imported Med",
|
||||
genericName: "Generic",
|
||||
takenBy: ["Alice"],
|
||||
inventory: {
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
},
|
||||
pillWeightMg: 250,
|
||||
schedules: [
|
||||
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true },
|
||||
],
|
||||
expiryDate: "2027-12-31",
|
||||
notes: "Test notes",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
],
|
||||
doseHistory: [],
|
||||
shareLinks: [],
|
||||
};
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().success).toBe(true);
|
||||
expect(response.json().imported.medications).toBe(1);
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].name).toBe("Imported Med");
|
||||
expect(result.rows[0].generic_name).toBe("Generic");
|
||||
expect(result.rows[0].pack_count).toBe(2);
|
||||
expect(result.rows[0].blisters_per_pack).toBe(3);
|
||||
expect(result.rows[0].pills_per_blister).toBe(10);
|
||||
expect(result.rows[0].loose_tablets).toBe(5);
|
||||
});
|
||||
|
||||
it("should replace existing data on import", async () => {
|
||||
// Create existing medication
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Existing Med",
|
||||
});
|
||||
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "New Med",
|
||||
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
doseHistory: [],
|
||||
shareLinks: [],
|
||||
};
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
// Verify old med deleted, new one exists
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].name).toBe("New Med");
|
||||
});
|
||||
|
||||
it("should import dose history with remapped IDs", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Med 1",
|
||||
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
doseHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
scheduleIndex: 0,
|
||||
scheduledTime: "2025-01-15T08:00:00.000Z",
|
||||
takenAt: "2025-01-15T08:15:00.000Z",
|
||||
markedBy: null,
|
||||
},
|
||||
],
|
||||
shareLinks: [],
|
||||
};
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
// Verify dose tracking
|
||||
const doses = await ctx.client.execute({
|
||||
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(doses.rows).toHaveLength(1);
|
||||
// Dose ID should contain the NEW medication ID
|
||||
const doseId = doses.rows[0].dose_id as string;
|
||||
expect(doseId).toMatch(/^\d+-0-\d+$/);
|
||||
});
|
||||
|
||||
it("should import settings", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [],
|
||||
doseHistory: [],
|
||||
settings: {
|
||||
emailEnabled: true,
|
||||
notificationEmail: "imported@example.com",
|
||||
language: "de",
|
||||
lowStockDays: 14,
|
||||
normalStockDays: 60,
|
||||
highStockDays: 120,
|
||||
},
|
||||
shareLinks: [],
|
||||
};
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
// Verify settings
|
||||
const settings = await ctx.client.execute({
|
||||
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(settings.rows).toHaveLength(1);
|
||||
expect(settings.rows[0].email_enabled).toBe(1);
|
||||
expect(settings.rows[0].notification_email).toBe("imported@example.com");
|
||||
expect(settings.rows[0].language).toBe("de");
|
||||
expect(settings.rows[0].low_stock_days).toBe(14);
|
||||
});
|
||||
|
||||
it("should import share links with new tokens", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [],
|
||||
doseHistory: [],
|
||||
shareLinks: [
|
||||
{
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 60,
|
||||
regenerateToken: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
// Verify share token
|
||||
const shares = await ctx.client.execute({
|
||||
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(shares.rows).toHaveLength(1);
|
||||
expect(shares.rows[0].taken_by).toBe("Daniel");
|
||||
expect(shares.rows[0].schedule_days).toBe(60);
|
||||
expect(shares.rows[0].token).toBeDefined();
|
||||
expect((shares.rows[0].token as string).length).toBe(16); // 8 bytes = 16 hex chars
|
||||
});
|
||||
|
||||
it("should reject invalid import data", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: { invalid: "data" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid import data format");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export/Import Roundtrip Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Export/Import Roundtrip", () => {
|
||||
it("should preserve all data through export/import cycle", async () => {
|
||||
// Setup: Create medications, doses, settings, shares
|
||||
const startDate = "2025-01-15T08:00:00.000Z";
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Roundtrip Med",
|
||||
genericName: "Generic Name",
|
||||
takenBy: ["Daniel", "Maria"],
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
pillWeightMg: 500,
|
||||
expiryDate: "2027-06-30",
|
||||
notes: "Test notes",
|
||||
intakeRemindersEnabled: true,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: startDate },
|
||||
{ usage: 0.5, every: 7, start: startDate },
|
||||
],
|
||||
});
|
||||
|
||||
// Create dose
|
||||
const timestampMs = new Date(startDate).getTime();
|
||||
const doseId = `${medId}-0-${timestampMs}`;
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
|
||||
args: [userId, doseId, Math.floor(Date.now() / 1000), "Daniel"],
|
||||
});
|
||||
|
||||
// Create settings
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, notification_email, language, low_stock_days) VALUES (?, 1, 'test@example.com', 'de', 14)`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
// Create share
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
|
||||
args: [userId, "original123", "Daniel", 60],
|
||||
});
|
||||
|
||||
// Export
|
||||
const exportResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
expect(exportResponse.statusCode).toBe(200);
|
||||
const exportData = exportResponse.json();
|
||||
|
||||
// Import (this replaces all data)
|
||||
const importResponse = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: exportData,
|
||||
});
|
||||
expect(importResponse.statusCode).toBe(200);
|
||||
|
||||
// Export again and compare
|
||||
const reExportResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
});
|
||||
const reExportData = reExportResponse.json();
|
||||
|
||||
// Compare (excluding timestamps and IDs that change)
|
||||
expect(reExportData.medications).toHaveLength(1);
|
||||
expect(reExportData.medications[0].name).toBe("Roundtrip Med");
|
||||
expect(reExportData.medications[0].genericName).toBe("Generic Name");
|
||||
expect(reExportData.medications[0].takenBy).toEqual(["Daniel", "Maria"]);
|
||||
expect(reExportData.medications[0].inventory).toEqual({
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
expect(reExportData.medications[0].schedules).toHaveLength(2);
|
||||
|
||||
expect(reExportData.doseHistory).toHaveLength(1);
|
||||
expect(reExportData.doseHistory[0].markedBy).toBe("Daniel");
|
||||
|
||||
expect(reExportData.settings.emailEnabled).toBe(true);
|
||||
expect(reExportData.settings.notificationEmail).toBe("test@example.com");
|
||||
expect(reExportData.settings.language).toBe("de");
|
||||
|
||||
expect(reExportData.shareLinks).toHaveLength(1);
|
||||
expect(reExportData.shareLinks[0].takenBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
it("should handle import with different schema (backward compatibility)", async () => {
|
||||
// Simulate import from older version without some fields
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Legacy Med",
|
||||
// Missing: genericName, takenBy, pillWeightMg, etc.
|
||||
inventory: {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
},
|
||||
schedules: [
|
||||
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
doseHistory: [],
|
||||
// Missing: settings, shareLinks
|
||||
};
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: importData,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().success).toBe(true);
|
||||
|
||||
// Verify defaults were applied
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].name).toBe("Legacy Med");
|
||||
expect(result.rows[0].generic_name).toBeNull();
|
||||
expect(result.rows[0].taken_by_json).toBe("[]");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,937 @@
|
||||
/**
|
||||
* Integration Tests - Testing interactions between multiple routes/features
|
||||
* These tests verify critical app behavior that spans multiple components.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
|
||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return { testClient: client, testDb: db };
|
||||
});
|
||||
|
||||
// Mock modules
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({
|
||||
env: {
|
||||
AUTH_ENABLED: false,
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
JWT_SECRET: "test-secret",
|
||||
REFRESH_SECRET: "test-refresh-secret",
|
||||
COOKIE_SECRET: "test-cookie-secret",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: () => 999999999,
|
||||
}));
|
||||
|
||||
// Import routes
|
||||
const { doseRoutes } = await import("../routes/doses.js");
|
||||
const { shareRoutes } = await import("../routes/share.js");
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { settingsRoutes } = await import("../routes/settings.js");
|
||||
|
||||
// =============================================================================
|
||||
// Schema & Setup
|
||||
// =============================================================================
|
||||
|
||||
async function createSchema(client: Client) {
|
||||
const tables = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of tables) {
|
||||
await client.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Integration Tests", () => {
|
||||
let app: FastifyInstance;
|
||||
const userId = 999999999;
|
||||
|
||||
beforeAll(async () => {
|
||||
await createSchema(testClient);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: 15,
|
||||
refreshTtl: 7,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
});
|
||||
|
||||
await app.register(doseRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearData(testClient);
|
||||
await testClient.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medication Update + Dose Tracking Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Medication Update cleans up old dose tracking", () => {
|
||||
it("should delete doses before new start date when start date is moved forward", async () => {
|
||||
// Create medication starting Jan 1
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
|
||||
const jan1 = new Date("2025-01-01T08:00:00.000Z").getTime();
|
||||
const jan2 = new Date("2025-01-02T08:00:00.000Z").getTime();
|
||||
const jan5 = new Date("2025-01-05T08:00:00.000Z").getTime();
|
||||
const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime();
|
||||
|
||||
for (const ts of [jan1, jan2, jan5, jan10]) {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: `${medId}-0-${ts}` },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify 4 doses exist
|
||||
const beforeUpdate = await testClient.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
|
||||
args: [`${medId}-%`],
|
||||
});
|
||||
expect(beforeUpdate.rows[0].count).toBe(4);
|
||||
|
||||
// Update medication to start Jan 5 (should delete Jan 1 and Jan 2 doses)
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-05T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify only 2 doses remain (Jan 5 and Jan 10)
|
||||
const afterUpdate = await testClient.execute({
|
||||
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ? ORDER BY dose_id`,
|
||||
args: [`${medId}-%`],
|
||||
});
|
||||
expect(afterUpdate.rows.length).toBe(2);
|
||||
expect(afterUpdate.rows[0].dose_id).toContain(String(jan5));
|
||||
expect(afterUpdate.rows[1].dose_id).toContain(String(jan10));
|
||||
});
|
||||
|
||||
it("should keep all doses when start date is moved backward", async () => {
|
||||
// Create medication starting Jan 10
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Mark dose on Jan 10
|
||||
const jan10 = new Date("2025-01-10T08:00:00.000Z").getTime();
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: `${medId}-0-${jan10}` },
|
||||
});
|
||||
|
||||
// Update to start Jan 1 (earlier)
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Dose should still exist
|
||||
const afterUpdate = await testClient.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
|
||||
args: [`${medId}-%`],
|
||||
});
|
||||
expect(afterUpdate.rows[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle multiple blisters with different start dates", async () => {
|
||||
// Create medication with 2 schedules: Jan 1 morning and Jan 5 evening
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Mark doses for both schedules
|
||||
const jan1_8am = new Date("2025-01-01T08:00:00.000Z").getTime();
|
||||
const jan3_8am = new Date("2025-01-03T08:00:00.000Z").getTime();
|
||||
const jan5_8pm = new Date("2025-01-05T20:00:00.000Z").getTime();
|
||||
const jan6_8pm = new Date("2025-01-06T20:00:00.000Z").getTime();
|
||||
|
||||
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan1_8am}` } });
|
||||
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-0-${jan3_8am}` } });
|
||||
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan5_8pm}` } });
|
||||
await app.inject({ method: "POST", url: "/doses/taken", payload: { doseId: `${medId}-1-${jan6_8pm}` } });
|
||||
|
||||
// 4 doses total
|
||||
const before = await testClient.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id LIKE ?`,
|
||||
args: [`${medId}-%`],
|
||||
});
|
||||
expect(before.rows[0].count).toBe(4);
|
||||
|
||||
// Update: move first schedule to Jan 4
|
||||
// Earliest start is now Jan 4, so Jan 1 and Jan 3 doses should be deleted
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-04T08:00:00.000Z" },
|
||||
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Should have 2 doses left (Jan 5 and Jan 6 evening doses)
|
||||
const after = await testClient.execute({
|
||||
sql: `SELECT dose_id FROM dose_tracking WHERE dose_id LIKE ?`,
|
||||
args: [`${medId}-%`],
|
||||
});
|
||||
expect(after.rows.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Share Link + Dose Tracking Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Share links and dose tracking integration", () => {
|
||||
it("should allow marking/unmarking doses via share link with correct markedBy", async () => {
|
||||
// Create medication for Daniel
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token for Daniel
|
||||
const shareRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark dose via share link
|
||||
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Verify markedBy is "Daniel"
|
||||
const result = await testClient.execute({
|
||||
sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].marked_by).toBe("Daniel");
|
||||
|
||||
// Unmark via share link
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/${token}/doses/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
// Verify deleted
|
||||
const afterDelete = await testClient.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(afterDelete.rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it("should show medication in shared schedule after marking dose", async () => {
|
||||
// Create medication
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Vitamin D",
|
||||
takenBy: ["Anna"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token
|
||||
const shareRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Anna", scheduleDays: 30 },
|
||||
});
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark a dose
|
||||
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Get shared schedule
|
||||
const scheduleRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
const data = scheduleRes.json();
|
||||
expect(data.takenBy).toBe("Anna");
|
||||
expect(data.medications).toHaveLength(1);
|
||||
expect(data.medications[0].name).toBe("Vitamin D");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings + Stock Calculation Mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Settings affect stock calculation", () => {
|
||||
it("should persist stock calculation mode across requests", async () => {
|
||||
// Set to manual mode
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
// Verify it's saved
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
expect(getRes.json().stockCalculationMode).toBe("manual");
|
||||
|
||||
// Change to automatic
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
},
|
||||
});
|
||||
|
||||
const getRes2 = await app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
expect(getRes2.json().stockCalculationMode).toBe("automatic");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-Person Medication Scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Multi-person medication scenarios", () => {
|
||||
it("should create separate share links for different people", async () => {
|
||||
// Create medication for multiple people
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Family Vitamins",
|
||||
takenBy: ["Daniel", "Anna", "Max"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Create share links for each person
|
||||
const danielShare = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
|
||||
const annaShare = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Anna", scheduleDays: 30 },
|
||||
});
|
||||
|
||||
// Both should succeed with different tokens
|
||||
expect(danielShare.statusCode).toBe(200);
|
||||
expect(annaShare.statusCode).toBe(200);
|
||||
expect(danielShare.json().token).not.toBe(annaShare.json().token);
|
||||
|
||||
// Each share link should show correct person
|
||||
const danielSchedule = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${danielShare.json().token}`,
|
||||
});
|
||||
expect(danielSchedule.json().takenBy).toBe("Daniel");
|
||||
|
||||
const annaSchedule = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${annaShare.json().token}`,
|
||||
});
|
||||
expect(annaSchedule.json().takenBy).toBe("Anna");
|
||||
});
|
||||
|
||||
it("should list all people correctly via /share/people", async () => {
|
||||
// Create multiple medications
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Med 1",
|
||||
takenBy: ["Daniel", "Anna"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Med 2",
|
||||
takenBy: ["Max"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Med 3",
|
||||
takenBy: ["Daniel"], // Daniel again
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Get all people
|
||||
const peopleRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/people",
|
||||
});
|
||||
|
||||
const people = peopleRes.json().people;
|
||||
expect(people).toContain("Daniel");
|
||||
expect(people).toContain("Anna");
|
||||
expect(people).toContain("Max");
|
||||
expect(people.length).toBe(3); // No duplicates
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle medication with 0 stock correctly", async () => {
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Empty Med",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
expect(createRes.json().packCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle medication with very high pill count", async () => {
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Bulk Med",
|
||||
packCount: 100,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 100,
|
||||
looseTablets: 500,
|
||||
blisters: [{ usage: 0.5, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
// Total: 100 * 10 * 100 + 500 = 100500 pills
|
||||
});
|
||||
|
||||
it("should handle fractional pill usage", async () => {
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Half-Pill Med",
|
||||
blisters: [
|
||||
{ usage: 0.5, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 0.25, every: 1, start: "2025-01-01T20:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
expect(createRes.json().blisters[0].usage).toBe(0.5);
|
||||
expect(createRes.json().blisters[1].usage).toBe(0.25);
|
||||
});
|
||||
|
||||
it("should handle weekly medication schedule", async () => {
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Weekly Med",
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
expect(createRes.json().blisters[0].every).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Planner Usage Calculation - POST /medications/usage
|
||||
// This is a CRITICAL feature for the app - calculates if stock is enough
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Planner usage calculation", () => {
|
||||
it("should calculate correct usage for daily medication", async () => {
|
||||
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
|
||||
// Schedule: 1 pill daily starting Jan 1
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Daily Med",
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate usage for Jan 1-10 (10 days = 10 pills needed)
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-11T00:00:00.000Z", // 10 days
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].medicationName).toBe("Daily Med");
|
||||
expect(data[0].plannerUsage).toBe(10); // 10 days × 1 pill
|
||||
// Note: 'enough' depends on current stock after consumption since start date
|
||||
// Since test runs ~364 days after Jan 1, most pills are consumed
|
||||
});
|
||||
|
||||
it("should detect insufficient stock", async () => {
|
||||
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
|
||||
// Schedule: 1 pill daily
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Low Stock Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 5,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate usage for 10 days (needs 10 pills, only have 5 originally)
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-11T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(10);
|
||||
expect(data[0].enough).toBe(false); // Not enough!
|
||||
});
|
||||
|
||||
it("should calculate weekly medication usage correctly", async () => {
|
||||
// Create medication: 10 pills total
|
||||
// Schedule: 1 pill every 7 days starting Jan 1
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Weekly Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate usage for 30 days (should need ~4-5 pills)
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-31T00:00:00.000Z", // 30 days
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
// Jan 1, 8, 15, 22, 29 = 5 doses
|
||||
expect(data[0].plannerUsage).toBe(5);
|
||||
});
|
||||
|
||||
it("should handle multiple intake schedules per medication", async () => {
|
||||
// Create medication with morning and evening doses
|
||||
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Twice Daily Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, // Morning: 1 pill
|
||||
{ usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, // Evening: 0.5 pill
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate for 10 days
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-11T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
// 10 days × (1 + 0.5) = 15 pills
|
||||
expect(data[0].plannerUsage).toBe(15);
|
||||
});
|
||||
|
||||
it("should calculate correct blisters needed", async () => {
|
||||
// 10 pills per blister, need 25 pills → need 3 blisters
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Blister Med",
|
||||
packCount: 5,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 2.5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// 10 days × 2.5 pills = 25 pills needed
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-11T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(25);
|
||||
expect(data[0].blistersNeeded).toBe(3); // ceil(25/10)
|
||||
expect(data[0].blisterSize).toBe(10);
|
||||
});
|
||||
|
||||
it("should reject invalid date range", async () => {
|
||||
// End before start
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-15T00:00:00.000Z",
|
||||
endDate: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should handle medication not yet started", async () => {
|
||||
// Medication starts in the future
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Future Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-06-01T08:00:00.000Z" }], // Starts June
|
||||
},
|
||||
});
|
||||
|
||||
// Query for January (before start)
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-31T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(0); // No usage before start
|
||||
});
|
||||
|
||||
it("should return correct totalPills based on current stock", async () => {
|
||||
// Fresh medication with future start date = no consumption yet
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Fresh Med",
|
||||
packCount: 2,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
// Start in far future so no consumption
|
||||
blisters: [{ usage: 1, every: 1, start: "2030-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2030-01-01T00:00:00.000Z",
|
||||
endDate: "2030-01-11T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
// Total: 2 packs × 2 blisters × 10 pills + 5 loose = 45 pills
|
||||
expect(data[0].totalPills).toBe(45);
|
||||
expect(data[0].plannerUsage).toBe(10);
|
||||
expect(data[0].enough).toBe(true); // 45 > 10
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Tests for /medications API endpoints.
|
||||
* Tests CRUD operations for medications.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// =============================================================================
|
||||
|
||||
async function registerMedicationRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// GET /medications - List all medications
|
||||
app.get("/medications", async (request, reply) => {
|
||||
const userId = 1;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY name`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
return result.rows.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
genericName: m.generic_name,
|
||||
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
|
||||
packCount: m.pack_count,
|
||||
blistersPerPack: m.blisters_per_pack,
|
||||
pillsPerBlister: m.pills_per_blister,
|
||||
looseTablets: m.loose_tablets,
|
||||
pillWeightMg: m.pill_weight_mg,
|
||||
imageUrl: m.image_url,
|
||||
expiryDate: m.expiry_date,
|
||||
notes: m.notes,
|
||||
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
|
||||
blisters: (() => {
|
||||
const usage: number[] = JSON.parse((m.usage_json as string) || "[]");
|
||||
const every: number[] = JSON.parse((m.every_json as string) || "[]");
|
||||
const start: string[] = JSON.parse((m.start_json as string) || "[]");
|
||||
return usage.map((u, i) => ({
|
||||
usage: u,
|
||||
every: every[i] || 1,
|
||||
start: start[i] || new Date().toISOString(),
|
||||
}));
|
||||
})(),
|
||||
}));
|
||||
});
|
||||
|
||||
// POST /medications - Create medication
|
||||
app.post<{
|
||||
Body: {
|
||||
name: string;
|
||||
genericName?: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string;
|
||||
notes?: string;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
blisters: Array<{ usage: number; every: number; start: string }>;
|
||||
};
|
||||
}>("/medications", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const body = request.body || {};
|
||||
|
||||
// Validation
|
||||
if (!body.name || body.name.length === 0) {
|
||||
return reply.status(400).send({ error: "Name is required" });
|
||||
}
|
||||
if (body.name.length > 100) {
|
||||
return reply.status(400).send({ error: "Name must be 100 characters or less" });
|
||||
}
|
||||
if (!body.blisters || body.blisters.length === 0) {
|
||||
return reply.status(400).send({ error: "At least one intake schedule is required" });
|
||||
}
|
||||
if (body.blisters.length > 12) {
|
||||
return reply.status(400).send({ error: "Maximum 12 intake schedules allowed" });
|
||||
}
|
||||
|
||||
const usageJson = JSON.stringify(body.blisters.map((b) => b.usage));
|
||||
const everyJson = JSON.stringify(body.blisters.map((b) => b.every));
|
||||
const startJson = JSON.stringify(body.blisters.map((b) => b.start));
|
||||
const takenByJson = JSON.stringify(body.takenBy || []);
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
pill_weight_mg, expiry_date, notes, intake_reminders_enabled,
|
||||
usage_json, every_json, start_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
userId,
|
||||
body.name,
|
||||
body.genericName || null,
|
||||
takenByJson,
|
||||
body.packCount ?? 1,
|
||||
body.blistersPerPack ?? 1,
|
||||
body.pillsPerBlister ?? 1,
|
||||
body.looseTablets ?? 0,
|
||||
body.pillWeightMg ?? null,
|
||||
body.expiryDate || null,
|
||||
body.notes || null,
|
||||
body.intakeRemindersEnabled ? 1 : 0,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
],
|
||||
});
|
||||
|
||||
return { id: result.rows[0].id, success: true };
|
||||
});
|
||||
|
||||
// PUT /medications/:id - Update medication
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
name: string;
|
||||
genericName?: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string;
|
||||
notes?: string;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
blisters: Array<{ usage: number; every: number; start: string }>;
|
||||
};
|
||||
}>("/medications/:id", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
const body = request.body || {};
|
||||
|
||||
// Check ownership
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!body.name || body.name.length === 0) {
|
||||
return reply.status(400).send({ error: "Name is required" });
|
||||
}
|
||||
if (!body.blisters || body.blisters.length === 0) {
|
||||
return reply.status(400).send({ error: "At least one intake schedule is required" });
|
||||
}
|
||||
|
||||
const usageJson = JSON.stringify(body.blisters.map((b) => b.usage));
|
||||
const everyJson = JSON.stringify(body.blisters.map((b) => b.every));
|
||||
const startJson = JSON.stringify(body.blisters.map((b) => b.start));
|
||||
const takenByJson = JSON.stringify(body.takenBy || []);
|
||||
|
||||
await client.execute({
|
||||
sql: `UPDATE medications SET
|
||||
name = ?, generic_name = ?, taken_by_json = ?,
|
||||
pack_count = ?, blisters_per_pack = ?, pills_per_blister = ?, loose_tablets = ?,
|
||||
pill_weight_mg = ?, expiry_date = ?, notes = ?, intake_reminders_enabled = ?,
|
||||
usage_json = ?, every_json = ?, start_json = ?,
|
||||
updated_at = strftime('%s','now')
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
args: [
|
||||
body.name,
|
||||
body.genericName || null,
|
||||
takenByJson,
|
||||
body.packCount ?? 1,
|
||||
body.blistersPerPack ?? 1,
|
||||
body.pillsPerBlister ?? 1,
|
||||
body.looseTablets ?? 0,
|
||||
body.pillWeightMg ?? null,
|
||||
body.expiryDate || null,
|
||||
body.notes || null,
|
||||
body.intakeRemindersEnabled ? 1 : 0,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
medId,
|
||||
userId,
|
||||
],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// DELETE /medications/:id - Delete medication
|
||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
|
||||
// Check ownership
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /medications/:id - Get single medication
|
||||
app.get<{ Params: { id: string } }>("/medications/:id", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
const m = result.rows[0];
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
genericName: m.generic_name,
|
||||
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
|
||||
packCount: m.pack_count,
|
||||
blistersPerPack: m.blisters_per_pack,
|
||||
pillsPerBlister: m.pills_per_blister,
|
||||
looseTablets: m.loose_tablets,
|
||||
pillWeightMg: m.pill_weight_mg,
|
||||
imageUrl: m.image_url,
|
||||
expiryDate: m.expiry_date,
|
||||
notes: m.notes,
|
||||
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
|
||||
blisters: (() => {
|
||||
const usage: number[] = JSON.parse((m.usage_json as string) || "[]");
|
||||
const every: number[] = JSON.parse((m.every_json as string) || "[]");
|
||||
const start: string[] = JSON.parse((m.start_json as string) || "[]");
|
||||
return usage.map((u, i) => ({
|
||||
usage: u,
|
||||
every: every[i] || 1,
|
||||
start: start[i] || new Date().toISOString(),
|
||||
}));
|
||||
})(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Medications API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerMedicationRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'");
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /medications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /medications", () => {
|
||||
it("should return empty array when no medications", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return list of medications", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
takenBy: ["Daniel"],
|
||||
packCount: 2,
|
||||
pillsPerBlister: 10,
|
||||
});
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Ibuprofen",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(2);
|
||||
// Sorted by name
|
||||
expect(data[0].name).toBe("Aspirin");
|
||||
expect(data[0].genericName).toBe("Acetylsalicylic acid");
|
||||
expect(data[0].takenBy).toEqual(["Daniel"]);
|
||||
expect(data[1].name).toBe("Ibuprofen");
|
||||
});
|
||||
|
||||
it("should return medication with all fields", async () => {
|
||||
const startDate = "2025-01-01T08:00:00.000Z";
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Test Med",
|
||||
genericName: "Generic Name",
|
||||
takenBy: ["Person1", "Person2"],
|
||||
packCount: 3,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 14,
|
||||
looseTablets: 5,
|
||||
pillWeightMg: 500,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: startDate },
|
||||
{ usage: 2, every: 2, start: startDate },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [med] = response.json();
|
||||
expect(med.name).toBe("Test Med");
|
||||
expect(med.genericName).toBe("Generic Name");
|
||||
expect(med.takenBy).toEqual(["Person1", "Person2"]);
|
||||
expect(med.packCount).toBe(3);
|
||||
expect(med.blistersPerPack).toBe(2);
|
||||
expect(med.pillsPerBlister).toBe(14);
|
||||
expect(med.looseTablets).toBe(5);
|
||||
expect(med.pillWeightMg).toBe(500);
|
||||
expect(med.blisters).toHaveLength(2);
|
||||
expect(med.blisters[0]).toEqual({ usage: 1, every: 1, start: startDate });
|
||||
expect(med.blisters[1]).toEqual({ usage: 2, every: 2, start: startDate });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /medications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /medications", () => {
|
||||
it("should create a medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "New Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.id).toBeDefined();
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT name FROM medications WHERE id = ?`,
|
||||
args: [data.id],
|
||||
});
|
||||
expect(result.rows[0].name).toBe("New Med");
|
||||
});
|
||||
|
||||
it("should create medication with all fields", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Full Med",
|
||||
genericName: "Generic",
|
||||
takenBy: ["Alice", "Bob"],
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
pillWeightMg: 250,
|
||||
expiryDate: "2026-12-31",
|
||||
notes: "Take with food",
|
||||
intakeRemindersEnabled: true,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 2, every: 1, start: "2025-01-01T20:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify
|
||||
const medId = response.json().id;
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT * FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
const med = result.rows[0];
|
||||
expect(med.name).toBe("Full Med");
|
||||
expect(med.generic_name).toBe("Generic");
|
||||
expect(JSON.parse(med.taken_by_json as string)).toEqual(["Alice", "Bob"]);
|
||||
expect(med.pack_count).toBe(2);
|
||||
expect(med.blisters_per_pack).toBe(3);
|
||||
expect(med.pills_per_blister).toBe(10);
|
||||
expect(med.loose_tablets).toBe(5);
|
||||
expect(med.pill_weight_mg).toBe(250);
|
||||
expect(med.expiry_date).toBe("2026-12-31");
|
||||
expect(med.notes).toBe("Take with food");
|
||||
expect(med.intake_reminders_enabled).toBe(1);
|
||||
});
|
||||
|
||||
it("should reject request without name", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Name is required");
|
||||
});
|
||||
|
||||
it("should reject request without blisters", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test",
|
||||
blisters: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("At least one intake schedule is required");
|
||||
});
|
||||
|
||||
it("should reject name over 100 characters", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "A".repeat(101),
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Name must be 100 characters or less");
|
||||
});
|
||||
|
||||
it("should reject more than 12 blisters", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test",
|
||||
blisters: Array(13).fill({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Maximum 12 intake schedules allowed");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /medications/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PUT /medications/:id", () => {
|
||||
it("should update a medication", async () => {
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Old Name",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "New Name",
|
||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT name, usage_json FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].name).toBe("New Name");
|
||||
expect(JSON.parse(result.rows[0].usage_json as string)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/medications/99999",
|
||||
payload: {
|
||||
name: "Test",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
|
||||
it("should not update medication of another user", async () => {
|
||||
// Create another user
|
||||
const otherUserId = await createTestUser(ctx.client, { username: "other" });
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId: otherUserId,
|
||||
name: "Other Med",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Hacked",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /medications/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DELETE /medications/:id", () => {
|
||||
it("should delete a medication", async () => {
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "To Delete",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: `/medications/${medId}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify deleted
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: "/medications/99999",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /medications/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /medications/:id", () => {
|
||||
it("should return single medication", async () => {
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Single Med",
|
||||
genericName: "Generic",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.id).toBe(medId);
|
||||
expect(data.name).toBe("Single Med");
|
||||
expect(data.genericName).toBe("Generic");
|
||||
expect(data.takenBy).toEqual(["Daniel"]);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/medications/99999",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock Calculation Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Stock Calculation", () => {
|
||||
it("should calculate total pills correctly", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Stock Test",
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
|
||||
const [med] = response.json();
|
||||
// Total = (2 packs × 3 blisters × 10 pills) + 5 loose = 65
|
||||
const totalPills =
|
||||
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
expect(totalPills).toBe(65);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,710 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
|
||||
// Create test database and mocks before anything else (hoisted)
|
||||
const { testClient, testDb, mockSendMail, mockSendShoutrrr, mockUpdateReminderSentTime, mockUpdateUserReminderSentTime } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockSendMail: vi.fn(),
|
||||
mockSendShoutrrr: vi.fn(),
|
||||
mockUpdateReminderSentTime: vi.fn(),
|
||||
mockUpdateUserReminderSentTime: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock nodemailer
|
||||
vi.mock("nodemailer", () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: mockSendMail,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the db module
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
// Mock env to disable auth
|
||||
vi.mock("../plugins/env.js", () => ({
|
||||
env: {
|
||||
AUTH_ENABLED: false,
|
||||
JWT_SECRET: "test-secret-key-for-testing",
|
||||
JWT_REFRESH_SECRET: "test-refresh-secret-key",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock auth plugin
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: () => 999999999,
|
||||
}));
|
||||
|
||||
// Mock reminder-scheduler
|
||||
vi.mock("../services/reminder-scheduler.js", () => ({
|
||||
updateReminderSentTime: mockUpdateReminderSentTime,
|
||||
updateUserReminderSentTime: mockUpdateUserReminderSentTime,
|
||||
}));
|
||||
|
||||
// Mock sendShoutrrrNotification from settings
|
||||
vi.mock("../routes/settings.js", async (importOriginal) => {
|
||||
const original = await importOriginal() as any;
|
||||
return {
|
||||
...original,
|
||||
sendShoutrrrNotification: mockSendShoutrrr,
|
||||
};
|
||||
});
|
||||
|
||||
import { plannerRoutes } from "../routes/planner.js";
|
||||
|
||||
// =============================================================================
|
||||
// Test Setup
|
||||
// =============================================================================
|
||||
|
||||
async function createSchema(client: Client) {
|
||||
const tableCreations = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
await client.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
}
|
||||
|
||||
describe("Planner Routes", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await createSchema(testClient);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearData(testClient);
|
||||
|
||||
// Create anonymous user
|
||||
await testClient.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockSendMail.mockReset();
|
||||
mockSendShoutrrr.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
describe("POST /planner/send-email", () => {
|
||||
it("should reject request with missing email", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing email or planner data" });
|
||||
});
|
||||
|
||||
it("should reject request with missing rows", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
rows: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing email or planner data" });
|
||||
});
|
||||
|
||||
it("should reject when SMTP is not configured", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "SMTP not configured" });
|
||||
});
|
||||
|
||||
it("should send email successfully when SMTP is configured", async () => {
|
||||
// Set SMTP env vars
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
language: "en",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Email sent successfully" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Cleanup
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle email with out of stock medications", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 5,
|
||||
plannerUsage: 30,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 3,
|
||||
fullBlisters: 0,
|
||||
loosePills: 5,
|
||||
enough: false,
|
||||
},
|
||||
{
|
||||
medicationId: 2,
|
||||
medicationName: "Ibuprofen",
|
||||
totalPills: 100,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 10,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check that HTML contains out of stock warning
|
||||
const mailCall = mockSendMail.mock.calls[0][0];
|
||||
expect(mailCall.html).toContain("Out of Stock");
|
||||
expect(mailCall.html).toContain("1 medication");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle SMTP error gracefully", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
mockSendMail.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-01",
|
||||
until: "2025-01-31",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toContain("Failed to send email");
|
||||
expect(response.json().error).toContain("Connection refused");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should use German locale when language is de", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
from: "2025-01-15",
|
||||
until: "2025-02-15",
|
||||
language: "de",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// German date format should be used
|
||||
const mailCall = mockSendMail.mock.calls[0][0];
|
||||
expect(mailCall.subject).toContain("Supply Overview");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /reminder/send-email", () => {
|
||||
it("should reject request with missing lowStock data", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing low stock data" });
|
||||
});
|
||||
|
||||
it("should reject request with no lowStock array", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing low stock data" });
|
||||
});
|
||||
|
||||
it("should return error when no notification channels configured", async () => {
|
||||
// User settings exist but email/shoutrrr disabled
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
||||
});
|
||||
|
||||
it("should send email reminder when email is enabled", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
// Enable email in user settings
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Reminder sent via email" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle empty medications (medsLeft <= 0)", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null },
|
||||
{ name: "Ibuprofen", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Check email contains EMPTY warning
|
||||
const mailCall = mockSendMail.mock.calls[0][0];
|
||||
expect(mailCall.subject).toContain("Empty");
|
||||
expect(mailCall.html).toContain("EMPTY");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle mixed empty and low stock medications", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null },
|
||||
{ name: "Ibuprofen", medsLeft: 10, daysLeft: 5, depletionDate: "2025-01-05" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const mailCall = mockSendMail.mock.calls[0][0];
|
||||
expect(mailCall.subject).toContain("Empty");
|
||||
expect(mailCall.subject).toContain("Running Low");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle email error gracefully", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockRejectedValueOnce(new Error("SMTP error"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toContain("Email:");
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should send push notification when shoutrrr is enabled", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Reminder sent via push" });
|
||||
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should send both email and push when both enabled", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Reminder sent via email and push" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should handle push notification error gracefully", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toContain("Push:");
|
||||
});
|
||||
|
||||
it("should handle push with empty meds using German translations", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check German translations are used
|
||||
const [title, message] = mockSendShoutrrr.mock.calls[0].slice(1);
|
||||
expect(title).toContain("Leer");
|
||||
});
|
||||
|
||||
it("should handle push exception gracefully", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendShoutrrr.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [
|
||||
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toContain("Push:");
|
||||
expect(response.json().error).toContain("Network error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
import cookie from "@fastify/cookie";
|
||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
// Import from utils to avoid index.ts import side effects (server start)
|
||||
import {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
} from "../utils/server-config.js";
|
||||
|
||||
describe("Index.ts Utility Functions", () => {
|
||||
describe("parseCorsOrigins", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,http://localhost:4173");
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
|
||||
it("should handle single origin", () => {
|
||||
const origins = parseCorsOrigins("https://myapp.example.com");
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,,http://localhost:4173,");
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const origins = parseCorsOrigins(" http://localhost:5173 , http://localhost:4173 ");
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty string", () => {
|
||||
const origins = parseCorsOrigins("");
|
||||
expect(origins).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBaseCookieOptions", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const options = buildBaseCookieOptions(15, true);
|
||||
expect(options.secure).toBe(true);
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe("lax");
|
||||
expect(options.path).toBe("/");
|
||||
});
|
||||
|
||||
it("should set secure=false in development", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.secure).toBe(false);
|
||||
});
|
||||
|
||||
it("should calculate maxAge in seconds from minutes", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.maxAge).toBe(15 * 60); // 900 seconds
|
||||
});
|
||||
|
||||
it("should handle custom TTL values", () => {
|
||||
const options = buildBaseCookieOptions(30, false);
|
||||
expect(options.maxAge).toBe(30 * 60); // 1800 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRefreshCookieOptions", () => {
|
||||
it("should extend base options with longer maxAge", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
|
||||
expect(refresh.httpOnly).toBe(true);
|
||||
expect(refresh.sameSite).toBe("lax");
|
||||
expect(refresh.maxAge).toBe(7 * 24 * 60 * 60); // 7 days in seconds
|
||||
});
|
||||
|
||||
it("should calculate 14 days correctly", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 14);
|
||||
expect(refresh.maxAge).toBe(14 * 24 * 60 * 60); // 1209600 seconds
|
||||
});
|
||||
|
||||
it("should preserve secure flag from base", () => {
|
||||
const base = buildBaseCookieOptions(15, true);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
expect(refresh.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppConfig", () => {
|
||||
it("should build complete config object", () => {
|
||||
const config = buildAppConfig({
|
||||
jwtSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
|
||||
expect(config.accessSecret).toBe("test-jwt-secret");
|
||||
expect(config.refreshSecret).toBe("test-refresh-secret");
|
||||
expect(config.accessTtl).toBe(15);
|
||||
expect(config.refreshTtl).toBe(7);
|
||||
expect(config.cookieOptions).toBeDefined();
|
||||
expect(config.refreshCookieOptions).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use empty strings for missing secrets", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
|
||||
expect(config.accessSecret).toBe("");
|
||||
expect(config.refreshSecret).toBe("");
|
||||
});
|
||||
|
||||
it("should set secure cookies in production", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: true,
|
||||
});
|
||||
|
||||
expect(config.cookieOptions.secure).toBe(true);
|
||||
expect(config.refreshCookieOptions.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureImagesDirectory", () => {
|
||||
const testDir = resolve(tmpdir(), `test-images-dir-${Date.now()}`);
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should create directory if it does not exist", () => {
|
||||
const imagesDir = ensureImagesDirectory(testDir);
|
||||
expect(existsSync(imagesDir)).toBe(true);
|
||||
expect(imagesDir).toContain("data/images");
|
||||
});
|
||||
|
||||
it("should return path if directory already exists", () => {
|
||||
const firstCall = ensureImagesDirectory(testDir);
|
||||
const secondCall = ensureImagesDirectory(testDir);
|
||||
expect(firstCall).toBe(secondCall);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJwtConfig", () => {
|
||||
it("should return real secret when auth enabled with secret", () => {
|
||||
const config = getJwtConfig(true, "my-super-secret");
|
||||
expect(config.secret).toBe("my-super-secret");
|
||||
expect(config.cookie.cookieName).toBe("access_token");
|
||||
expect(config.cookie.signed).toBe(false);
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth disabled", () => {
|
||||
const config = getJwtConfig(false, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth enabled but no secret", () => {
|
||||
const config = getJwtConfig(true, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth enabled with empty secret", () => {
|
||||
const config = getJwtConfig(true, "");
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test the server bootstrap logic without starting the actual server
|
||||
|
||||
describe("Server Bootstrap", () => {
|
||||
describe("Fastify App Configuration", () => {
|
||||
it("should create a Fastify instance with logger", async () => {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: "silent", // Disable logging for tests
|
||||
},
|
||||
});
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app.log).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register sensible plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
|
||||
// Sensible adds error helpers
|
||||
expect(app.httpErrors).toBeDefined();
|
||||
expect(app.httpErrors.notFound).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register cors plugin with multiple origins", async () => {
|
||||
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
|
||||
// Add a test route
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
await app.ready();
|
||||
|
||||
// Test CORS headers
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test",
|
||||
headers: {
|
||||
origin: "http://localhost:5173",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:5173");
|
||||
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
// Add a test route that sets a cookie
|
||||
app.get("/set-cookie", async (request, reply) => {
|
||||
reply.setCookie("test", "value", { path: "/" });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/set-cookie",
|
||||
});
|
||||
|
||||
expect(response.headers["set-cookie"]).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Decorator", () => {
|
||||
it("should create config with auth settings", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 7;
|
||||
|
||||
const baseCookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: false, // test environment
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60,
|
||||
};
|
||||
|
||||
const refreshCookieOptions = {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: accessTtlMinutes,
|
||||
refreshTtl: refreshTtlDays,
|
||||
cookieOptions: baseCookieOptions,
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
expect((app as any).config.accessTtl).toBe(15);
|
||||
expect((app as any).config.refreshTtl).toBe(7);
|
||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should calculate cookie maxAge correctly", () => {
|
||||
const accessTtlMinutes = 30;
|
||||
const refreshTtlDays = 14;
|
||||
|
||||
const accessMaxAge = accessTtlMinutes * 60;
|
||||
const refreshMaxAge = refreshTtlDays * 24 * 60 * 60;
|
||||
|
||||
expect(accessMaxAge).toBe(1800); // 30 minutes in seconds
|
||||
expect(refreshMaxAge).toBe(1209600); // 14 days in seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORS Origins Parsing", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const originsEnv = "http://localhost:5173,http://localhost:4173";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
|
||||
it("should handle single origin", () => {
|
||||
const originsEnv = "https://myapp.example.com";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
const originsEnv = "http://localhost:5173,,http://localhost:4173,";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const originsEnv = " http://localhost:5173 , http://localhost:4173 ";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Route Registration", () => {
|
||||
it("should register multiple route plugins", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: any) => {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
};
|
||||
|
||||
const authRoutes = async (app: any) => {
|
||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||
};
|
||||
|
||||
const medicationRoutes = async (app: any) => {
|
||||
app.get("/medications", async () => []);
|
||||
};
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
|
||||
await app.ready();
|
||||
|
||||
// Verify routes are registered
|
||||
const routes = app.printRoutes();
|
||||
expect(routes).toContain("health");
|
||||
expect(routes).toContain("auth/login");
|
||||
expect(routes).toContain("medications");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server Startup", () => {
|
||||
it("should listen on specified port", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
// Use port 0 to get a random available port
|
||||
const address = await app.listen({ port: 0, host: "127.0.0.1" });
|
||||
|
||||
expect(address).toContain("127.0.0.1");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should handle listen errors gracefully", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Try to listen on an invalid port
|
||||
await expect(
|
||||
app.listen({ port: -1, host: "127.0.0.1" })
|
||||
).rejects.toThrow();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Images Directory", () => {
|
||||
it("should construct images directory path correctly", () => {
|
||||
const resolve = (base: string, ...paths: string[]) => {
|
||||
return [base, ...paths].join("/").replace(/\/+/g, "/");
|
||||
};
|
||||
|
||||
const cwd = "/app";
|
||||
const imagesDir = resolve(cwd, "data/images");
|
||||
|
||||
expect(imagesDir).toBe("/app/data/images");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cookie Options", () => {
|
||||
describe("Production vs Development", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const isProduction = true;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(true);
|
||||
});
|
||||
|
||||
it("should set secure=false in development", () => {
|
||||
const isProduction = false;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
it("should configure rate limit settings", () => {
|
||||
const rateLimitConfig = {
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
};
|
||||
|
||||
expect(rateLimitConfig.max).toBe(100);
|
||||
expect(rateLimitConfig.timeWindow).toBe("1 minute");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT Configuration", () => {
|
||||
it("should configure JWT with auth enabled", () => {
|
||||
const authEnabled = true;
|
||||
const jwtSecret = "my-super-secret-jwt-key";
|
||||
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe(jwtSecret);
|
||||
expect(jwtConfig.cookie.cookieName).toBe("access_token");
|
||||
expect(jwtConfig.cookie.signed).toBe(false);
|
||||
});
|
||||
|
||||
it("should use dummy secret with auth disabled", () => {
|
||||
const authEnabled = false;
|
||||
const jwtSecret = undefined;
|
||||
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multipart Configuration", () => {
|
||||
it("should set file size limit to 10MB", () => {
|
||||
const fileSizeLimit = 10 * 1024 * 1024;
|
||||
|
||||
expect(fileSizeLimit).toBe(10485760);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
// Import actual utility functions from scheduler-utils
|
||||
import {
|
||||
getTimezone,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getTodayInTimezone,
|
||||
getNextScheduledTime,
|
||||
getMsUntilNextCheck,
|
||||
parseBlisters,
|
||||
parseTakenByJson,
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
getUpcomingIntakes,
|
||||
getTodaysIntakes,
|
||||
createDefaultReminderState,
|
||||
createDefaultIntakeReminderState,
|
||||
parseReminderState,
|
||||
parseIntakeReminderState,
|
||||
cleanOldIntakeReminders,
|
||||
type Blister,
|
||||
type ReminderState,
|
||||
type IntakeReminderState,
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
describe("Scheduler Utils - Timezone Functions", () => {
|
||||
let originalTz: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalTz = process.env.TZ;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTz !== undefined) {
|
||||
process.env.TZ = originalTz;
|
||||
} else {
|
||||
delete process.env.TZ;
|
||||
}
|
||||
});
|
||||
|
||||
describe("getTimezone", () => {
|
||||
it("should return TZ env variable when set", () => {
|
||||
process.env.TZ = "America/New_York";
|
||||
expect(getTimezone()).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("should return UTC when TZ not set", () => {
|
||||
delete process.env.TZ;
|
||||
expect(getTimezone()).toBe("UTC");
|
||||
});
|
||||
|
||||
it("should handle Europe/Berlin timezone", () => {
|
||||
process.env.TZ = "Europe/Berlin";
|
||||
expect(getTimezone()).toBe("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInTimezone", () => {
|
||||
it("should format date in given timezone", () => {
|
||||
const date = new Date("2025-12-30T12:00:00.000Z");
|
||||
const formatted = formatInTimezone(date, "UTC");
|
||||
expect(formatted).toContain("30");
|
||||
expect(formatted).toContain("12");
|
||||
});
|
||||
|
||||
it("should use process.env.TZ when no tz provided", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const date = new Date("2025-12-30T15:30:00.000Z");
|
||||
const formatted = formatInTimezone(date);
|
||||
expect(formatted).toContain("15:30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentHourInTimezone", () => {
|
||||
it("should return a valid hour (0-23)", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const hour = getCurrentHourInTimezone();
|
||||
expect(hour).toBeGreaterThanOrEqual(0);
|
||||
expect(hour).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
it("should respect timezone parameter", () => {
|
||||
const hourUtc = getCurrentHourInTimezone("UTC");
|
||||
expect(hourUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(hourUtc).toBeLessThanOrEqual(23);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodayInTimezone", () => {
|
||||
it("should return date in YYYY-MM-DD format", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const today = getTodayInTimezone();
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("should return a valid date", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const today = getTodayInTimezone();
|
||||
const date = new Date(today);
|
||||
expect(date.toString()).not.toBe("Invalid Date");
|
||||
});
|
||||
|
||||
it("should respect timezone parameter", () => {
|
||||
const today = getTodayInTimezone("UTC");
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextScheduledTime", () => {
|
||||
it("should return a Date object", () => {
|
||||
const next = getNextScheduledTime(6, "UTC");
|
||||
expect(next).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should return a time in the future", () => {
|
||||
// Use hour 0 to minimize chance of being exactly at that hour
|
||||
const next = getNextScheduledTime(0, "UTC");
|
||||
expect(next.getTime()).toBeGreaterThan(Date.now() - 60 * 60 * 1000); // Within 1 hour of now or future
|
||||
});
|
||||
|
||||
it("should schedule for the given hour", () => {
|
||||
const next = getNextScheduledTime(10, "UTC");
|
||||
const hourInUtc = parseInt(next.toLocaleString("en-US", { timeZone: "UTC", hour: "numeric", hour12: false }), 10);
|
||||
expect(hourInUtc).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMsUntilNextCheck", () => {
|
||||
it("should return a positive number (or very small negative within tolerance)", () => {
|
||||
const ms = getMsUntilNextCheck(6, "UTC");
|
||||
// Could be slightly negative if we're right at the scheduled time
|
||||
expect(ms).toBeGreaterThan(-60000);
|
||||
});
|
||||
|
||||
it("should be less than or equal to 24 hours", () => {
|
||||
const ms = getMsUntilNextCheck(6, "UTC");
|
||||
const maxMs = 24 * 60 * 60 * 1000 + 60000; // 24h + 1min tolerance
|
||||
expect(ms).toBeLessThanOrEqual(maxMs);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Blister Parsing", () => {
|
||||
describe("parseBlisters", () => {
|
||||
it("should parse valid blister JSON arrays", () => {
|
||||
const row = {
|
||||
usageJson: "[1, 2, 0.5]",
|
||||
everyJson: "[1, 2, 7]",
|
||||
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
|
||||
};
|
||||
|
||||
const blisters = parseBlisters(row);
|
||||
|
||||
expect(blisters).toHaveLength(3);
|
||||
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
|
||||
expect(blisters[1]).toEqual({ usage: 2, every: 2, start: "2025-01-01T20:00" });
|
||||
expect(blisters[2]).toEqual({ usage: 0.5, every: 7, start: "2025-01-01T12:00" });
|
||||
});
|
||||
|
||||
it("should handle arrays of different lengths (use shortest)", () => {
|
||||
const row = {
|
||||
usageJson: "[1, 2]",
|
||||
everyJson: "[1]",
|
||||
startJson: '["2025-01-01T08:00", "2025-01-01T20:00", "2025-01-01T12:00"]',
|
||||
};
|
||||
|
||||
const blisters = parseBlisters(row);
|
||||
|
||||
expect(blisters).toHaveLength(1);
|
||||
expect(blisters[0]).toEqual({ usage: 1, every: 1, start: "2025-01-01T08:00" });
|
||||
});
|
||||
|
||||
it("should return empty array for empty JSON arrays", () => {
|
||||
const row = {
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
};
|
||||
|
||||
const blisters = parseBlisters(row);
|
||||
expect(blisters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return empty array for invalid JSON", () => {
|
||||
const row = {
|
||||
usageJson: "invalid",
|
||||
everyJson: "[1]",
|
||||
startJson: '["2025-01-01T08:00"]',
|
||||
};
|
||||
|
||||
const blisters = parseBlisters(row);
|
||||
expect(blisters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return empty array for non-array JSON", () => {
|
||||
const row = {
|
||||
usageJson: '{"usage": 1}',
|
||||
everyJson: "[1]",
|
||||
startJson: '["2025-01-01T08:00"]',
|
||||
};
|
||||
|
||||
const blisters = parseBlisters(row);
|
||||
expect(blisters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTakenByJson", () => {
|
||||
it("should return empty array for null input", () => {
|
||||
expect(parseTakenByJson(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for undefined input", () => {
|
||||
expect(parseTakenByJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty string", () => {
|
||||
expect(parseTakenByJson("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse valid JSON array of strings", () => {
|
||||
expect(parseTakenByJson('["Alice", "Bob"]')).toEqual(["Alice", "Bob"]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty JSON array", () => {
|
||||
expect(parseTakenByJson("[]")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter out non-string values", () => {
|
||||
expect(parseTakenByJson('[1, "Alice", null, "Bob", true]')).toEqual(["Alice", "Bob"]);
|
||||
});
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
expect(parseTakenByJson('["Alice", "", "Bob", " "]')).toEqual(["Alice", "Bob"]);
|
||||
});
|
||||
|
||||
it("should return empty array for invalid JSON", () => {
|
||||
expect(parseTakenByJson("invalid json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for non-array JSON", () => {
|
||||
expect(parseTakenByJson('{"name": "Alice"}')).toEqual([]);
|
||||
expect(parseTakenByJson('"Alice"')).toEqual([]);
|
||||
expect(parseTakenByJson("123")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Daily Usage Calculation", () => {
|
||||
describe("calculateDailyUsage", () => {
|
||||
it("should calculate daily usage for single daily dose", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
||||
expect(calculateDailyUsage(blisters)).toBe(1);
|
||||
});
|
||||
|
||||
it("should calculate daily usage for twice daily dose", () => {
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
|
||||
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
|
||||
];
|
||||
expect(calculateDailyUsage(blisters)).toBe(2);
|
||||
});
|
||||
|
||||
it("should calculate daily usage for weekly dose", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 7, start: "2025-01-01T08:00" }];
|
||||
expect(calculateDailyUsage(blisters)).toBeCloseTo(1/7, 5);
|
||||
});
|
||||
|
||||
it("should calculate daily usage for mixed schedules", () => {
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 2, every: 1, start: "2025-01-01T08:00" }, // 2 per day
|
||||
{ usage: 1, every: 2, start: "2025-01-01T20:00" }, // 0.5 per day
|
||||
];
|
||||
expect(calculateDailyUsage(blisters)).toBe(2.5);
|
||||
});
|
||||
|
||||
it("should return 0 for empty blisters", () => {
|
||||
expect(calculateDailyUsage([])).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle fractional usage amounts", () => {
|
||||
const blisters: Blister[] = [{ usage: 0.5, every: 1, start: "2025-01-01T08:00" }];
|
||||
expect(calculateDailyUsage(blisters)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Depletion Calculation", () => {
|
||||
describe("calculateDepletionInfo", () => {
|
||||
it("should calculate days left correctly", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
||||
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
||||
expect(result.daysLeft).toBe(30);
|
||||
expect(result.depletionDate).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should calculate days left with multiple doses per day", () => {
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00" },
|
||||
{ usage: 1, every: 1, start: "2025-01-01T20:00" },
|
||||
];
|
||||
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
||||
expect(result.daysLeft).toBe(15);
|
||||
});
|
||||
|
||||
it("should return null when no blisters configured", () => {
|
||||
const result = calculateDepletionInfo({ count: 30, blisters: [] }, "en");
|
||||
expect(result.daysLeft).toBeNull();
|
||||
expect(result.depletionDate).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when usage is zero", () => {
|
||||
const blisters: Blister[] = [{ usage: 0, every: 1, start: "2025-01-01T08:00" }];
|
||||
const result = calculateDepletionInfo({ count: 30, blisters }, "en");
|
||||
expect(result.daysLeft).toBeNull();
|
||||
});
|
||||
|
||||
it("should floor the days left", () => {
|
||||
// 10 pills / 3 per day = 3.33... days -> floors to 3
|
||||
const blisters: Blister[] = [{ usage: 3, every: 1, start: "2025-01-01T08:00" }];
|
||||
const result = calculateDepletionInfo({ count: 10, blisters }, "en");
|
||||
expect(result.daysLeft).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle German language", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00" }];
|
||||
const result = calculateDepletionInfo({ count: 10, blisters }, "de");
|
||||
expect(result.depletionDate).toBeTruthy();
|
||||
// German locale should be used
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
describe("getUpcomingIntakes", () => {
|
||||
it("should return empty array when no intakes in window", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
// Set "now" to a time far from any scheduled intake
|
||||
const now = new Date("2025-01-01T12:00:00.000Z").getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should find intake within reminder window", () => {
|
||||
// Schedule intake at 08:00, check at 07:45 (15 minutes before)
|
||||
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].medName).toBe("TestMed");
|
||||
expect(result[0].usage).toBe(2);
|
||||
expect(result[0].takenBy).toEqual(["Alice"]);
|
||||
expect(result[0].pillWeightMg).toBe(500);
|
||||
});
|
||||
|
||||
it("should skip blisters with zero interval", () => {
|
||||
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00.000Z" }];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple blisters", () => {
|
||||
// Two intakes at 08:00 and 08:01
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 2, every: 1, start: "2025-01-01T08:01:00.000Z" },
|
||||
];
|
||||
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
|
||||
|
||||
// Both should be found as they're within the window
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodaysIntakes", () => {
|
||||
it("should return all intakes for today", () => {
|
||||
// Daily medication at 08:00 starting yesterday
|
||||
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||
|
||||
// Get intakes for 2025-01-02 (today's intake should be at 08:00)
|
||||
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
|
||||
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
const intake = result.find(i => i.intakeTime.getUTCHours() === 8);
|
||||
expect(intake).toBeDefined();
|
||||
expect(intake?.medName).toBe("TestMed");
|
||||
expect(intake?.usage).toBe(1);
|
||||
});
|
||||
|
||||
it("should include past intakes from today", () => {
|
||||
// Medication at 00:01 today (definitely in the past)
|
||||
const todayMidnight = new Date();
|
||||
todayMidnight.setUTCHours(0, 1, 0, 0);
|
||||
|
||||
const blisters: Blister[] = [{
|
||||
usage: 2,
|
||||
every: 1,
|
||||
start: todayMidnight.toISOString()
|
||||
}];
|
||||
|
||||
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].medName).toBe("PastMed");
|
||||
expect(result[0].usage).toBe(2);
|
||||
expect(result[0].takenBy).toEqual(["Bob"]);
|
||||
expect(result[0].pillWeightMg).toBe(250);
|
||||
});
|
||||
|
||||
it("should handle multiple intakes per day", () => {
|
||||
// Two intakes today: morning and evening
|
||||
const today = new Date();
|
||||
const morning = new Date(today);
|
||||
morning.setUTCHours(8, 0, 0, 0);
|
||||
const evening = new Date(today);
|
||||
evening.setUTCHours(20, 0, 0, 0);
|
||||
|
||||
const blisters: Blister[] = [
|
||||
{ usage: 1, every: 1, start: morning.toISOString() },
|
||||
{ usage: 1, every: 1, start: evening.toISOString() },
|
||||
];
|
||||
|
||||
const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC");
|
||||
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should not include intakes from other days", () => {
|
||||
// Weekly medication on a different day of week
|
||||
const lastWeek = new Date();
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
|
||||
const blisters: Blister[] = [{
|
||||
usage: 1,
|
||||
every: 7,
|
||||
start: lastWeek.toISOString()
|
||||
}];
|
||||
|
||||
// If today is not the same day of week, should return empty
|
||||
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
|
||||
|
||||
// This test might return 0 or 1 depending on the day
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle timezone correctly", () => {
|
||||
// 23:00 in Europe/Berlin on a specific date
|
||||
const blisters: Blister[] = [{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
|
||||
}];
|
||||
|
||||
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
if (result.length > 0) {
|
||||
expect(result[0].intakeTimeStr).toContain("23:");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - State Management", () => {
|
||||
describe("createDefaultReminderState", () => {
|
||||
it("should create default reminder state", () => {
|
||||
const state = createDefaultReminderState();
|
||||
expect(state.lastAutoEmailSent).toBeNull();
|
||||
expect(state.lastAutoEmailDate).toBeNull();
|
||||
expect(state.notifiedMedications).toEqual([]);
|
||||
expect(state.nextScheduledCheck).toBeNull();
|
||||
expect(state.lastNotificationType).toBeNull();
|
||||
expect(state.lastNotificationChannel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDefaultIntakeReminderState", () => {
|
||||
it("should create default intake reminder state", () => {
|
||||
const state = createDefaultIntakeReminderState();
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReminderState", () => {
|
||||
it("should parse valid JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
lastAutoEmailSent: "2025-12-30T10:00:00.000Z",
|
||||
lastAutoEmailDate: "2025-12-30",
|
||||
notifiedMedications: ["med1", "med2"],
|
||||
nextScheduledCheck: "2025-12-31T06:00:00.000Z",
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: "email",
|
||||
});
|
||||
|
||||
const state = parseReminderState(json);
|
||||
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
|
||||
expect(state.lastAutoEmailDate).toBe("2025-12-30");
|
||||
expect(state.notifiedMedications).toEqual(["med1", "med2"]);
|
||||
expect(state.lastNotificationType).toBe("stock");
|
||||
expect(state.lastNotificationChannel).toBe("email");
|
||||
});
|
||||
|
||||
it("should handle partial state with defaults", () => {
|
||||
const json = JSON.stringify({ lastAutoEmailSent: "2025-12-30T10:00:00.000Z" });
|
||||
|
||||
const state = parseReminderState(json);
|
||||
expect(state.lastAutoEmailSent).toBe("2025-12-30T10:00:00.000Z");
|
||||
expect(state.lastAutoEmailDate).toBeNull();
|
||||
expect(state.notifiedMedications).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return defaults for invalid JSON", () => {
|
||||
const state = parseReminderState("invalid json {{{");
|
||||
expect(state.lastAutoEmailSent).toBeNull();
|
||||
expect(state.notifiedMedications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIntakeReminderState", () => {
|
||||
it("should parse valid new format JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
reminders: {
|
||||
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
|
||||
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
const state = parseIntakeReminderState(json);
|
||||
expect(Object.keys(state.reminders)).toHaveLength(2);
|
||||
expect(state.reminders["med1:123"].sendCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should convert old array format to new format", () => {
|
||||
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
|
||||
|
||||
const state = parseIntakeReminderState(json);
|
||||
expect(Object.keys(state.reminders)).toHaveLength(2);
|
||||
expect(state.reminders["med1:123"]).toBeDefined();
|
||||
expect(state.reminders["med1:123"].sendCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should return defaults for invalid JSON", () => {
|
||||
const state = parseIntakeReminderState("invalid");
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle missing reminders field", () => {
|
||||
const state = parseIntakeReminderState("{}");
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanOldIntakeReminders", () => {
|
||||
it("should remove entries from past days (timezone-aware)", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
today.setHours(12, 0, 0, 0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const reminders = {
|
||||
[`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 },
|
||||
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
|
||||
};
|
||||
|
||||
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||
|
||||
expect(Object.keys(cleaned)).toHaveLength(1);
|
||||
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should keep all entries from today", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
morning.setHours(8, 0, 0, 0);
|
||||
|
||||
const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
evening.setHours(20, 0, 0, 0);
|
||||
|
||||
const reminders = {
|
||||
[`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 },
|
||||
[`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 },
|
||||
};
|
||||
|
||||
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||
expect(Object.keys(cleaned)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle empty reminders", () => {
|
||||
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
|
||||
expect(cleaned).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle malformed entries (invalid timestamp in key)", () => {
|
||||
const reminders = {
|
||||
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
|
||||
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 }
|
||||
};
|
||||
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
|
||||
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
|
||||
expect(Object.keys(cleaned)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* Tests for /settings API endpoints.
|
||||
* Tests user settings CRUD operations.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
setUserSettings,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// =============================================================================
|
||||
|
||||
async function registerSettingsRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// GET /settings - Get user settings
|
||||
app.get("/settings", async (request, reply) => {
|
||||
const userId = 1;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Return defaults
|
||||
return {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
expiryWarningDays: 90,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
};
|
||||
}
|
||||
|
||||
const s = result.rows[0];
|
||||
return {
|
||||
emailEnabled: Boolean(s.email_enabled),
|
||||
notificationEmail: s.notification_email || "",
|
||||
emailStockReminders: Boolean(s.email_stock_reminders),
|
||||
emailIntakeReminders: Boolean(s.email_intake_reminders),
|
||||
shoutrrrEnabled: Boolean(s.shoutrrr_enabled),
|
||||
shoutrrrUrl: s.shoutrrr_url || "",
|
||||
shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders),
|
||||
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
|
||||
reminderDaysBefore: s.reminder_days_before,
|
||||
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
|
||||
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses ?? false),
|
||||
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled ?? false),
|
||||
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
|
||||
maxNaggingReminders: s.max_nagging_reminders ?? 5,
|
||||
lowStockDays: s.low_stock_days,
|
||||
normalStockDays: s.normal_stock_days,
|
||||
highStockDays: s.high_stock_days,
|
||||
expiryWarningDays: s.expiry_warning_days,
|
||||
language: s.language,
|
||||
stockCalculationMode: s.stock_calculation_mode,
|
||||
};
|
||||
});
|
||||
|
||||
// PUT /settings - Update user settings
|
||||
app.put<{
|
||||
Body: {
|
||||
emailEnabled?: boolean;
|
||||
notificationEmail?: string;
|
||||
emailStockReminders?: boolean;
|
||||
emailIntakeReminders?: boolean;
|
||||
shoutrrrEnabled?: boolean;
|
||||
shoutrrrUrl?: string;
|
||||
shoutrrrStockReminders?: boolean;
|
||||
shoutrrrIntakeReminders?: boolean;
|
||||
reminderDaysBefore?: number;
|
||||
repeatDailyReminders?: boolean;
|
||||
skipRemindersForTakenDoses?: boolean;
|
||||
repeatRemindersEnabled?: boolean;
|
||||
reminderRepeatIntervalMinutes?: number;
|
||||
maxNaggingReminders?: number;
|
||||
lowStockDays?: number;
|
||||
normalStockDays?: number;
|
||||
highStockDays?: number;
|
||||
expiryWarningDays?: number;
|
||||
language?: string;
|
||||
stockCalculationMode?: "automatic" | "manual";
|
||||
};
|
||||
}>("/settings", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const body = request.body || {};
|
||||
|
||||
// Validation
|
||||
if (body.emailEnabled && !body.notificationEmail) {
|
||||
return reply.status(400).send({ error: "Email address required when email is enabled" });
|
||||
}
|
||||
if (body.notificationEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.notificationEmail)) {
|
||||
return reply.status(400).send({ error: "Invalid email address" });
|
||||
}
|
||||
if (body.lowStockDays !== undefined && (body.lowStockDays < 1 || body.lowStockDays > 365)) {
|
||||
return reply.status(400).send({ error: "lowStockDays must be between 1 and 365" });
|
||||
}
|
||||
if (body.language && !["en", "de"].includes(body.language)) {
|
||||
return reply.status(400).send({ error: "Language must be 'en' or 'de'" });
|
||||
}
|
||||
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
|
||||
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
|
||||
}
|
||||
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
|
||||
return reply.status(400).send({ error: "reminderRepeatIntervalMinutes must be between 5 and 480" });
|
||||
}
|
||||
if (body.maxNaggingReminders !== undefined && (body.maxNaggingReminders < 1 || body.maxNaggingReminders > 20)) {
|
||||
return reply.status(400).send({ error: "maxNaggingReminders must be between 1 and 20" });
|
||||
}
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
// Insert new settings
|
||||
await client.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email,
|
||||
email_stock_reminders, email_intake_reminders,
|
||||
shoutrrr_enabled, shoutrrr_url,
|
||||
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
|
||||
reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses,
|
||||
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||
low_stock_days, normal_stock_days, high_stock_days,
|
||||
expiry_warning_days, language, stock_calculation_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
body.emailEnabled ? 1 : 0,
|
||||
body.notificationEmail || null,
|
||||
body.emailStockReminders !== false ? 1 : 0,
|
||||
body.emailIntakeReminders !== false ? 1 : 0,
|
||||
body.shoutrrrEnabled ? 1 : 0,
|
||||
body.shoutrrrUrl || null,
|
||||
body.shoutrrrStockReminders !== false ? 1 : 0,
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
body.expiryWarningDays ?? 90,
|
||||
body.language || "en",
|
||||
body.stockCalculationMode || "automatic",
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Update existing settings
|
||||
await client.execute({
|
||||
sql: `UPDATE user_settings SET
|
||||
email_enabled = ?,
|
||||
notification_email = ?,
|
||||
email_stock_reminders = ?,
|
||||
email_intake_reminders = ?,
|
||||
shoutrrr_enabled = ?,
|
||||
shoutrrr_url = ?,
|
||||
shoutrrr_stock_reminders = ?,
|
||||
shoutrrr_intake_reminders = ?,
|
||||
reminder_days_before = ?,
|
||||
repeat_daily_reminders = ?,
|
||||
skip_reminders_for_taken_doses = ?,
|
||||
repeat_reminders_enabled = ?,
|
||||
reminder_repeat_interval_minutes = ?,
|
||||
max_nagging_reminders = ?,
|
||||
low_stock_days = ?,
|
||||
normal_stock_days = ?,
|
||||
high_stock_days = ?,
|
||||
expiry_warning_days = ?,
|
||||
language = ?,
|
||||
stock_calculation_mode = ?,
|
||||
updated_at = strftime('%s','now')
|
||||
WHERE user_id = ?`,
|
||||
args: [
|
||||
body.emailEnabled ? 1 : 0,
|
||||
body.notificationEmail || null,
|
||||
body.emailStockReminders !== false ? 1 : 0,
|
||||
body.emailIntakeReminders !== false ? 1 : 0,
|
||||
body.shoutrrrEnabled ? 1 : 0,
|
||||
body.shoutrrrUrl || null,
|
||||
body.shoutrrrStockReminders !== false ? 1 : 0,
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
body.expiryWarningDays ?? 90,
|
||||
body.language || "en",
|
||||
body.stockCalculationMode || "automatic",
|
||||
userId,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Settings API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerSettingsRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /settings", () => {
|
||||
it("should return default settings for new user", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.emailEnabled).toBe(false);
|
||||
expect(data.lowStockDays).toBe(30);
|
||||
expect(data.normalStockDays).toBe(90);
|
||||
expect(data.highStockDays).toBe(180);
|
||||
expect(data.language).toBe("en");
|
||||
expect(data.stockCalculationMode).toBe("automatic");
|
||||
});
|
||||
|
||||
it("should return saved settings", async () => {
|
||||
// Create settings first
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "manual",
|
||||
lowStockDays: 14,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.stockCalculationMode).toBe("manual");
|
||||
expect(data.lowStockDays).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PUT /settings", () => {
|
||||
it("should create settings for new user", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
language: "de",
|
||||
lowStockDays: 14,
|
||||
stockCalculationMode: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT language, low_stock_days, stock_calculation_mode FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].language).toBe("de");
|
||||
expect(result.rows[0].low_stock_days).toBe(14);
|
||||
expect(result.rows[0].stock_calculation_mode).toBe("manual");
|
||||
});
|
||||
|
||||
it("should update existing settings", async () => {
|
||||
// Create initial settings
|
||||
await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: { language: "en" },
|
||||
});
|
||||
|
||||
// Update
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: { language: "de" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT language FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].language).toBe("de");
|
||||
});
|
||||
|
||||
it("should enable email notifications", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: true,
|
||||
notificationEmail: "test@example.com",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT email_enabled, notification_email, email_stock_reminders, email_intake_reminders
|
||||
FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].email_enabled).toBe(1);
|
||||
expect(result.rows[0].notification_email).toBe("test@example.com");
|
||||
expect(result.rows[0].email_stock_reminders).toBe(1);
|
||||
expect(result.rows[0].email_intake_reminders).toBe(0);
|
||||
});
|
||||
|
||||
it("should reject email enabled without email address", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Email address required when email is enabled");
|
||||
});
|
||||
|
||||
it("should reject invalid email address", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
notificationEmail: "not-an-email",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid email address");
|
||||
});
|
||||
|
||||
it("should reject invalid lowStockDays", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
lowStockDays: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("lowStockDays must be between 1 and 365");
|
||||
});
|
||||
|
||||
it("should reject invalid language", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
language: "fr",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Language must be 'en' or 'de'");
|
||||
});
|
||||
|
||||
it("should reject invalid stockCalculationMode", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
stockCalculationMode: "invalid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("stockCalculationMode must be 'automatic' or 'manual'");
|
||||
});
|
||||
|
||||
it("should enable shoutrrr notifications", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/mytopic",
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT shoutrrr_enabled, shoutrrr_url FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].shoutrrr_enabled).toBe(1);
|
||||
expect(result.rows[0].shoutrrr_url).toBe("ntfy://ntfy.sh/mytopic");
|
||||
});
|
||||
|
||||
it("should update threshold settings", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
lowStockDays: 14,
|
||||
normalStockDays: 60,
|
||||
highStockDays: 120,
|
||||
expiryWarningDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT low_stock_days, normal_stock_days, high_stock_days, expiry_warning_days
|
||||
FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].low_stock_days).toBe(14);
|
||||
expect(result.rows[0].normal_stock_days).toBe(60);
|
||||
expect(result.rows[0].high_stock_days).toBe(120);
|
||||
expect(result.rows[0].expiry_warning_days).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stock Calculation Mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Stock Calculation Mode", () => {
|
||||
it("should switch to manual mode", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
stockCalculationMode: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
expect(getResponse.json().stockCalculationMode).toBe("manual");
|
||||
});
|
||||
|
||||
it("should switch back to automatic mode", async () => {
|
||||
// Set to manual first
|
||||
await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: { stockCalculationMode: "manual" },
|
||||
});
|
||||
|
||||
// Switch back
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: { stockCalculationMode: "automatic" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
expect(getResponse.json().stockCalculationMode).toBe("automatic");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repeat Reminders & Skip Reminders Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Repeat Reminders Settings", () => {
|
||||
it("should enable repeat reminders with interval", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||
expect(settings.reminderRepeatIntervalMinutes).toBe(10);
|
||||
});
|
||||
|
||||
it("should validate repeat interval range", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 2,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 500,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should validate max nagging reminders range", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 0,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 25,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
|
||||
// Valid values should work
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
maxNaggingReminders: 10,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.maxNaggingReminders).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skip Reminders for Taken Doses", () => {
|
||||
it("should enable and disable skip reminders setting", async () => {
|
||||
let response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
skipRemindersForTakenDoses: true,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
skipRemindersForTakenDoses: false,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("should work with repeat reminders enabled", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 5,
|
||||
skipRemindersForTakenDoses: true,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
const settings = getResponse.json();
|
||||
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||
expect(settings.reminderRepeatIntervalMinutes).toBe(5);
|
||||
expect(settings.skipRemindersForTakenDoses).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Test setup and utilities for MedAssist backend API tests.
|
||||
* Uses in-memory SQLite for isolation between test files.
|
||||
*/
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { getTableCreationSQL } from "../db/schema-sql.js";
|
||||
|
||||
// Type for our test database
|
||||
export type TestDb = ReturnType<typeof drizzle>;
|
||||
|
||||
// =============================================================================
|
||||
// Test App Builder
|
||||
// =============================================================================
|
||||
export interface TestContext {
|
||||
app: FastifyInstance;
|
||||
db: TestDb;
|
||||
client: Client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a test Fastify app with in-memory SQLite.
|
||||
* Each test file gets its own isolated database.
|
||||
*/
|
||||
export async function buildTestApp(): Promise<TestContext> {
|
||||
// Create in-memory SQLite database
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
|
||||
// Run schema creation
|
||||
await runTestMigrations(client);
|
||||
|
||||
// Create Fastify app with minimal plugins
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// Decorate config (matches index.ts structure)
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: 15,
|
||||
refreshTtl: 7,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
});
|
||||
|
||||
return { app, db, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test database schema
|
||||
*/
|
||||
async function runTestMigrations(client: Client): Promise<void> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
await client.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Helpers
|
||||
// =============================================================================
|
||||
|
||||
export interface CreateUserOptions {
|
||||
username?: string;
|
||||
authProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user and return the ID
|
||||
*/
|
||||
export async function createTestUser(
|
||||
client: Client,
|
||||
options: CreateUserOptions = {}
|
||||
): Promise<number> {
|
||||
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`,
|
||||
args: [username, authProvider],
|
||||
});
|
||||
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
|
||||
export interface CreateMedicationOptions {
|
||||
userId: number;
|
||||
name?: string;
|
||||
genericName?: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
/** Array of { usage, every, start } for each blister schedule */
|
||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test medication and return the ID
|
||||
*/
|
||||
export async function createTestMedication(
|
||||
client: Client,
|
||||
options: CreateMedicationOptions
|
||||
): Promise<number> {
|
||||
const {
|
||||
userId,
|
||||
name = "Test Medication",
|
||||
genericName = null,
|
||||
takenBy = [],
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
pillWeightMg = null,
|
||||
expiryDate = null,
|
||||
notes = null,
|
||||
intakeRemindersEnabled = false,
|
||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||
} = options;
|
||||
|
||||
// Extract arrays from blisters
|
||||
const usageJson = JSON.stringify(blisters.map((b) => b.usage));
|
||||
const everyJson = JSON.stringify(blisters.map((b) => b.every));
|
||||
const startJson = JSON.stringify(blisters.map((b) => b.start));
|
||||
const takenByJson = JSON.stringify(takenBy);
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
userId,
|
||||
name,
|
||||
genericName,
|
||||
takenByJson,
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate,
|
||||
notes,
|
||||
intakeRemindersEnabled ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
|
||||
export interface CreateShareTokenOptions {
|
||||
userId: number;
|
||||
takenBy: string;
|
||||
token?: string;
|
||||
scheduleDays?: number;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test share token and return the token string
|
||||
*/
|
||||
export async function createTestShareToken(
|
||||
client: Client,
|
||||
options: CreateShareTokenOptions
|
||||
): Promise<string> {
|
||||
const {
|
||||
userId,
|
||||
takenBy,
|
||||
token = `test_token_${Date.now()}`,
|
||||
scheduleDays = 30,
|
||||
expiresAt = null,
|
||||
} = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export interface CreateDoseTrackingOptions {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
markedBy?: string | null;
|
||||
takenAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dose tracking record
|
||||
*/
|
||||
export async function createTestDoseTracking(
|
||||
client: Client,
|
||||
options: CreateDoseTrackingOptions
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
doseId,
|
||||
markedBy = null,
|
||||
takenAt = Math.floor(Date.now() / 1000),
|
||||
} = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
args: [userId, doseId, markedBy, takenAt],
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserSettingsOptions {
|
||||
userId: number;
|
||||
stockCalculationMode?: "automatic" | "manual";
|
||||
lowStockDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user settings
|
||||
*/
|
||||
export async function setUserSettings(
|
||||
client: Client,
|
||||
options: UpdateUserSettingsOptions
|
||||
): Promise<void> {
|
||||
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await client.execute({
|
||||
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
|
||||
args: [stockCalculationMode, lowStockDays, userId],
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
|
||||
args: [userId, stockCalculationMode, lowStockDays],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test Cleanup
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Close test app and database connections
|
||||
*/
|
||||
export async function closeTestApp(ctx: TestContext): Promise<void> {
|
||||
await ctx.app.close();
|
||||
ctx.client.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from test database (between tests)
|
||||
*/
|
||||
export async function clearTestData(client: Client): Promise<void> {
|
||||
// Order matters due to foreign keys
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM refresh_tokens");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Vitest Global Setup
|
||||
// =============================================================================
|
||||
|
||||
// Set test environment
|
||||
process.env.AUTH_ENABLED = "false";
|
||||
process.env.NODE_ENV = "test";
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* Tests for share link API endpoints.
|
||||
* Tests creating share tokens, accessing shared schedules, and marking doses via share links.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
createTestShareToken,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// =============================================================================
|
||||
|
||||
async function registerShareRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// POST /share - Create a share token
|
||||
app.post<{ Body: { takenBy: string; scheduleDays?: number } }>("/share", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { takenBy, scheduleDays = 30 } = request.body || {};
|
||||
|
||||
if (!takenBy || typeof takenBy !== "string" || takenBy.length === 0) {
|
||||
return reply.status(400).send({ error: "takenBy is required", code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
if (scheduleDays < 1 || scheduleDays > 365) {
|
||||
return reply.status(400).send({ error: "scheduleDays must be 1-365", code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
// Check if user has medications for this person
|
||||
const meds = await client.execute({
|
||||
sql: `SELECT id, taken_by_json FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const hasMatchingMed = meds.rows.some((m) => {
|
||||
const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]");
|
||||
return takenByList.includes(takenBy);
|
||||
});
|
||||
|
||||
if (!hasMatchingMed) {
|
||||
return reply.status(400).send({ error: "No medications found for this person", code: "NO_MEDICATIONS" });
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = `share_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: new Date(expiresAt * 1000).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// GET /share/:token - Get shared schedule data
|
||||
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
const shareResult = await client.execute({
|
||||
sql: `SELECT st.*, u.username as owner_username
|
||||
FROM share_tokens st
|
||||
JOIN users u ON st.user_id = u.id
|
||||
WHERE st.token = ?`,
|
||||
args: [token],
|
||||
});
|
||||
|
||||
if (shareResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Share link not found", code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const share = shareResult.rows[0];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check expiry
|
||||
if (share.expires_at && (share.expires_at as number) < now) {
|
||||
return reply.status(410).send({
|
||||
error: "Share link has expired",
|
||||
code: "EXPIRED",
|
||||
ownerUsername: share.owner_username,
|
||||
takenBy: share.taken_by,
|
||||
expiredAt: new Date((share.expires_at as number) * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get medications for this person
|
||||
const medsResult = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [share.user_id],
|
||||
});
|
||||
|
||||
const medications = medsResult.rows
|
||||
.filter((m) => {
|
||||
const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]");
|
||||
return takenByList.includes(share.taken_by as string);
|
||||
})
|
||||
.map((m) => {
|
||||
const usageArr: number[] = JSON.parse(m.usage_json as string || "[]");
|
||||
const everyArr: number[] = JSON.parse(m.every_json as string || "[]");
|
||||
const startArr: string[] = JSON.parse(m.start_json as string || "[]");
|
||||
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
genericName: m.generic_name,
|
||||
pillWeightMg: m.pill_weight_mg,
|
||||
imageUrl: m.image_url,
|
||||
totalPills:
|
||||
(m.pack_count as number) *
|
||||
(m.blisters_per_pack as number) *
|
||||
(m.pills_per_blister as number) +
|
||||
(m.loose_tablets as number),
|
||||
packCount: m.pack_count,
|
||||
blistersPerPack: m.blisters_per_pack,
|
||||
looseTablets: m.loose_tablets,
|
||||
pillsPerBlister: m.pills_per_blister,
|
||||
takenBy: JSON.parse(m.taken_by_json as string || "[]"),
|
||||
blisters: usageArr.map((usage, i) => ({
|
||||
usage,
|
||||
every: everyArr[i] || 1,
|
||||
start: startArr[i] || new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Get settings
|
||||
const settingsResult = await client.execute({
|
||||
sql: `SELECT low_stock_days FROM user_settings WHERE user_id = ?`,
|
||||
args: [share.user_id],
|
||||
});
|
||||
|
||||
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
|
||||
|
||||
return {
|
||||
takenBy: share.taken_by,
|
||||
sharedBy: share.owner_username,
|
||||
scheduleDays: share.schedule_days,
|
||||
medications,
|
||||
stockThresholds: {
|
||||
lowStockDays,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// GET /share/:token/doses - Get taken doses for share link
|
||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
const shareResult = await client.execute({
|
||||
sql: `SELECT user_id FROM share_tokens WHERE token = ?`,
|
||||
args: [token],
|
||||
});
|
||||
|
||||
if (shareResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Share link not found" });
|
||||
}
|
||||
|
||||
const userId = shareResult.rows[0].user_id;
|
||||
|
||||
const dosesResult = await client.execute({
|
||||
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
return {
|
||||
doses: dosesResult.rows.map((d) => ({
|
||||
doseId: d.dose_id,
|
||||
takenAt: (d.taken_at as number) * 1000,
|
||||
markedBy: d.marked_by,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// POST /share/:token/doses - Mark dose via share link
|
||||
app.post<{ Params: { token: string }; Body: { doseId: string } }>(
|
||||
"/share/:token/doses",
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const { doseId } = request.body || {};
|
||||
|
||||
if (!doseId) {
|
||||
return reply.status(400).send({ error: "doseId is required" });
|
||||
}
|
||||
|
||||
const shareResult = await client.execute({
|
||||
sql: `SELECT user_id, taken_by FROM share_tokens WHERE token = ?`,
|
||||
args: [token],
|
||||
});
|
||||
|
||||
if (shareResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Share link not found" });
|
||||
}
|
||||
|
||||
const { user_id: userId, taken_by: takenBy } = shareResult.rows[0];
|
||||
|
||||
// Check if already marked
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert with markedBy = takenBy from share token
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, takenBy],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /share/:token/doses/:doseId - Unmark dose via share link
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/doses/:doseId",
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
|
||||
const shareResult = await client.execute({
|
||||
sql: `SELECT user_id FROM share_tokens WHERE token = ?`,
|
||||
args: [token],
|
||||
});
|
||||
|
||||
if (shareResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Share link not found" });
|
||||
}
|
||||
|
||||
const userId = shareResult.rows[0].user_id;
|
||||
|
||||
await client.execute({
|
||||
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// GET /share/people - Get unique takenBy values
|
||||
app.get("/share/people", async (request, reply) => {
|
||||
const userId = 1;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT taken_by_json FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const peopleSet = new Set<string>();
|
||||
for (const row of result.rows) {
|
||||
const takenByList: string[] = JSON.parse(row.taken_by_json as string || "[]");
|
||||
takenByList.forEach((p) => peopleSet.add(p));
|
||||
}
|
||||
|
||||
return { people: Array.from(peopleSet).sort() };
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Share Link API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerShareRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Reset SQLite autoincrement so user gets ID 1
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share - Create share token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /share", () => {
|
||||
it("should create a share token for a person", async () => {
|
||||
// Create medication with takenBy
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.token).toBeDefined();
|
||||
expect(data.token.length).toBeGreaterThan(10);
|
||||
expect(data.shareUrl).toBe(`/share/${data.token}`);
|
||||
expect(data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject when no medications for person", async () => {
|
||||
// Create medication with different takenBy
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Max"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: "No medications found for this person",
|
||||
code: "NO_MEDICATIONS",
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject request without takenBy", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { scheduleDays: 30 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: "takenBy is required",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use custom scheduleDays", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 90 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Verify in DB
|
||||
const token = response.json().token;
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT schedule_days FROM share_tokens WHERE token = ?`,
|
||||
args: [token],
|
||||
});
|
||||
expect(result.rows[0].schedule_days).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token - Access shared schedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /share/:token", () => {
|
||||
it("should return shared schedule data", async () => {
|
||||
// Create medication
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
takenBy: ["Daniel"],
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
});
|
||||
|
||||
// Create share token
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 30,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
|
||||
expect(data.takenBy).toBe("Daniel");
|
||||
expect(data.sharedBy).toBe("testuser");
|
||||
expect(data.scheduleDays).toBe(30);
|
||||
expect(data.medications).toHaveLength(1);
|
||||
|
||||
const med = data.medications[0];
|
||||
expect(med.name).toBe("Aspirin");
|
||||
expect(med.genericName).toBe("Acetylsalicylic acid");
|
||||
expect(med.totalPills).toBe(2 * 3 * 10 + 5); // 65
|
||||
expect(med.takenBy).toEqual(["Daniel"]);
|
||||
expect(med.blisters).toHaveLength(1);
|
||||
expect(med.blisters[0].usage).toBe(1);
|
||||
expect(med.blisters[0].every).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 404 for invalid token", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/share/invalid_token_123",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 410 for expired token", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
// Create expired token (expired 1 day ago)
|
||||
const expiredAt = Math.floor(Date.now() / 1000) - 86400;
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
expiresAt: expiredAt,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(410);
|
||||
const data = response.json();
|
||||
expect(data.code).toBe("EXPIRED");
|
||||
expect(data.ownerUsername).toBe("testuser");
|
||||
expect(data.takenBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
it("should filter medications to only those for takenBy person", async () => {
|
||||
// Create two medications - one for Daniel, one for Max
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Ibuprofen",
|
||||
takenBy: ["Max"],
|
||||
});
|
||||
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.medications).toHaveLength(1);
|
||||
expect(data.medications[0].name).toBe("Aspirin");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Share Token Dose Tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Share link dose tracking", () => {
|
||||
it("POST /share/:token/doses should mark dose with markedBy", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify markedBy is set to takenBy from share token
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT marked_by FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].marked_by).toBe("Daniel");
|
||||
});
|
||||
|
||||
it("GET /share/:token/doses should return all doses for owner", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
|
||||
// Create some dose tracking records
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, "1-0-1735344000000", null],
|
||||
});
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, "1-0-1735430400000", "Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}/doses`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("DELETE /share/:token/doses/:doseId should unmark dose", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
});
|
||||
|
||||
const token = await createTestShareToken(ctx.client, {
|
||||
userId,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Mark dose first
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Unmark
|
||||
const response = await ctx.app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/${token}/doses/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify deleted
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /share/people", () => {
|
||||
it("should return unique takenBy values from all medications", async () => {
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Med 1",
|
||||
takenBy: ["Daniel", "Max"],
|
||||
});
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Med 2",
|
||||
takenBy: ["Daniel", "Lisa"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/share/people",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.people).toEqual(["Daniel", "Lisa", "Max"]); // sorted
|
||||
});
|
||||
|
||||
it("should return empty array when no medications", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/share/people",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ people: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* Tests for stock calculation modes (automatic vs manual).
|
||||
* Tests the /medications/usage endpoint with different settings.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
createTestDoseTracking,
|
||||
setUserSettings,
|
||||
TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// =============================================================================
|
||||
|
||||
async function registerUsageRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// POST /medications/usage - Calculate medication usage for a date range
|
||||
app.post<{ Body: { startDate: string; endDate: string } }>(
|
||||
"/medications/usage",
|
||||
async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { startDate, endDate } = request.body || {};
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return reply.status(400).send({ error: "startDate and endDate are required" });
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// Get user settings
|
||||
const settingsResult = await client.execute({
|
||||
sql: `SELECT stock_calculation_mode FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
const stockMode =
|
||||
settingsResult.rows.length > 0
|
||||
? (settingsResult.rows[0].stock_calculation_mode as string)
|
||||
: "automatic";
|
||||
|
||||
// Get all medications
|
||||
const medsResult = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const med of medsResult.rows) {
|
||||
const totalPills =
|
||||
(med.pack_count as number) *
|
||||
(med.blisters_per_pack as number) *
|
||||
(med.pills_per_blister as number) +
|
||||
(med.loose_tablets as number);
|
||||
|
||||
const blisterSize = med.pills_per_blister as number;
|
||||
|
||||
// Calculate usage based on schedule
|
||||
const usageArr: number[] = JSON.parse((med.usage_json as string) || "[]");
|
||||
const everyArr: number[] = JSON.parse((med.every_json as string) || "[]");
|
||||
const startArr: string[] = JSON.parse((med.start_json as string) || "[]");
|
||||
|
||||
let plannerUsage = 0;
|
||||
|
||||
if (stockMode === "automatic") {
|
||||
// Automatic: Calculate from schedule
|
||||
for (let i = 0; i < usageArr.length; i++) {
|
||||
const usage = usageArr[i] || 0;
|
||||
const every = everyArr[i] || 1;
|
||||
const scheduleStart = new Date(startArr[i] || start);
|
||||
|
||||
// Count doses from scheduleStart to end within the range
|
||||
let current = new Date(scheduleStart);
|
||||
while (current <= end) {
|
||||
if (current >= start) {
|
||||
plannerUsage += usage;
|
||||
}
|
||||
current.setDate(current.getDate() + every);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Manual: Count only tracked doses in the date range
|
||||
const dosesResult = await client.execute({
|
||||
sql: `SELECT dose_id FROM dose_tracking
|
||||
WHERE user_id = ?
|
||||
AND taken_at >= ?
|
||||
AND taken_at <= ?`,
|
||||
args: [
|
||||
userId,
|
||||
Math.floor(start.getTime() / 1000),
|
||||
Math.floor(end.getTime() / 1000),
|
||||
],
|
||||
});
|
||||
|
||||
// Filter to doses for this medication
|
||||
const medIdStr = `${med.id}-`;
|
||||
for (const dose of dosesResult.rows) {
|
||||
const doseId = dose.dose_id as string;
|
||||
if (doseId.startsWith(medIdStr)) {
|
||||
// Parse usage from the schedule based on blister index
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
plannerUsage += usageArr[blisterIdx] || 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate how many blisters/pills needed
|
||||
const blistersNeeded = Math.ceil(plannerUsage / blisterSize);
|
||||
const fullBlisters = Math.floor(plannerUsage / blisterSize);
|
||||
const loosePills = plannerUsage % blisterSize;
|
||||
|
||||
results.push({
|
||||
medicationId: med.id,
|
||||
medicationName: med.name,
|
||||
totalPills,
|
||||
plannerUsage,
|
||||
blisterSize,
|
||||
blistersNeeded,
|
||||
fullBlisters,
|
||||
loosePills,
|
||||
enough: totalPills >= plannerUsage,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
);
|
||||
|
||||
// GET /medications - List medications (for checking stock)
|
||||
app.get("/medications", async (request, reply) => {
|
||||
const userId = 1;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT * FROM medications WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
return result.rows.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
packCount: m.pack_count,
|
||||
blistersPerPack: m.blisters_per_pack,
|
||||
pillsPerBlister: m.pills_per_blister,
|
||||
looseTablets: m.loose_tablets,
|
||||
totalPills:
|
||||
(m.pack_count as number) *
|
||||
(m.blisters_per_pack as number) *
|
||||
(m.pills_per_blister as number) +
|
||||
(m.loose_tablets as number),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Stock Calculation API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerUsageRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Reset SQLite autoincrement so user gets ID 1
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Automatic Mode Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Automatic mode", () => {
|
||||
beforeEach(async () => {
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "automatic",
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate usage from schedule", async () => {
|
||||
// Medication: 1 pill daily starting Jan 1
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Aspirin",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// Calculate usage for 10 days (Jan 1-10)
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(1);
|
||||
|
||||
const med = data[0];
|
||||
expect(med.medicationName).toBe("Aspirin");
|
||||
expect(med.totalPills).toBe(30);
|
||||
expect(med.plannerUsage).toBe(10); // 10 days, 1 pill/day
|
||||
expect(med.enough).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle every-other-day schedules", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Med B",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 20,
|
||||
blisters: [{ usage: 2, every: 2, start: start.toISOString() }], // 2 pills every 2 days
|
||||
});
|
||||
|
||||
// 10 days: Jan 1, 3, 5, 7, 9 = 5 doses × 2 pills = 10 pills
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle multiple blisters (schedules)", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Multi Schedule",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 50,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: start.toISOString() }, // Morning: 1/day
|
||||
{ usage: 1, every: 1, start: start.toISOString() }, // Evening: 1/day
|
||||
],
|
||||
});
|
||||
|
||||
// 10 days: 2 schedules × 10 days × 1 pill = 20 pills
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(20);
|
||||
});
|
||||
|
||||
it("should return enough=false when stock insufficient", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Low Stock Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 5, // Only 5 pills
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// Need 10 pills but only have 5
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].totalPills).toBe(5);
|
||||
expect(data[0].plannerUsage).toBe(10);
|
||||
expect(data[0].enough).toBe(false);
|
||||
});
|
||||
|
||||
it("should calculate blister counts correctly", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Blister Test",
|
||||
packCount: 2,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10, // 4 blisters × 10 = 40 pills
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// 25 days = 25 pills needed = 2 full blisters + 5 loose
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-25T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(25);
|
||||
expect(data[0].blisterSize).toBe(10);
|
||||
expect(data[0].blistersNeeded).toBe(3); // ceil(25/10)
|
||||
expect(data[0].fullBlisters).toBe(2); // floor(25/10)
|
||||
expect(data[0].loosePills).toBe(5); // 25 % 10
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual Mode Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Manual mode", () => {
|
||||
beforeEach(async () => {
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("should count only tracked doses", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Manual Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// In automatic mode this would count 10 doses
|
||||
// In manual mode, only count tracked doses
|
||||
// Track only 3 doses
|
||||
const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000);
|
||||
const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000);
|
||||
const jan8 = Math.floor(new Date("2025-01-08T08:00:00.000Z").getTime() / 1000);
|
||||
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan2 * 1000}`,
|
||||
takenAt: jan2,
|
||||
});
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan5 * 1000}`,
|
||||
takenAt: jan5,
|
||||
});
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan8 * 1000}`,
|
||||
takenAt: jan8,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(3); // Only 3 tracked doses
|
||||
});
|
||||
|
||||
it("should return 0 usage when no doses tracked", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Untracked Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// No dose tracking records
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(0);
|
||||
expect(data[0].enough).toBe(true);
|
||||
});
|
||||
|
||||
it("should only count doses within date range", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Range Test",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// Dose before range (Dec 31)
|
||||
const dec31 = Math.floor(new Date("2024-12-31T08:00:00.000Z").getTime() / 1000);
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${dec31 * 1000}`,
|
||||
takenAt: dec31,
|
||||
});
|
||||
|
||||
// Dose in range (Jan 5)
|
||||
const jan5 = Math.floor(new Date("2025-01-05T08:00:00.000Z").getTime() / 1000);
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan5 * 1000}`,
|
||||
takenAt: jan5,
|
||||
});
|
||||
|
||||
// Dose after range (Jan 15)
|
||||
const jan15 = Math.floor(new Date("2025-01-15T08:00:00.000Z").getTime() / 1000);
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan15 * 1000}`,
|
||||
takenAt: jan15,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(1); // Only Jan 5 is in range
|
||||
});
|
||||
|
||||
it("should handle multi-pill doses correctly", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Multi-Pill",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 2, every: 1, start: start.toISOString() }], // 2 pills per dose
|
||||
});
|
||||
|
||||
const jan2 = Math.floor(new Date("2025-01-02T08:00:00.000Z").getTime() / 1000);
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${jan2 * 1000}`, // Blister index 0 has usage=2
|
||||
takenAt: jan2,
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[0].plannerUsage).toBe(2); // 1 dose × 2 pills
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode Comparison Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Automatic vs Manual mode comparison", () => {
|
||||
it("should show different results for same medication", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
const medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Comparison Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
// Track only 5 of the 10 scheduled doses
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
const date = new Date(`2025-01-0${day}T08:00:00.000Z`);
|
||||
const ts = Math.floor(date.getTime() / 1000);
|
||||
await createTestDoseTracking(ctx.client, {
|
||||
userId,
|
||||
doseId: `${medId}-0-${ts * 1000}`,
|
||||
takenAt: ts,
|
||||
});
|
||||
}
|
||||
|
||||
// Test automatic mode
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "automatic",
|
||||
});
|
||||
|
||||
const autoResponse = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(autoResponse.statusCode).toBe(200);
|
||||
const autoData = autoResponse.json();
|
||||
expect(autoData[0].plannerUsage).toBe(10); // Schedule says 10 doses
|
||||
|
||||
// Test manual mode
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "manual",
|
||||
});
|
||||
|
||||
const manualResponse = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(manualResponse.statusCode).toBe(200);
|
||||
const manualData = manualResponse.json();
|
||||
expect(manualData[0].plannerUsage).toBe(5); // Only 5 actually tracked
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple Medications Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Multiple medications", () => {
|
||||
it("should calculate usage for all medications", async () => {
|
||||
const start = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Med A",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 20,
|
||||
blisters: [{ usage: 1, every: 1, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Med B",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 20,
|
||||
blisters: [{ usage: 2, every: 2, start: start.toISOString() }],
|
||||
});
|
||||
|
||||
await setUserSettings(ctx.client, {
|
||||
userId,
|
||||
stockCalculationMode: "automatic",
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-10T23:59:59.999Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
||||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
||||
|
||||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for translations module
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||
|
||||
describe("Translations Module", () => {
|
||||
describe("getTranslations", () => {
|
||||
it("should return English translations for 'en'", () => {
|
||||
const translations = getTranslations("en");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
|
||||
it("should return German translations for 'de'", () => {
|
||||
const translations = getTranslations("de");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("Tabletten");
|
||||
});
|
||||
|
||||
it("should fallback to English for unknown language", () => {
|
||||
const translations = getTranslations("fr" as Language);
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
|
||||
it("should have all required keys in English", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBeDefined();
|
||||
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.title).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBeDefined();
|
||||
expect(translations.intakeReminder.takenBy).toBeDefined();
|
||||
|
||||
// Push notification keys
|
||||
expect(translations.push.stockTitle).toBeDefined();
|
||||
expect(translations.push.intakeTitle).toBeDefined();
|
||||
expect(translations.push.pillsLeft).toBeDefined();
|
||||
expect(translations.push.emptySection).toBeDefined();
|
||||
expect(translations.push.lowSection).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have all required keys in German", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBe("Medikament");
|
||||
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBe("Tabletten");
|
||||
expect(translations.intakeReminder.takenBy).toBe("für {name}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("t (template function)", () => {
|
||||
it("should replace single placeholder", () => {
|
||||
const result = t("Hello {name}!", { name: "World" });
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("should replace multiple placeholders", () => {
|
||||
const result = t("{count} {type} running low", { count: 3, type: "medications" });
|
||||
expect(result).toBe("3 medications running low");
|
||||
});
|
||||
|
||||
it("should replace same placeholder multiple times", () => {
|
||||
const result = t("{name} and {name} again", { name: "test" });
|
||||
expect(result).toBe("test and test again");
|
||||
});
|
||||
|
||||
it("should leave unmatched placeholders", () => {
|
||||
const result = t("Hello {name}!", {});
|
||||
expect(result).toBe("Hello {name}!");
|
||||
});
|
||||
|
||||
it("should handle numeric values", () => {
|
||||
const result = t("{count} pills left", { count: 42 });
|
||||
expect(result).toBe("42 pills left");
|
||||
});
|
||||
|
||||
it("should handle empty params object", () => {
|
||||
const result = t("No placeholders here", {});
|
||||
expect(result).toBe("No placeholders here");
|
||||
});
|
||||
|
||||
it("should work with real translation strings", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
// Stock reminder subject
|
||||
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low");
|
||||
|
||||
// Intake reminder description
|
||||
const description = t(translations.intakeReminder.description, { minutes: 30 });
|
||||
expect(description).toBe("Time to take your medication in 30 minutes:");
|
||||
|
||||
// Push notification
|
||||
const push = t(translations.push.pillsAt, { count: 2, time: "08:00" });
|
||||
expect(push).toBe("2 pills at 08:00");
|
||||
});
|
||||
|
||||
it("should work with German translations", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp");
|
||||
|
||||
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
|
||||
expect(takenBy).toBe("für Daniel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateLocale", () => {
|
||||
it("should return 'en-US' for English", () => {
|
||||
expect(getDateLocale("en")).toBe("en-US");
|
||||
});
|
||||
|
||||
it("should return 'de-DE' for German", () => {
|
||||
expect(getDateLocale("de")).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("should return 'en-US' for unknown language", () => {
|
||||
expect(getDateLocale("fr" as Language)).toBe("en-US");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Shared utility functions for scheduler services.
|
||||
* Exported separately to allow testing without side effects.
|
||||
*/
|
||||
|
||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||
|
||||
export type Blister = { usage: number; every: number; start: string };
|
||||
|
||||
// =============================================================================
|
||||
// Timezone utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Get current timezone from TZ env variable or default to UTC */
|
||||
export function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
/** Format a date in the configured timezone */
|
||||
export function formatInTimezone(date: Date, tz?: string): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
/** Get current hour in the configured timezone */
|
||||
export function getCurrentHourInTimezone(tz?: string): number {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
}
|
||||
|
||||
/** Get today's date string in the configured timezone (YYYY-MM-DD) */
|
||||
export function getTodayInTimezone(tz?: string): string {
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
/** Calculate the next scheduled time for a given reminder hour */
|
||||
export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
||||
const now = new Date();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - reminderHour;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
}
|
||||
|
||||
/** Calculate milliseconds until next check at the given reminder hour */
|
||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Blister/medication parsing utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Parse blister schedules from JSON columns */
|
||||
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse takenByJson to array of strings */
|
||||
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stock calculation utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Calculate daily usage from blisters */
|
||||
export function calculateDailyUsage(blisters: Blister[]): number {
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
}
|
||||
|
||||
/** Calculate depletion information for a medication */
|
||||
export function calculateDepletionInfo(
|
||||
med: { count: number; blisters: Blister[] },
|
||||
language: Language
|
||||
): { daysLeft: number | null; depletionDate: string | null } {
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Intake reminder utilities
|
||||
// =============================================================================
|
||||
|
||||
export type UpcomingIntake = {
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string[];
|
||||
pillWeightMg: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||
* Returns all intakes scheduled for today in user's timezone.
|
||||
*/
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string
|
||||
): UpcomingIntake[] {
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
intakes.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return intakes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming intakes that fall within the reminder window.
|
||||
* Returns intakes that should be notified about right now.
|
||||
*/
|
||||
export function getUpcomingIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
minutesBefore: number,
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
nowOverride?: number
|
||||
): UpcomingIntake[] {
|
||||
const now = nowOverride ?? Date.now();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Window to detect if "now" is the right time to send reminder
|
||||
// We check if the notify time (intake - minutesBefore) falls within current minute ±1
|
||||
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
|
||||
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence is within the reminder window, use it
|
||||
// (intake hasn't happened yet, we should remind)
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State file utilities
|
||||
// =============================================================================
|
||||
|
||||
export type ReminderState = {
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
};
|
||||
|
||||
export type IntakeReminderEntry = {
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times reminder was sent
|
||||
};
|
||||
|
||||
export type IntakeReminderState = {
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
};
|
||||
|
||||
/** Create default reminder state */
|
||||
export function createDefaultReminderState(): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create default intake reminder state */
|
||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||
return { reminders: {} };
|
||||
}
|
||||
|
||||
/** Parse reminder state from JSON string */
|
||||
export function parseReminderState(json: string): ReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
} catch {
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse intake reminder state from JSON string (backward compatible) */
|
||||
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Utility functions for server configuration.
|
||||
* Exported separately to allow testing without triggering server start.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import type { CookieSerializeOptions } from "@fastify/cookie";
|
||||
|
||||
/**
|
||||
* Parse comma-separated CORS origins string
|
||||
*/
|
||||
export function parseCorsOrigins(originsStr: string): string[] {
|
||||
return originsStr
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build base cookie options for access token
|
||||
*/
|
||||
export function buildBaseCookieOptions(
|
||||
accessTtlMinutes: number,
|
||||
isProduction: boolean
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60, // Convert minutes to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build refresh cookie options (extends base with longer TTL)
|
||||
*/
|
||||
export function buildRefreshCookieOptions(
|
||||
baseCookieOptions: CookieSerializeOptions,
|
||||
refreshTtlDays: number
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete app configuration object
|
||||
*/
|
||||
export interface AppConfigOptions {
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
accessTtlMinutes: number;
|
||||
refreshTtlDays: number;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
refreshCookieOptions: CookieSerializeOptions;
|
||||
}
|
||||
|
||||
export function buildAppConfig(options: AppConfigOptions): AppConfig {
|
||||
const cookieOptions = buildBaseCookieOptions(
|
||||
options.accessTtlMinutes,
|
||||
options.isProduction
|
||||
);
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(
|
||||
cookieOptions,
|
||||
options.refreshTtlDays
|
||||
);
|
||||
|
||||
return {
|
||||
accessSecret: options.jwtSecret || "",
|
||||
refreshSecret: options.refreshSecret || "",
|
||||
accessTtl: options.accessTtlMinutes,
|
||||
refreshTtl: options.refreshTtlDays,
|
||||
cookieOptions,
|
||||
refreshCookieOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images directory exists
|
||||
*/
|
||||
export function ensureImagesDirectory(cwd?: string): string {
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
return imagesDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT configuration based on auth enabled status
|
||||
*/
|
||||
export interface JwtConfig {
|
||||
secret: string;
|
||||
cookie: {
|
||||
cookieName: string;
|
||||
signed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function getJwtConfig(authEnabled: boolean, jwtSecret?: string): JwtConfig {
|
||||
const effectiveSecret =
|
||||
authEnabled && jwtSecret
|
||||
? jwtSecret
|
||||
: "auth-disabled-no-secret-needed";
|
||||
|
||||
return {
|
||||
secret: effectiveSecret,
|
||||
cookie: {
|
||||
cookieName: "access_token",
|
||||
signed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
// Run tests sequentially to avoid DB conflicts
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
// Timeout for longer integration tests
|
||||
testTimeout: 10000,
|
||||
},
|
||||
});
|
||||
Generated
+10
-10
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1713,9 +1713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -1735,12 +1735,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,7 +15,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+489
-123
@@ -32,8 +32,8 @@ type PlannerRow = {
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
stripSize: number;
|
||||
stripsNeeded: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
@@ -289,6 +289,10 @@ function AppContent() {
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
@@ -337,6 +341,10 @@ function AppContent() {
|
||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
// Clear missed doses confirmation dialog
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
// Tag input state for "Taken By" field
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
// Share dialog state
|
||||
@@ -347,6 +355,12 @@ function AppContent() {
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
// Export/Import state
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImportData, setPendingImportData] = useState<any>(null);
|
||||
// Collapsed days state (manually collapsed days are persisted)
|
||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||
@@ -358,15 +372,12 @@ function AppContent() {
|
||||
setScheduleDays(storedDays ? Number(storedDays) : 30);
|
||||
|
||||
// Load manually collapsed/expanded days from localStorage
|
||||
const storedCollapsed = localStorage.getItem(userStorageKey(user.id, "collapsedDays"));
|
||||
const storedExpanded = localStorage.getItem(userStorageKey(user.id, "expandedDays"));
|
||||
try {
|
||||
setManuallyCollapsedDays(storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set());
|
||||
setManuallyExpandedDays(storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set());
|
||||
} catch {
|
||||
setManuallyCollapsedDays(new Set());
|
||||
setManuallyExpandedDays(new Set());
|
||||
}
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
userStorageKey(user.id, "collapsedDays"),
|
||||
userStorageKey(user.id, "expandedDays")
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
@@ -377,7 +388,17 @@ function AppContent() {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
||||
const taken = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses) {
|
||||
if (d.dismissed) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setDismissedDoses(dismissed);
|
||||
}
|
||||
// Don't reset on error - keep current state
|
||||
} catch {
|
||||
@@ -460,6 +481,35 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss missed doses without deducting from stock
|
||||
async function dismissMissedDoses(doseIds: string[]) {
|
||||
if (doseIds.length === 0) return;
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/doses/dismiss", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseIds }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state - move these from neither set to dismissed set
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of doseIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setShowClearMissedConfirm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error - dialog stays open
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
@@ -573,6 +623,20 @@ function AppContent() {
|
||||
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
||||
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
||||
|
||||
// Calculate missed past dose IDs for the "Clear missed" feature
|
||||
const missedPastDoseIds = useMemo(() => {
|
||||
const totalPastDoses = pastDays.flatMap(d =>
|
||||
d.meds.flatMap(m =>
|
||||
m.doses.flatMap(dose =>
|
||||
(dose.takenBy || []).length > 0
|
||||
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
|
||||
: [dose.id]
|
||||
)
|
||||
)
|
||||
);
|
||||
return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id));
|
||||
}, [pastDays, takenDoses, dismissedDoses]);
|
||||
|
||||
// Load medications and settings when user changes (or on initial mount)
|
||||
useEffect(() => {
|
||||
loadMeds();
|
||||
@@ -630,6 +694,10 @@ function AppContent() {
|
||||
notificationEmail: settings.notificationEmail,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -773,6 +841,110 @@ function AppContent() {
|
||||
setSendingReminderEmail(false);
|
||||
}
|
||||
|
||||
// Export data to JSON file
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch('/api/export?includeSensitive=true', {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const data = await res.json();
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
a.href = url;
|
||||
a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export error:", err);
|
||||
}
|
||||
setExporting(false);
|
||||
}
|
||||
|
||||
// Handle file selection for import
|
||||
function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (!data.version || !data.exportedAt) {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
return;
|
||||
}
|
||||
setPendingImportData(data);
|
||||
setShowImportConfirm(true);
|
||||
} catch {
|
||||
alert(t('exportImport.invalidFile'));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
// Confirm and execute import
|
||||
async function handleImportConfirm() {
|
||||
if (!pendingImportData) return;
|
||||
setImporting(true);
|
||||
setShowImportConfirm(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(pendingImportData),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(t('exportImport.importError') + ": " + (err.error || "Unknown error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', {
|
||||
medications: result.imported.medications,
|
||||
doses: result.imported.doseHistory,
|
||||
shares: result.imported.shareLinks,
|
||||
}));
|
||||
|
||||
// Reload all data
|
||||
loadMeds();
|
||||
loadSettings();
|
||||
loadTakenDoses();
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
alert(t('exportImport.importError'));
|
||||
}
|
||||
|
||||
setPendingImportData(null);
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
// Helper function to load taken doses (extracted from useEffect)
|
||||
async function loadTakenDoses() {
|
||||
try {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMed(id: number) {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
@@ -1352,31 +1524,46 @@ function AppContent() {
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const missedCount = missedPastDoseIds.length;
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||
</span>
|
||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||
{missedPastDoses > 0 ? (
|
||||
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}>⚠️ {missedPastDoses}</span>
|
||||
) : totalPastDoses.length > 0 ? (
|
||||
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
||||
) : null}
|
||||
<div className="past-days-header">
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||
</span>
|
||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||
{missedCount > 0 ? (
|
||||
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}>⚠️ {missedCount}</span>
|
||||
) : totalPastDoses.length > 0 ? (
|
||||
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
||||
) : null}
|
||||
</div>
|
||||
{missedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-missed-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowClearMissedConfirm(true);
|
||||
}}
|
||||
title={t('dashboard.schedules.clearMissed')}
|
||||
>
|
||||
{t('dashboard.schedules.clearMissed')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
|
||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
@@ -1564,6 +1751,35 @@ function AppContent() {
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{/* Clear Missed Doses Confirmation Modal */}
|
||||
{showClearMissedConfirm && (
|
||||
<div className="modal-overlay" onClick={() => setShowClearMissedConfirm(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||
<button className="modal-close" onClick={() => setShowClearMissedConfirm(false)}>×</button>
|
||||
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('dashboard.schedules.clearMissedConfirmTitle')}</h2>
|
||||
<p style={{marginBottom: "24px"}}>{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}</p>
|
||||
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => setShowClearMissedConfirm(false)}
|
||||
disabled={clearingMissed}
|
||||
>
|
||||
{t('dashboard.schedules.clearMissedCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => dismissMissedDoses(missedPastDoseIds)}
|
||||
disabled={clearingMissed}
|
||||
>
|
||||
{clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
} />
|
||||
|
||||
@@ -1855,7 +2071,7 @@ function AppContent() {
|
||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
||||
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
||||
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
|
||||
<span data-label={t('planner.table.blisters')}>{row.stripsNeeded} × {row.stripSize}</span>
|
||||
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
|
||||
<span data-label={t('planner.table.available')}>
|
||||
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
|
||||
</span>
|
||||
@@ -1930,7 +2146,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailStockReminders}
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -1941,7 +2157,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrStockReminders}
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -1955,7 +2171,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailIntakeReminders}
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -1966,7 +2182,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrIntakeReminders}
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -1978,16 +2194,94 @@ function AppContent() {
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
|
||||
)}
|
||||
|
||||
{/* Skip reminders for taken doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border-color)"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.skipTakenDoses')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.skipRemindersForTakenDoses}
|
||||
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Repeat reminders for missed doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "12px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.repeatReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatRemindersEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Reminder interval (only shown when repeat is enabled) */}
|
||||
{settings.repeatRemindersEnabled && (
|
||||
<>
|
||||
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.reminderInterval')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="480"
|
||||
step="5"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.maxNaggingReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.email')}</h3>
|
||||
<label className="toggle-switch small">
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
|
||||
checked={settings.smtpHost ? settings.emailEnabled : false}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.shoutrrrEnabled) {
|
||||
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, emailEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
disabled={!settings.smtpHost}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
@@ -2031,7 +2325,14 @@ function AppContent() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrEnabled: e.target.checked })}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.emailEnabled) {
|
||||
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, shoutrrrEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
@@ -2043,12 +2344,12 @@ function AppContent() {
|
||||
<span className="field-label">{t('settings.push.url')}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="url"
|
||||
type="text"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
placeholder={t('settings.push.urlPlaceholder')}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.push.supports')}>ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2209,6 +2510,48 @@ function AppContent() {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Export/Import Section */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>
|
||||
{t('exportImport.title')}
|
||||
<span className="info-tooltip" data-tooltip={t('exportImport.description')}>ⓘ</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<div className="export-import-grid">
|
||||
{/* Export */}
|
||||
<div className="export-import-card">
|
||||
<h3>{t('exportImport.exportTitle')}</h3>
|
||||
<p className="export-import-desc">{t('exportImport.exportDesc')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="export-import-card">
|
||||
<h3>{t('exportImport.importTitle')}</h3>
|
||||
<p className="export-import-desc">{t('exportImport.importDesc')}</p>
|
||||
<label className="export-import-file-btn">
|
||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFileSelect}
|
||||
disabled={importing}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
||||
@@ -2216,6 +2559,39 @@ function AppContent() {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<div className="modal-overlay" onClick={() => setShowImportConfirm(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||
<button className="modal-close" onClick={() => { setShowImportConfirm(false); setPendingImportData(null); }}>×</button>
|
||||
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.confirmImport')}</h2>
|
||||
<p style={{marginBottom: "12px"}}>{t('exportImport.confirmImportMessage')}</p>
|
||||
<p className="warning-text" style={{marginBottom: "24px"}}>
|
||||
⚠️ {t('exportImport.confirmImportWarning')}
|
||||
</p>
|
||||
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
setShowImportConfirm(false);
|
||||
setPendingImportData(null);
|
||||
}}
|
||||
>
|
||||
{t('exportImport.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={handleImportConfirm}
|
||||
>
|
||||
{t('exportImport.confirmButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
} />
|
||||
|
||||
@@ -2895,22 +3271,36 @@ function toIsoString(value: string) {
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function loadCollapsedDaysFromStorage(collapsedKey: string, expandedKey: string): { collapsed: Set<string>; expanded: Set<string> } {
|
||||
const storedCollapsed = localStorage.getItem(collapsedKey);
|
||||
const storedExpanded = localStorage.getItem(expandedKey);
|
||||
try {
|
||||
return {
|
||||
collapsed: storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set(),
|
||||
expanded: storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set()
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
collapsed: new Set(),
|
||||
expanded: new Set()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toDateValue(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const fallback = Number.isNaN(d.getTime()) ? new Date() : d;
|
||||
return `${fallback.getFullYear()}-${pad2(fallback.getMonth() + 1)}-${pad2(fallback.getDate())}`;
|
||||
}
|
||||
|
||||
function toTimeValue(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
const now = new Date();
|
||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const fallback = Number.isNaN(d.getTime()) ? new Date() : d;
|
||||
return `${pad2(fallback.getHours())}:${pad2(fallback.getMinutes())}`;
|
||||
}
|
||||
|
||||
function combineDateAndTime(dateStr: string, timeStr: string): string {
|
||||
@@ -2920,23 +3310,9 @@ function combineDateAndTime(dateStr: string, timeStr: string): string {
|
||||
|
||||
function toInputValue(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
// Return current local time in datetime-local format
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
// Convert to local time format for datetime-local input
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
const d = Number.isNaN(date.getTime()) ? new Date() : date;
|
||||
// Use existing helper functions to avoid duplication
|
||||
return `${toDateValue(d)}T${toTimeValue(d)}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string, locale: string) {
|
||||
@@ -3449,15 +3825,12 @@ function SharedSchedule() {
|
||||
// Load collapsed/expanded state from localStorage
|
||||
useEffect(() => {
|
||||
if (token && typeof window !== "undefined") {
|
||||
const storedCollapsed = localStorage.getItem(`share_${token}_collapsedDays`);
|
||||
const storedExpanded = localStorage.getItem(`share_${token}_expandedDays`);
|
||||
try {
|
||||
setManuallyCollapsedDays(storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set());
|
||||
setManuallyExpandedDays(storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set());
|
||||
} catch {
|
||||
setManuallyCollapsedDays(new Set());
|
||||
setManuallyExpandedDays(new Set());
|
||||
}
|
||||
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
|
||||
`share_${token}_collapsedDays`,
|
||||
`share_${token}_expandedDays`
|
||||
);
|
||||
setManuallyCollapsedDays(collapsed);
|
||||
setManuallyExpandedDays(expanded);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -3625,31 +3998,32 @@ function SharedSchedule() {
|
||||
fetchData();
|
||||
}, [token, t]);
|
||||
|
||||
// Build schedule from medications
|
||||
// Build schedule from medications - matches buildSchedulePreview logic exactly
|
||||
const schedule = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
// Use same logic as buildSchedulePreview in main app
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
|
||||
const todayStartTime = todayStart.getTime();
|
||||
|
||||
// Calculate end time: today midnight + scheduleDays days
|
||||
const endDate = new Date(todayStart);
|
||||
endDate.setDate(endDate.getDate() + data.scheduleDays);
|
||||
const endTime = endDate.getTime();
|
||||
// Use 180 days horizon like main app (scheduleDays only limits futureDays display)
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 180);
|
||||
const endTime = end.getTime();
|
||||
|
||||
const doses: { id: string; when: number; medName: string; usage: number; timeStr: string; isPast: boolean; takenBy: string[] }[] = [];
|
||||
const doses: { id: string; when: number; medName: string; usage: number; timeStr: string; isPast: boolean; takenBy: string[]; dateStr: string }[] = [];
|
||||
|
||||
for (const med of data.medications) {
|
||||
med.blisters.forEach((blister, blisterIdx) => {
|
||||
const startDate = new Date(blister.start);
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
let t = startDate.getTime();
|
||||
|
||||
// Start from the very first dose (blister start)
|
||||
while (t <= endTime) {
|
||||
const d = new Date(t);
|
||||
const isPast = t < todayStartTime;
|
||||
if (Number.isNaN(startDate.getTime())) return;
|
||||
|
||||
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
||||
// This ensures identical timestamps even across DST changes
|
||||
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
|
||||
const t = d.getTime();
|
||||
const isPast = d < todayStart;
|
||||
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
|
||||
const doseId = `${med.id}-${blisterIdx}-${t}`;
|
||||
doses.push({
|
||||
@@ -3660,48 +4034,40 @@ function SharedSchedule() {
|
||||
isPast,
|
||||
takenBy: med.takenBy || [],
|
||||
timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString(i18n.language, { weekday: "short", day: "2-digit", month: "short" }),
|
||||
});
|
||||
t += intervalMs;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
doses.sort((a, b) => a.when - b.when);
|
||||
|
||||
// Group by date
|
||||
const grouped: { dateStr: string; date: Date; isPast: boolean; meds: { medName: string; total: number; lastWhen: number; doses: typeof doses }[] }[] = [];
|
||||
const byDate = new Map<string, typeof doses>();
|
||||
|
||||
for (const dose of doses) {
|
||||
const dateKey = new Date(dose.when).toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
if (!byDate.has(dateKey)) byDate.set(dateKey, []);
|
||||
byDate.get(dateKey)!.push(dose);
|
||||
// Group by date - matches groupedSchedule logic in main app
|
||||
type DoseInfo = typeof doses[number];
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
|
||||
|
||||
for (const dose of doses.slice(0, 2000)) {
|
||||
const day = days.get(dose.dateStr) ?? { dateStr: dose.dateStr, date: new Date(dose.when), isPast: dose.isPast, meds: new Map() };
|
||||
const medEntry = day.meds.get(dose.medName) ?? { medName: dose.medName, total: 0, doses: [], lastWhen: dose.when };
|
||||
medEntry.total += dose.usage;
|
||||
medEntry.doses.push(dose);
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, dose.when);
|
||||
day.meds.set(dose.medName, medEntry);
|
||||
days.set(dose.dateStr, day);
|
||||
}
|
||||
|
||||
for (const [dateStr, dayDoses] of byDate) {
|
||||
const byMed = new Map<string, typeof doses>();
|
||||
for (const dose of dayDoses) {
|
||||
if (!byMed.has(dose.medName)) byMed.set(dose.medName, []);
|
||||
byMed.get(dose.medName)!.push(dose);
|
||||
}
|
||||
const meds = Array.from(byMed.entries()).map(([medName, medDoses]) => ({
|
||||
medName,
|
||||
total: medDoses.reduce((sum, d) => sum + d.usage, 0),
|
||||
lastWhen: Math.max(...medDoses.map(d => d.when)),
|
||||
doses: medDoses,
|
||||
}));
|
||||
grouped.push({ dateStr, date: new Date(dayDoses[0].when), isPast: dayDoses[0].isPast, meds });
|
||||
}
|
||||
|
||||
return grouped;
|
||||
|
||||
return Array.from(days.values()).map((d) => ({
|
||||
dateStr: d.dateStr,
|
||||
date: d.date,
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values())
|
||||
}));
|
||||
}, [data, i18n.language]);
|
||||
|
||||
// Split into past and future - matches main app logic
|
||||
const pastDays = useMemo(() => schedule.filter(d => d.isPast), [schedule]);
|
||||
const futureDays = useMemo(() => schedule.filter(d => !d.isPast), [schedule]);
|
||||
// Limit future days by scheduleDays setting (same as main app)
|
||||
const futureDays = useMemo(() => schedule.filter(d => !d.isPast).slice(0, data?.scheduleDays ?? 30), [schedule, data?.scheduleDays]);
|
||||
|
||||
// Calculate coverage for stock status colors (matches main app logic)
|
||||
// This needs to account for taken doses and calculate depletion time
|
||||
|
||||
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{t("auth.register", "Create Account")}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{/* SSO Login Button - also show on registration */}
|
||||
{authState?.oidcEnabled && (
|
||||
<div className="auth-sso">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary auth-submit sso-btn"
|
||||
onClick={() => window.location.href = "/api/auth/oidc/login"}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||
</button>
|
||||
{authState?.localAuthEnabled && (
|
||||
<div className="auth-divider">
|
||||
<span>{t("auth.or", "or")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Registration Form - only show if local auth is enabled */}
|
||||
{authState?.localAuthEnabled && (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<div className="auth-links">
|
||||
|
||||
@@ -38,7 +38,14 @@
|
||||
"pastDaysCount": "{{count}} Tag",
|
||||
"pastDaysCount_other": "{{count}} Tage",
|
||||
"missedDoses": "{{count}} verpasste Dosis",
|
||||
"missedDoses_other": "{{count}} verpasste Dosen"
|
||||
"missedDoses_other": "{{count}} verpasste Dosen",
|
||||
"clearMissed": "Verpasste löschen",
|
||||
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
|
||||
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||
"clearMissedConfirm": "Ja, löschen",
|
||||
"clearMissedCancel": "Abbrechen",
|
||||
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatische Erinnerungen aktiv",
|
||||
@@ -157,7 +164,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Bestands-Erinnerungen",
|
||||
"intakeReminders": "Einnahme-Erinnerungen",
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
|
||||
"reminderInterval": "Erinnerungsintervall (Minuten)",
|
||||
"reminderIntervalTooltip": "Wie oft wiederholte Erinnerungen für verpasste Dosen gesendet werden sollen",
|
||||
"maxNaggingReminders": "Max. Erinnerungen pro Dosis",
|
||||
"maxNaggingRemindersTooltip": "Wiederholungserinnerungen nach dieser Anzahl Versuchen stoppen (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Empfänger",
|
||||
@@ -165,7 +180,9 @@
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
|
||||
"urlPlaceholder": "ntfy://topic oder pushover://:token@userkey/",
|
||||
"supports": "Unterstützt ntfy, Pushover, Gotify, Discord, Telegram, Slack & mehr",
|
||||
"docsLink": "Siehe shoutrrr.dev für alle Services"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Erinnerungsplan",
|
||||
@@ -340,5 +357,31 @@
|
||||
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
||||
"expiredOn": "Abgelaufen am: {{date}}"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Daten Export / Import",
|
||||
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||
"exportTitle": "Export",
|
||||
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
||||
"importTitle": "Import",
|
||||
"importDesc": "Stelle Daten aus einer Sicherung wieder her. Dies ersetzt alle bestehenden Daten.",
|
||||
"export": "Daten exportieren",
|
||||
"exporting": "Exportiere...",
|
||||
"import": "Datei auswählen",
|
||||
"importing": "Importiere...",
|
||||
"selectFile": "Datei auswählen",
|
||||
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
|
||||
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
|
||||
"confirmImport": "Alle Daten ersetzen?",
|
||||
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
||||
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||
"confirmButton": "Ja, alles ersetzen",
|
||||
"cancelButton": "Abbrechen",
|
||||
"exportSuccess": "Daten erfolgreich exportiert",
|
||||
"importSuccess": "Daten erfolgreich importiert",
|
||||
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
|
||||
"importError": "Daten konnten nicht importiert werden",
|
||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
|
||||
"downloadFilename": "medassist-export"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,14 @@
|
||||
"pastDaysCount": "{{count}} day",
|
||||
"pastDaysCount_other": "{{count}} days",
|
||||
"missedDoses": "{{count}} missed dose",
|
||||
"missedDoses_other": "{{count}} missed doses"
|
||||
"missedDoses_other": "{{count}} missed doses",
|
||||
"clearMissed": "Clear missed",
|
||||
"clearMissedConfirmTitle": "Clear Missed Doses?",
|
||||
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
|
||||
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
|
||||
"clearMissedConfirm": "Yes, Clear",
|
||||
"clearMissedCancel": "Cancel",
|
||||
"clearMissedSuccess": "Cleared {{count}} missed doses"
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatic reminders active",
|
||||
@@ -159,7 +166,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Stock Reminders",
|
||||
"intakeReminders": "Intake Reminders",
|
||||
"enableHint": "Enable at least one channel below to receive notifications."
|
||||
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||
"skipTakenDoses": "Skip reminders for taken doses",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||
"repeatReminders": "Repeat reminders for missed doses",
|
||||
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
|
||||
"reminderInterval": "Reminder interval (minutes)",
|
||||
"reminderIntervalTooltip": "How often to send repeated reminders for missed doses",
|
||||
"maxNaggingReminders": "Max reminders per dose",
|
||||
"maxNaggingRemindersTooltip": "Stop sending repeat reminders after this many attempts (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Recipient",
|
||||
@@ -167,7 +182,9 @@
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
"supports": "Supports ntfy, Discord, Telegram, Slack"
|
||||
"urlPlaceholder": "ntfy://topic or pushover://:token@userkey/",
|
||||
"supports": "Supports ntfy, Pushover, Gotify, Discord, Telegram, Slack & more",
|
||||
"docsLink": "See shoutrrr.dev for all services"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Reminder Schedule",
|
||||
@@ -342,5 +359,31 @@
|
||||
"contact": "Please contact {{username}} to request a new link.",
|
||||
"expiredOn": "Expired on: {{date}}"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Data Export / Import",
|
||||
"description": "Backup your data or transfer it to another device.",
|
||||
"exportTitle": "Export",
|
||||
"exportDesc": "Download all your data as a JSON file.",
|
||||
"importTitle": "Import",
|
||||
"importDesc": "Restore data from a backup file. This will replace all existing data.",
|
||||
"export": "Export Data",
|
||||
"exporting": "Exporting...",
|
||||
"import": "Select File",
|
||||
"importing": "Importing...",
|
||||
"selectFile": "Select File",
|
||||
"includeSensitive": "Include sensitive data (notification URLs)",
|
||||
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
|
||||
"confirmImport": "Replace All Data?",
|
||||
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
||||
"confirmImportWarning": "This action cannot be undone!",
|
||||
"confirmButton": "Yes, Replace All",
|
||||
"cancelButton": "Cancel",
|
||||
"exportSuccess": "Data exported successfully",
|
||||
"importSuccess": "Data imported successfully",
|
||||
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
|
||||
"importError": "Failed to import data",
|
||||
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
|
||||
"downloadFilename": "medassist-export"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +690,35 @@ textarea.auto-resize {
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
}
|
||||
|
||||
/* Past days header container - toggle + clear button */
|
||||
.past-days-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.past-days-header .past-days-toggle {
|
||||
flex: 1;
|
||||
}
|
||||
.clear-missed-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--warning);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--btn-radius);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
}
|
||||
.clear-missed-btn:hover {
|
||||
background: rgba(234, 179, 8, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.clear-missed-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Past day blocks styling */
|
||||
.day-block.past {
|
||||
opacity: 0.7;
|
||||
@@ -3977,3 +4006,79 @@ h3 .reminder-icon.info-tooltip {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Export/Import Section */
|
||||
.card:has(.export-import-grid) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card:has(.export-import-grid) .card-head {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.export-import-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.export-import-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.export-import-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.export-import-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.export-import-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.export-import-card button,
|
||||
.export-import-file-btn {
|
||||
margin-top: auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.export-import-file-btn {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: var(--btn-radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-import-file-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.export-import-file-btn input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+2
-16
@@ -18,9 +18,6 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Secondary remote (self-hosted git)
|
||||
SECONDARY_REMOTE="git@git.danielvolz.org:daniel/medassist-ng.git"
|
||||
|
||||
# Get script directory and project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
@@ -95,25 +92,14 @@ if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
echo -e "${BLUE}Creating tag v${NEW_VERSION}...${NC}"
|
||||
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||
|
||||
# Push
|
||||
echo -e "${BLUE}Pushing to origin (GitHub)...${NC}"
|
||||
git push origin main
|
||||
git push origin "v${NEW_VERSION}"
|
||||
|
||||
# Ask about secondary remote
|
||||
echo ""
|
||||
read -p "Also push to git.danielvolz.org? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${BLUE}Pushing to git.danielvolz.org...${NC}"
|
||||
git push "$SECONDARY_REMOTE" main
|
||||
git push "$SECONDARY_REMOTE" "v${NEW_VERSION}"
|
||||
echo -e "${GREEN}✓ Pushed to git.danielvolz.org${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
||||
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
||||
|
||||
Reference in New Issue
Block a user