Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd2661498 | |||
| 89edd74de3 | |||
| bafde8f689 | |||
| 30d72f625d | |||
| cea1a8b119 | |||
| 3aa2b608b0 | |||
| e24a540f17 | |||
| fae96c9fdd | |||
| 11b55fc638 | |||
| b68c0b0737 | |||
| 8fc9fac8f5 | |||
| 1920b47924 | |||
| 857b1462e3 | |||
| 813aa0faf9 | |||
| 75bb7abebc | |||
| bb46b26ec6 | |||
| 8d22669bef | |||
| fb0b3df794 | |||
| 48ae48a165 | |||
| a190667320 | |||
| cfdca04df9 | |||
| a28e3724ae | |||
| 42d00dd1c0 | |||
| 8928915947 | |||
| cfd37ca526 | |||
| 288e075786 | |||
| 13c6430dee | |||
| ec3793dd05 | |||
| d5f6ceba19 | |||
| 6f0553d7dd | |||
| 82b2be48cd | |||
| 269a549563 | |||
| 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 |
@@ -78,4 +78,44 @@ REMINDER_DAYS_BEFORE=7
|
|||||||
# Admin settings (not editable in UI)
|
# Admin settings (not editable in UI)
|
||||||
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
|
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
|
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,13 @@
|
|||||||
# MedAssist-ng - AI Coding Instructions
|
# 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.
|
||||||
|
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
|
||||||
|
- **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
|
## Architecture Overview
|
||||||
|
|
||||||
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
||||||
@@ -33,8 +41,274 @@ docker compose up -d
|
|||||||
|
|
||||||
# Database migrations
|
# Database migrations
|
||||||
cd backend && npm run migrate
|
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**!
|
||||||
|
|
||||||
|
### Release Workflow (MANDATORY for minor/major releases)
|
||||||
|
|
||||||
|
The `main` branch is protected - releases must go through the automated release script.
|
||||||
|
|
||||||
|
**Release Process:**
|
||||||
|
```bash
|
||||||
|
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
|
||||||
|
./scripts/release.sh [patch|minor|major]
|
||||||
|
|
||||||
|
# 2. GitHub Actions creates a DRAFT release automatically
|
||||||
|
# 3. User asks AI to write release notes:
|
||||||
|
# "Write the release notes for vX.Y.Z"
|
||||||
|
# 4. AI writes descriptive release notes following the style guide below
|
||||||
|
# 5. User publishes the draft release with the written notes
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
|
||||||
|
> Do NOT just publish the auto-generated commit list. Follow the process above.
|
||||||
|
|
||||||
|
**AI Assistant Release Notes Workflow:**
|
||||||
|
1. When user asks to write release notes for a version:
|
||||||
|
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
|
||||||
|
- Read through the changes to understand what was added/fixed
|
||||||
|
- Write release notes following the style guide below
|
||||||
|
- Present the notes to the user for copying to GitHub
|
||||||
|
|
||||||
|
### Creating Release Notes
|
||||||
|
|
||||||
|
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||||
|
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||||
|
|
||||||
|
**Keep it informative but concise.** Users want to know what changed and where to find it.
|
||||||
|
|
||||||
|
**Required structure of release notes:**
|
||||||
|
|
||||||
|
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
|
||||||
|
2. **"New Features" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
|
||||||
|
3. **"Where to Find It"**: Tell users where they can access the new feature
|
||||||
|
4. **Breaking Changes Warning** (if applicable): See below
|
||||||
|
|
||||||
|
**Style guidelines:**
|
||||||
|
- Use `### Heading` for sections (New Features, Improvements, Security, etc.)
|
||||||
|
- Use **bold** for feature names in bullet points
|
||||||
|
- Keep descriptions on the same line as the feature name
|
||||||
|
- Minimal emoji usage (sparingly, not on every line)
|
||||||
|
- Always end with "Where to Find It" section
|
||||||
|
|
||||||
|
**DO NOT include:**
|
||||||
|
- ❌ Technical implementation details (new columns, endpoints, database changes)
|
||||||
|
- ❌ Number of tests added
|
||||||
|
- ❌ Internal API changes (unless breaking)
|
||||||
|
- ❌ Excessive emoji on every bullet point
|
||||||
|
|
||||||
|
**Example of good release notes:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
|
||||||
|
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
|
||||||
|
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
|
||||||
|
|
||||||
|
### Mobile Improvements
|
||||||
|
|
||||||
|
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
|
||||||
|
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
|
||||||
|
|
||||||
|
### Where to Find It
|
||||||
|
|
||||||
|
The refill button appears in the medication detail modal and in the edit form for each medication.
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
## Key Patterns
|
||||||
|
|
||||||
### Backend Routes (`backend/src/routes/`)
|
### Backend Routes (`backend/src/routes/`)
|
||||||
@@ -142,9 +416,25 @@ Each blister defines a recurring intake:
|
|||||||
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
||||||
| Email Notifications | Enable, email address, stock/intake toggles |
|
| Email Notifications | Enable, email address, stock/intake toggles |
|
||||||
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), 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) |
|
| 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`)
|
## Database Schema (`backend/src/db/schema.ts`)
|
||||||
|
|
||||||
| Table | Description |
|
| Table | Description |
|
||||||
@@ -206,14 +496,75 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|||||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
- **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
|
### ⚠️ MANDATORY for EVERY New Feature
|
||||||
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
|
**Before implementing ANY feature that touches user data or settings:**
|
||||||
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
|
|
||||||
|
1. **Check if new DB columns are needed** - Does the feature require storing new data?
|
||||||
|
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
|
||||||
|
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
|
||||||
|
|
||||||
|
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
|
||||||
|
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
|
||||||
|
|
||||||
|
### Schema Management with Drizzle Kit
|
||||||
|
|
||||||
|
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
|
||||||
|
|
||||||
|
- **`backend/src/db/schema.ts`** - Drizzle ORM schema definitions (TypeScript)
|
||||||
|
- **`backend/drizzle/`** - Generated SQL migrations (auto-generated from schema.ts)
|
||||||
|
|
||||||
|
**DO NOT manually edit migration files!** They are generated from schema.ts.
|
||||||
|
|
||||||
|
### Adding New Columns
|
||||||
|
|
||||||
|
1. **Add to schema.ts** with DEFAULT value:
|
||||||
|
```typescript
|
||||||
|
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate migration**:
|
||||||
|
```bash
|
||||||
|
cd backend && npx drizzle-kit generate --name add_column_name
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add backward-compatible ALTER migration** in `client.ts` `runAlterMigrations()`:
|
||||||
|
```typescript
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **NULL-safe reading** in routes:
|
||||||
|
```typescript
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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. **Generate migration**: Run `npx drizzle-kit generate` after schema changes
|
||||||
|
4. **Add ALTER migration**: For backward compatibility with existing DBs
|
||||||
|
|
||||||
|
### What is NOT Allowed
|
||||||
|
|
||||||
|
- ❌ Deleting or renaming columns (breaks old DBs)
|
||||||
|
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
|
||||||
|
- ❌ Reading columns without fallback in code
|
||||||
|
- ❌ Manually editing migration SQL files
|
||||||
|
- ❌ 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
|
## File Locations
|
||||||
|
|
||||||
@@ -221,6 +572,8 @@ When adding new database columns:
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| Backend entry | `backend/src/index.ts` |
|
| Backend entry | `backend/src/index.ts` |
|
||||||
| Database schema | `backend/src/db/schema.ts` |
|
| Database schema | `backend/src/db/schema.ts` |
|
||||||
|
| Drizzle migrations | `backend/drizzle/*.sql` |
|
||||||
|
| Drizzle config | `backend/drizzle.config.ts` |
|
||||||
| Backend routes | `backend/src/routes/*.ts` |
|
| Backend routes | `backend/src/routes/*.ts` |
|
||||||
| Backend services | `backend/src/services/*.ts` |
|
| Backend services | `backend/src/services/*.ts` |
|
||||||
| Frontend app | `frontend/src/App.tsx` |
|
| Frontend app | `frontend/src/App.tsx` |
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
@@ -3,19 +3,12 @@ name: Build and Push Docker Images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.github/workflows/docker-build.yml'
|
||||||
tags: ['v*']
|
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:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -23,11 +16,59 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
|
||||||
|
# Default minimal permissions
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-push:
|
||||||
|
needs: [backend-test, frontend-build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -77,6 +118,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Create GitHub Release (only on tag push)
|
# Create GitHub Release (only on tag push)
|
||||||
|
|||||||
@@ -16,27 +16,63 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Get version info
|
||||||
id: changelog
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
VERSION=${CURRENT_TAG#v}
|
||||||
|
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Get previous tag
|
# Get previous tag
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||||
|
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||||
if [ -z "$PREV_TAG" ]; then
|
PREV_TAG=""
|
||||||
# First release - get all commits
|
|
||||||
CHANGES=$(git log --pretty=format:"- %s" HEAD)
|
|
||||||
else
|
|
||||||
# Get commits since last tag
|
|
||||||
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
|
|
||||||
fi
|
fi
|
||||||
|
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||||
# Write to file for multiline support
|
|
||||||
echo "$CHANGES" > changelog.txt
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Generate release template
|
||||||
|
run: |
|
||||||
|
cat > release_notes.md << 'EOF'
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Write 1-2 sentences describing the main changes in this release.
|
||||||
|
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
<!-- List new features with **bold** names and descriptions -->
|
||||||
|
- **Feature Name**: Description of the feature
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
<!-- List improvements and fixes -->
|
||||||
|
- **Improvement**: Description
|
||||||
|
|
||||||
|
### Where to Find It
|
||||||
|
|
||||||
|
<!-- Tell users where they can access new features -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
|
||||||
|
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Create Draft Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: release_notes.md
|
||||||
generate_release_notes: true
|
draft: true
|
||||||
|
generate_release_notes: false
|
||||||
|
name: "Release ${{ steps.version.outputs.tag }}"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
||||||
@@ -1,33 +1,73 @@
|
|||||||
# Node
|
# ===================
|
||||||
|
# Dependencies
|
||||||
|
# ===================
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Build outputs
|
||||||
|
# ===================
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.tmp/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Test & Coverage
|
||||||
|
# ===================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Environment
|
||||||
|
# ===================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Database & Data
|
||||||
|
# ===================
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Logs
|
||||||
|
# ===================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# ===================
|
||||||
dist/
|
# OS files
|
||||||
build/
|
# ===================
|
||||||
coverage/
|
|
||||||
.tmp/
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# SQLite
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
*.db-journal
|
|
||||||
backend/data/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Editor
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# IDE / Editor
|
||||||
|
# ===================
|
||||||
|
.idea/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Keep shared VS Code settings
|
||||||
|
# .vscode/ is NOT ignored - settings.json is useful for the team
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Misc
|
||||||
|
# ===================
|
||||||
|
*.local
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"vitest.root": "backend",
|
||||||
|
"vitest.enable": true,
|
||||||
|
"vitest.commandLine": "npm test --"
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
@@ -38,11 +39,91 @@
|
|||||||
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<a id="screenshots"></a>
|
||||||
|
<details>
|
||||||
|
<summary><strong>Screenshots</strong></summary>
|
||||||
|
<blockquote>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Dashboard</summary>
|
||||||
|
|
||||||
|
Overview with stock status, reorder reminders, and upcoming schedules.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Medication Detail</summary>
|
||||||
|
|
||||||
|
View medication details, stock information, and intake schedule.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Medications & Edit Form</summary>
|
||||||
|
|
||||||
|
Manage your medications with the edit form and refill feature.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Demand Calculator (Planner)</summary>
|
||||||
|
|
||||||
|
Calculate how many pills you need for a specific date range.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Shared Schedule</summary>
|
||||||
|
|
||||||
|
Share your medication schedule with others via a public link.
|
||||||
|
|
||||||
|
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Mobile Views</summary>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Dashboard</strong><br>
|
||||||
|
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Medications</strong><br>
|
||||||
|
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<strong>Schedule</strong><br>
|
||||||
|
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</blockquote>
|
||||||
|
</details>
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, and loose pills
|
- Track exact stock: packs, blisters, and loose pills
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
|
|
||||||
|
### Medication Refill
|
||||||
|
- One-click refill with pack or loose pill options
|
||||||
|
- Complete refill history per medication
|
||||||
|
- Automatic stock updates after each refill
|
||||||
|
|
||||||
### Flexible Schedules
|
### Flexible Schedules
|
||||||
- Daily, weekly, or custom intervals per medication
|
- Daily, weekly, or custom intervals per medication
|
||||||
- Independent schedules for each medication
|
- Independent schedules for each medication
|
||||||
@@ -60,9 +141,14 @@
|
|||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||||
|
|
||||||
|
### Data Export & Import
|
||||||
|
- Export all your data (medications, dose history, settings) as JSON
|
||||||
|
- Import previously exported data with automatic ID remapping
|
||||||
|
- Choose whether to include sensitive data in exports
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Email via SMTP
|
- 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
|
- Supports both stock warnings and intake reminders
|
||||||
|
|
||||||
### Privacy & Security
|
### Privacy & Security
|
||||||
@@ -148,6 +234,54 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `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
|
# Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Copy drizzle migrations folder (required for database setup)
|
||||||
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
# Create data directory and set ownership to node user (UID 1000)
|
# Create data directory and set ownership to node user (UID 1000)
|
||||||
RUN mkdir -p /app/data && chown -R node:node /app
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || "./data/medassist.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE TABLE `dose_tracking` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`dose_id` text(255) NOT NULL,
|
||||||
|
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||||
|
`marked_by` text(100),
|
||||||
|
`dismissed` integer DEFAULT false NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `medications` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`name` text(100) NOT NULL,
|
||||||
|
`generic_name` text(100),
|
||||||
|
`taken_by_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`pack_count` integer DEFAULT 1 NOT NULL,
|
||||||
|
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
|
||||||
|
`pills_per_blister` integer DEFAULT 1 NOT NULL,
|
||||||
|
`loose_tablets` integer DEFAULT 0 NOT NULL,
|
||||||
|
`pill_weight_mg` integer,
|
||||||
|
`usage_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`every_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`start_json` text DEFAULT '[]' NOT NULL,
|
||||||
|
`image_url` text,
|
||||||
|
`expiry_date` text,
|
||||||
|
`notes` text,
|
||||||
|
`intake_reminders_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `refill_history` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`medication_id` integer NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`packs_added` integer DEFAULT 0 NOT NULL,
|
||||||
|
`loose_pills_added` integer DEFAULT 0 NOT NULL,
|
||||||
|
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `refresh_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`token_id` text(255) NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`rotated_at` integer,
|
||||||
|
`revoked` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `share_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`token` text(64) NOT NULL,
|
||||||
|
`taken_by` text(100) NOT NULL,
|
||||||
|
`schedule_days` integer DEFAULT 30 NOT NULL,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`expires_at` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_settings` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`email_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`notification_email` text,
|
||||||
|
`email_stock_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`email_intake_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`shoutrrr_url` text,
|
||||||
|
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
|
||||||
|
`reminder_days_before` integer DEFAULT 7 NOT NULL,
|
||||||
|
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
|
||||||
|
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
|
||||||
|
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
|
||||||
|
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
|
||||||
|
`low_stock_days` integer DEFAULT 30 NOT NULL,
|
||||||
|
`normal_stock_days` integer DEFAULT 90 NOT NULL,
|
||||||
|
`high_stock_days` integer DEFAULT 180 NOT NULL,
|
||||||
|
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
|
||||||
|
`language` text(10) DEFAULT 'en' NOT NULL,
|
||||||
|
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
|
||||||
|
`last_auto_email_sent` text,
|
||||||
|
`last_notification_type` text,
|
||||||
|
`last_notification_channel` text,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text(100) NOT NULL,
|
||||||
|
`password_hash` text(255),
|
||||||
|
`avatar_url` text(255),
|
||||||
|
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
|
||||||
|
`oidc_subject` text(255),
|
||||||
|
`is_active` integer DEFAULT true NOT NULL,
|
||||||
|
`last_login_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `last_stock_correction_at` integer;
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
|
||||||
|
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"last_stock_correction_at": {
|
||||||
|
"name": "last_stock_correction_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768600500759,
|
||||||
|
"tag": "0000_init",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768734577830,
|
||||||
|
"tag": "0001_add_stock_adjustment",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768736677092,
|
||||||
|
"tag": "0002_add_last_stock_correction_at",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.0.2",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"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": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^10.0.1",
|
"@fastify/cookie": "^10.0.1",
|
||||||
@@ -15,13 +18,13 @@
|
|||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
"@fastify/rate-limit": "^10.1.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/sensible": "^6.0.4",
|
"@fastify/sensible": "^6.0.4",
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@libsql/client": "^0.10.0",
|
"@libsql/client": "^0.10.0",
|
||||||
"argon2": "^0.40.0",
|
"argon2": "^0.40.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.32.2",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"openid-client": "^6.8.1",
|
"openid-client": "^6.8.1",
|
||||||
@@ -30,7 +33,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
"@types/nodemailer": "^6.4.21",
|
"@types/nodemailer": "^6.4.21",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,171 @@
|
|||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
// Get migrations folder path (relative to this file's location)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run drizzle-kit migrations on the database */
|
||||||
|
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await migrate(database, { migrationsFolder });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||||
|
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 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`,
|
||||||
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
|
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||||
|
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables that might be missing (silently fail if already exists)
|
||||||
|
const createTableMigrations = [
|
||||||
|
// Added in v1.3.x - refill history tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of createTableMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Silently ignore "table already exists" errors
|
||||||
|
if (!e.message?.includes("already exists")) {
|
||||||
|
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
|
// Use absolute path to ensure it works in Docker
|
||||||
const dataDir = resolve(process.cwd(), "data");
|
const { dataDir, dbPath, url } = getDbPaths();
|
||||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
|
||||||
const url = `file:${dbPath}`;
|
|
||||||
|
|
||||||
console.log(`[DB] Data directory: ${dataDir}`);
|
console.log(`[DB] Data directory: ${dataDir}`);
|
||||||
console.log(`[DB] Database path: ${dbPath}`);
|
console.log(`[DB] Database path: ${dbPath}`);
|
||||||
console.log(`[DB] Database URL: ${url}`);
|
console.log(`[DB] Database URL: ${url}`);
|
||||||
|
|
||||||
// Ensure data directory exists and is writable
|
// Ensure data directory exists and is writable
|
||||||
try {
|
const dirResult = ensureDataDirectory(dataDir);
|
||||||
if (!existsSync(dataDir)) {
|
if (!dirResult.success) {
|
||||||
mkdirSync(dataDir, { recursive: true });
|
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
||||||
console.log(`[DB] Created data directory: ${dataDir}`);
|
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||||
} else {
|
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||||
console.log(`[DB] Data directory exists: ${dataDir}`);
|
process.exit(1);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
// Check if directory is writable
|
|
||||||
accessSync(dataDir, constants.W_OK);
|
|
||||||
console.log(`[DB] Data directory is writable`);
|
console.log(`[DB] Data directory is writable`);
|
||||||
|
|
||||||
// Log directory stats
|
// Log directory stats
|
||||||
const stats = statSync(dataDir);
|
const stats = statSync(dataDir);
|
||||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
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`);
|
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;
|
let client: Client;
|
||||||
@@ -59,120 +182,27 @@ export const db = drizzle(client);
|
|||||||
|
|
||||||
// Auto-run migrations (self-healing database)
|
// Auto-run migrations (self-healing database)
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
// First, ensure all tables exist (for fresh databases)
|
// Run drizzle-kit generated migrations
|
||||||
const tableCreations = [
|
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
const migrateResult = await runDrizzleMigrations(db);
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
if (!migrateResult.success) {
|
||||||
username text NOT NULL UNIQUE,
|
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||||
password_hash text,
|
} else {
|
||||||
avatar_url text,
|
console.log(`[DB] Drizzle migrations completed`);
|
||||||
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) {
|
// Run ALTER TABLE migrations for backward compatibility
|
||||||
try {
|
const alterResult = await runAlterMigrations(client);
|
||||||
await client.execute(sql);
|
if (alterResult.errors.length > 0) {
|
||||||
} catch (e: any) {
|
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
|
||||||
console.error(`[DB] Table creation error:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log(`[DB] Tables verified/created`);
|
console.log(`[DB] Tables verified/created`);
|
||||||
|
|
||||||
// If auth is disabled, ensure a default user exists (ID=1)
|
// If auth is disabled, ensure a default user exists (ID=1)
|
||||||
const authEnabled = process.env.AUTH_ENABLED === "true";
|
const authEnabled = process.env.AUTH_ENABLED === "true";
|
||||||
if (!authEnabled) {
|
const created = await ensureDefaultUser(client, authEnabled);
|
||||||
try {
|
if (created) {
|
||||||
// Check if default user exists
|
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +1,82 @@
|
|||||||
import { createClient } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import fs from "fs";
|
import { resolve, dirname } from "path";
|
||||||
import path from "path";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||||
|
|
||||||
|
// Get migrations folder path (relative to this file's location)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Exported utility functions for testing
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||||
|
export function splitSQLStatements(sql: string): string[] {
|
||||||
|
return sql.split(';').filter(s => s.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute drizzle migrations on a database */
|
||||||
|
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
|
||||||
|
// Count tables as a proxy for "executed" statements
|
||||||
|
const tables = await client.execute(
|
||||||
|
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||||
|
);
|
||||||
|
const executed = Number(tables.rows[0].count) || 0;
|
||||||
|
|
||||||
|
return { success: true, executed, errors };
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(err.message);
|
||||||
|
return { success: false, executed: 0, 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";
|
const url = "file:./data/medassist-ng.db";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("Starting database setup...");
|
console.log("Starting database setup...");
|
||||||
console.log("Database URL:", url);
|
console.log("Database URL:", url);
|
||||||
|
console.log("Migrations folder:", migrationsFolder);
|
||||||
|
|
||||||
const client = createClient({ url });
|
const client = createClient({ url });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
// Create tables - fresh schema without roles, with per-user settings
|
console.log("Running drizzle migrations...");
|
||||||
const sql = `
|
await migrate(db, { migrationsFolder });
|
||||||
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);
|
|
||||||
|
|
||||||
for (const stmt of statements) {
|
|
||||||
console.log("Executing:", stmt.trim().substring(0, 50) + "...");
|
|
||||||
await client.execute(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Database setup complete!");
|
console.log("Database setup complete!");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
// Only run main() if this file is executed directly (not imported)
|
||||||
console.error("Migration failed:", err);
|
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||||
process.exit(1);
|
if (isMainModule) {
|
||||||
});
|
main().catch((err) => {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id integer NOT NULL,
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
packs_added integer NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||||
|
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", {
|
|||||||
packCount: integer("pack_count").notNull().default(1),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||||
looseTablets: integer("loose_tablets").notNull().default(0),
|
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
|
||||||
|
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||||
|
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||||
pillWeightMg: integer("pill_weight_mg"),
|
pillWeightMg: integer("pill_weight_mg"),
|
||||||
usageJson: text("usage_json").notNull().default("[]"),
|
usageJson: text("usage_json").notNull().default("[]"),
|
||||||
everyJson: text("every_json").notNull().default("[]"),
|
everyJson: text("every_json").notNull().default("[]"),
|
||||||
@@ -60,10 +62,15 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
// Reminder settings
|
// Reminder settings
|
||||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
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)
|
// Stock thresholds (days)
|
||||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||||
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
@@ -111,4 +118,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Refill History - Tracks when medication stock was refilled
|
||||||
|
// =============================================================================
|
||||||
|
export const refillHistory = sqliteTable("refill_history", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
|
||||||
|
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
packsAdded: integer("packs_added").notNull().default(0),
|
||||||
|
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||||
|
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify, { FastifyInstance } from "fastify";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import cookie, { CookieSerializeOptions } from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { env } from "./plugins/env.js";
|
import { env } from "./plugins/env.js";
|
||||||
import { migrationsReady } from "./db/client.js";
|
import { migrationsReady } from "./db/client.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
@@ -19,18 +19,118 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
|
import { exportRoutes } from "./routes/export.js";
|
||||||
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-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);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server initialization (runs on import)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
// Wait for database migrations before anything else
|
// Wait for database migrations before anything else
|
||||||
await migrationsReady;
|
await migrationsReady;
|
||||||
console.log("[DB] Migrations complete, starting server...");
|
console.log("[DB] Migrations complete, starting server...");
|
||||||
|
|
||||||
// Ensure images directory exists
|
// Ensure images directory exists
|
||||||
const imagesDir = resolve(process.cwd(), "data/images");
|
const imagesDir = ensureImagesDirectory();
|
||||||
if (!existsSync(imagesDir)) {
|
|
||||||
mkdirSync(imagesDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -38,24 +138,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)
|
// Auth token TTLs (hardcoded - no need for user configuration)
|
||||||
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
|
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
|
||||||
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
|
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
|
||||||
|
|
||||||
const baseCookieOptions: CookieSerializeOptions = {
|
const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production");
|
||||||
httpOnly: true,
|
const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays);
|
||||||
sameSite: "lax",
|
|
||||||
secure: env.NODE_ENV === "production",
|
|
||||||
path: "/",
|
|
||||||
maxAge: accessTtlMinutes * 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshCookieOptions: CookieSerializeOptions = {
|
|
||||||
...baseCookieOptions,
|
|
||||||
maxAge: refreshTtlDays * 24 * 60 * 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Config decorator - only include secrets if auth is enabled
|
// Config decorator - only include secrets if auth is enabled
|
||||||
app.decorate("config", {
|
app.decorate("config", {
|
||||||
@@ -77,18 +167,8 @@ await app.register(rateLimit, {
|
|||||||
await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" });
|
await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" });
|
||||||
|
|
||||||
// JWT plugin - only register with valid secret if auth is enabled
|
// JWT plugin - only register with valid secret if auth is enabled
|
||||||
if (env.AUTH_ENABLED && env.JWT_SECRET) {
|
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
|
||||||
await app.register(jwt, {
|
await app.register(jwt, jwtConfig);
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
@@ -105,6 +185,8 @@ await app.register(settingsRoutes);
|
|||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,33 @@ const ARGON2_OPTIONS: argon2.Options = {
|
|||||||
hashLength: 32, // 256-bit hash
|
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
|
// Validation Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -65,7 +92,9 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /auth/register - User registration
|
// 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
|
// Check auth state
|
||||||
const state = await getAuthState();
|
const state = await getAuthState();
|
||||||
|
|
||||||
@@ -123,7 +152,9 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /auth/login - User login
|
// 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();
|
const state = await getAuthState();
|
||||||
|
|
||||||
if (!state.authEnabled) {
|
if (!state.authEnabled) {
|
||||||
@@ -223,7 +254,9 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /auth/refresh - Refresh access token
|
// 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;
|
const refreshTokenCookie = request.cookies.refresh_token;
|
||||||
if (!refreshTokenCookie) {
|
if (!refreshTokenCookie) {
|
||||||
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
|
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)
|
// 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;
|
const refreshTokenCookie = request.cookies.refresh_token;
|
||||||
|
|
||||||
if (refreshTokenCookie) {
|
if (refreshTokenCookie) {
|
||||||
@@ -340,7 +375,10 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PUT /auth/me - Update current user profile
|
// 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;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
if (!authUser) {
|
if (!authUser) {
|
||||||
return reply.status(401).send({ error: "Not authenticated" });
|
return reply.status(401).send({ error: "Not authenticated" });
|
||||||
@@ -391,7 +429,10 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /auth/avatar - Upload user avatar
|
// 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;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
if (!authUser) {
|
if (!authUser) {
|
||||||
return reply.status(401).send({ error: "Not authenticated" });
|
return reply.status(401).send({ error: "Not authenticated" });
|
||||||
@@ -440,7 +481,10 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /auth/avatar - Delete user avatar
|
// 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;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
if (!authUser) {
|
if (!authUser) {
|
||||||
return reply.status(401).send({ error: "Not authenticated" });
|
return reply.status(401).send({ error: "Not authenticated" });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, shareTokens } from "../db/schema.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 { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
|||||||
doseId: z.string().min(1, "doseId is required"),
|
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
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: any, reply: any): Promise<number> {
|
||||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
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
|
// 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,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
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),
|
||||||
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
dismissed: z.boolean().default(false),
|
||||||
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
|
});
|
||||||
|
|
||||||
|
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}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||||
|
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | 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;
|
||||||
|
|
||||||
|
// Check if there's a person suffix (4th part onwards, could be multi-part name)
|
||||||
|
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
|
||||||
|
|
||||||
|
return { medicationId, blisterIndex, timestampMs, person };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dose ID from parts (with optional person suffix)
|
||||||
|
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
|
||||||
|
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
|
||||||
|
return person ? `${base}-${person}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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; includeImages?: string } }>(
|
||||||
|
"/export",
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const includeSensitive = request.query.includeSensitive === "true";
|
||||||
|
const includeImages = request.query.includeImages !== "false"; // Default to 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);
|
||||||
|
|
||||||
|
// Safely convert lastStockCorrectionAt to ISO string
|
||||||
|
let lastStockCorrectionAtIso: string | null = null;
|
||||||
|
if (med.lastStockCorrectionAt) {
|
||||||
|
try {
|
||||||
|
if (med.lastStockCorrectionAt instanceof Date && !isNaN(med.lastStockCorrectionAt.getTime())) {
|
||||||
|
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
|
||||||
|
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
|
||||||
|
const d = new Date(med.lastStockCorrectionAt);
|
||||||
|
lastStockCorrectionAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastStockCorrectionAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
|
},
|
||||||
|
pillWeightMg: med.pillWeightMg,
|
||||||
|
schedules: parseBlistersForExport(med),
|
||||||
|
expiryDate: med.expiryDate,
|
||||||
|
notes: med.notes,
|
||||||
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
|
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||||
|
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Safely convert takenAt to ISO string
|
||||||
|
let takenAtIso: string;
|
||||||
|
try {
|
||||||
|
if (dose.takenAt instanceof Date && !isNaN(dose.takenAt.getTime())) {
|
||||||
|
takenAtIso = dose.takenAt.toISOString();
|
||||||
|
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
||||||
|
const d = new Date(dose.takenAt);
|
||||||
|
takenAtIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely convert scheduled time
|
||||||
|
let scheduledTimeIso: string;
|
||||||
|
try {
|
||||||
|
const d = new Date(parsed.timestampMs);
|
||||||
|
scheduledTimeIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} catch {
|
||||||
|
scheduledTimeIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationRef: exportId,
|
||||||
|
scheduleIndex: parsed.blisterIndex,
|
||||||
|
scheduledTime: scheduledTimeIso,
|
||||||
|
takenAt: takenAtIso,
|
||||||
|
markedBy: dose.markedBy,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
|
takenByPerson: parsed.person,
|
||||||
|
};
|
||||||
|
}).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) => {
|
||||||
|
// Safely convert expiresAt to ISO string
|
||||||
|
let expiresAtIso: string | null = null;
|
||||||
|
if (share.expiresAt) {
|
||||||
|
try {
|
||||||
|
if (share.expiresAt instanceof Date && !isNaN(share.expiresAt.getTime())) {
|
||||||
|
expiresAtIso = share.expiresAt.toISOString();
|
||||||
|
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
||||||
|
const d = new Date(share.expiresAt);
|
||||||
|
expiresAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
expiresAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
scheduleDays: share.scheduleDays,
|
||||||
|
expiresAt: expiresAtIso,
|
||||||
|
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",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
// Increase body limit to 50MB to handle exports with base64 images
|
||||||
|
rawBody: true,
|
||||||
|
},
|
||||||
|
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
|
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();
|
||||||
|
// Rebuild dose ID with optional person suffix
|
||||||
|
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: new Date(dose.takenAt),
|
||||||
|
markedBy: dose.markedBy || null,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,19 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Read version from package.json at startup
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJsonPath = resolve(__dirname, "../../package.json");
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||||
|
const backendVersion = packageJson.version || "unknown";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
app.get("/health", async () => ({
|
||||||
|
status: "ok",
|
||||||
|
version: backendVersion,
|
||||||
|
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||||
|
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
|
stockAdjustment: row.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: row.pillWeightMg,
|
pillWeightMg: row.pillWeightMg,
|
||||||
blisters: parseBlisters(row),
|
blisters: parseBlisters(row),
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: row.imageUrl,
|
||||||
@@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: inserted.pillWeightMg,
|
pillWeightMg: inserted.pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
@@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: result[0].pillWeightMg,
|
pillWeightMg: result[0].pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
@@ -245,6 +251,41 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||||
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>("/medications/:id/stock-adjustment", async (req, reply) => {
|
||||||
|
const idNum = Number(req.params.id);
|
||||||
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
|
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
||||||
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(medications)
|
||||||
|
.set({
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!result.length) return reply.notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result[0].id,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
|
updatedAt: result[0].updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
@@ -339,7 +380,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
const looseTablets = row.looseTablets ?? 0;
|
const looseTablets = row.looseTablets ?? 0;
|
||||||
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||||
|
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption up to now (same logic as frontend)
|
// Calculate consumption up to now (same logic as frontend)
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
@@ -353,7 +395,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
|
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)
|
// Calculate current stock using realistic consumption order (loose first, then blisters)
|
||||||
const consumed = originalTotalPills - currentPills;
|
const consumed = originalTotalPills - currentPills;
|
||||||
@@ -373,8 +415,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationName: row.name,
|
medicationName: row.name,
|
||||||
totalPills: currentPills,
|
totalPills: currentPills,
|
||||||
plannerUsage: usageTotal,
|
plannerUsage: usageTotal,
|
||||||
stripSize: pillsPerBlister,
|
blisterSize: pillsPerBlister,
|
||||||
stripsNeeded,
|
blistersNeeded,
|
||||||
fullBlisters,
|
fullBlisters,
|
||||||
loosePills,
|
loosePills,
|
||||||
enough,
|
enough,
|
||||||
|
|||||||
@@ -7,14 +7,27 @@ import type { AuthUser } from "../types/fastify.js";
|
|||||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.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 = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
totalPills: number;
|
totalPills: number;
|
||||||
plannerUsage: number;
|
plannerUsage: number;
|
||||||
stripSize: number;
|
blisterSize: number;
|
||||||
stripsNeeded: number;
|
blistersNeeded: number;
|
||||||
stripsAvailable: number;
|
fullBlisters: number;
|
||||||
|
loosePills: number;
|
||||||
enough: boolean;
|
enough: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,11 +112,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
.map(
|
.map(
|
||||||
(row) => `
|
(row) => `
|
||||||
<tr>
|
<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.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;"><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.blistersNeeded} × ${row.blisterSize}</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.fullBlisters}${row.loosePills > 0 ? ` (+${row.loosePills})` : ""}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
<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; ${
|
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||||
row.enough
|
row.enough
|
||||||
@@ -169,7 +182,7 @@ Supply overview from ${fromDate} to ${untilDate}
|
|||||||
|
|
||||||
${summaryText}
|
${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`;
|
Sent from MedAssist-ng Medication Planner`;
|
||||||
@@ -280,7 +293,7 @@ Sent from MedAssist-ng Medication Planner`;
|
|||||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<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; ${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;">${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>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { medications, refillHistory } from "../db/schema.js";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
|
const refillSchema = z.object({
|
||||||
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||||
|
message: "Must add at least one pack or some loose pills",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function refillRoutes(app: FastifyInstance) {
|
||||||
|
// All refill routes require auth
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
// 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: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
throw new Error("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /medications/:id/refill - Add stock to medication
|
||||||
|
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
||||||
|
const parsed = refillSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
const medId = Number(req.params.id);
|
||||||
|
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [med] = await db.select().from(medications).where(
|
||||||
|
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||||
|
);
|
||||||
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
|
const { packsAdded, loosePillsAdded } = parsed.data;
|
||||||
|
|
||||||
|
// Update medication stock
|
||||||
|
const newPackCount = med.packCount + packsAdded;
|
||||||
|
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||||
|
|
||||||
|
await db.update(medications)
|
||||||
|
.set({
|
||||||
|
packCount: newPackCount,
|
||||||
|
looseTablets: newLooseTablets,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
|
|
||||||
|
// Create refill history entry
|
||||||
|
const [refill] = await db.insert(refillHistory)
|
||||||
|
.values({
|
||||||
|
medicationId: medId,
|
||||||
|
userId,
|
||||||
|
packsAdded,
|
||||||
|
loosePillsAdded,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Calculate pills added for response
|
||||||
|
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
refill: {
|
||||||
|
id: refill.id,
|
||||||
|
packsAdded,
|
||||||
|
loosePillsAdded,
|
||||||
|
totalPillsAdded,
|
||||||
|
refillDate: refill.refillDate,
|
||||||
|
},
|
||||||
|
newStock: {
|
||||||
|
packCount: newPackCount,
|
||||||
|
looseTablets: newLooseTablets,
|
||||||
|
totalPills: newPackCount * pillsPerPack + newLooseTablets,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /medications/:id/refills - Get refill history for a medication
|
||||||
|
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
||||||
|
const medId = Number(req.params.id);
|
||||||
|
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [med] = await db.select().from(medications).where(
|
||||||
|
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||||
|
);
|
||||||
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
|
// Get refill history, newest first
|
||||||
|
const refills = await db.select()
|
||||||
|
.from(refillHistory)
|
||||||
|
.where(eq(refillHistory.medicationId, medId))
|
||||||
|
.orderBy(desc(refillHistory.refillDate));
|
||||||
|
|
||||||
|
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
|
||||||
|
return refills.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
packsAdded: r.packsAdded,
|
||||||
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
|
||||||
|
refillDate: r.refillDate,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export type UserSettings = {
|
|||||||
shoutrrrIntakeReminders: boolean;
|
shoutrrrIntakeReminders: boolean;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
repeatDailyReminders: boolean;
|
repeatDailyReminders: boolean;
|
||||||
|
skipRemindersForTakenDoses: boolean;
|
||||||
|
repeatRemindersEnabled: boolean;
|
||||||
|
reminderRepeatIntervalMinutes: number;
|
||||||
|
maxNaggingReminders: number;
|
||||||
lowStockDays: number;
|
lowStockDays: number;
|
||||||
normalStockDays: number;
|
normalStockDays: number;
|
||||||
highStockDays: number;
|
highStockDays: number;
|
||||||
@@ -45,6 +49,10 @@ type SettingsBody = {
|
|||||||
emailIntakeReminders: boolean;
|
emailIntakeReminders: boolean;
|
||||||
shoutrrrStockReminders: boolean;
|
shoutrrrStockReminders: boolean;
|
||||||
shoutrrrIntakeReminders: boolean;
|
shoutrrrIntakeReminders: boolean;
|
||||||
|
skipRemindersForTakenDoses: boolean;
|
||||||
|
repeatRemindersEnabled: boolean;
|
||||||
|
reminderRepeatIntervalMinutes: number;
|
||||||
|
maxNaggingReminders: number;
|
||||||
language: string;
|
language: string;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
};
|
};
|
||||||
@@ -57,37 +65,58 @@ type TestShoutrrrBody = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default settings for new users
|
// Helper to parse boolean env vars
|
||||||
const defaultSettings = {
|
function envBool(key: string, defaultVal: boolean): boolean {
|
||||||
emailEnabled: false,
|
const val = process.env[key];
|
||||||
notificationEmail: null,
|
if (val === undefined) return defaultVal;
|
||||||
emailStockReminders: true,
|
return val === "true" || val === "1";
|
||||||
emailIntakeReminders: true,
|
}
|
||||||
shoutrrrEnabled: false,
|
|
||||||
shoutrrrUrl: null,
|
// Helper to parse integer env vars
|
||||||
shoutrrrStockReminders: true,
|
function envInt(key: string, defaultVal: number): number {
|
||||||
shoutrrrIntakeReminders: true,
|
const val = process.env[key];
|
||||||
reminderDaysBefore: 7,
|
if (val === undefined) return defaultVal;
|
||||||
repeatDailyReminders: false,
|
const parsed = parseInt(val, 10);
|
||||||
lowStockDays: 30,
|
return isNaN(parsed) ? defaultVal : parsed;
|
||||||
normalStockDays: 90,
|
}
|
||||||
highStockDays: 180,
|
|
||||||
language: "en",
|
// Default settings for new users - read from ENV with fallbacks
|
||||||
stockCalculationMode: "automatic" as const,
|
function getDefaultSettings() {
|
||||||
lastAutoEmailSent: null,
|
return {
|
||||||
lastNotificationType: null,
|
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||||
lastNotificationChannel: null,
|
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
|
// Helper to get or create user settings
|
||||||
async function getOrCreateUserSettings(userId: number) {
|
async function getOrCreateUserSettings(userId: number) {
|
||||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Create default settings for user
|
// Create default settings for user (using ENV defaults)
|
||||||
[settings] = await db.insert(userSettings).values({
|
[settings] = await db.insert(userSettings).values({
|
||||||
userId,
|
userId,
|
||||||
...defaultSettings,
|
...getDefaultSettings(),
|
||||||
}).returning();
|
}).returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
|||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
@@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
|||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
@@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||||
// SMTP settings (from .env - shared/server-configured)
|
// SMTP settings (from .env - shared/server-configured)
|
||||||
@@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||||
reminderDaysBefore: body.reminderDaysBefore,
|
reminderDaysBefore: body.reminderDaysBefore,
|
||||||
repeatDailyReminders,
|
repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: body.lowStockDays ?? 30,
|
lowStockDays: body.lowStockDays ?? 30,
|
||||||
normalStockDays: body.normalStockDays ?? 90,
|
normalStockDays: body.normalStockDays ?? 90,
|
||||||
highStockDays: body.highStockDays ?? 180,
|
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.)
|
// 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 }> {
|
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
// Validate URL to prevent SSRF
|
||||||
|
const validation = isAllowedNotificationUrl(urlStr);
|
||||||
|
if (!validation.allowed) {
|
||||||
|
return { success: false, error: validation.error };
|
||||||
|
}
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
let method = "POST";
|
let method = "POST";
|
||||||
let headers: Record<string, string> = {};
|
let headers: Record<string, string> = {};
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
return {
|
return {
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
|
|||||||
@@ -1,145 +1,59 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, gte, lte } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
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 { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||||
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
||||||
|
|
||||||
type Blister = { usage: number; every: number; start: string };
|
// Import shared utilities
|
||||||
|
import {
|
||||||
type IntakeReminderState = {
|
getTimezone,
|
||||||
sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders
|
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 REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
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");
|
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
|
||||||
|
|
||||||
function loadIntakeReminderState(): IntakeReminderState {
|
function loadIntakeReminderState(): IntakeReminderState {
|
||||||
try {
|
try {
|
||||||
if (existsSync(intakeReminderStateFile)) {
|
if (existsSync(intakeReminderStateFile)) {
|
||||||
const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8"));
|
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||||
return {
|
|
||||||
sentReminders: saved.sentReminders ?? [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return { sentReminders: [] };
|
return createDefaultIntakeReminderState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveIntakeReminderState(state: IntakeReminderState): void {
|
function saveIntakeReminderState(state: IntakeReminderState): void {
|
||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||||
try {
|
return parseBlisters(row);
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpcomingIntake = {
|
async function sendIntakeReminderEmail(
|
||||||
medName: string;
|
email: string,
|
||||||
usage: number;
|
intakes: UpcomingIntake[],
|
||||||
intakeTime: Date;
|
language: Language,
|
||||||
intakeTimeStr: string;
|
isRepeat: boolean = false,
|
||||||
takenBy: string[]; // Changed to array
|
repeatIntervalMinutes?: number
|
||||||
pillWeightMg: number | null;
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
};
|
|
||||||
|
|
||||||
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 }> {
|
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const smtpUser = process.env.SMTP_USER;
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
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
|
? tr.intakeReminder.alertSingle
|
||||||
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
|
: 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 = `
|
const html = `
|
||||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
<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);">
|
<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>
|
<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;">
|
<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;">
|
<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}
|
const plainText = `${tr.intakeReminder.title}
|
||||||
|
|
||||||
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
|
${description}
|
||||||
|
|
||||||
${intakes.map((i) => {
|
${intakes.map((i) => {
|
||||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||||
@@ -245,7 +164,9 @@ ${intakes.map((i) => {
|
|||||||
---
|
---
|
||||||
${tr.intakeReminder.footer}`;
|
${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 {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
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> {
|
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
|
// Get all user settings to iterate over each user
|
||||||
const allUserSettings = await getAllUserSettings();
|
const allUserSettings = await getAllUserSettings();
|
||||||
|
|
||||||
if (allUserSettings.length === 0) {
|
if (allUserSettings.length === 0) {
|
||||||
|
logger.info(`[IntakeReminder] No users with settings found`);
|
||||||
return; // No users with settings
|
return; // No users with settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
||||||
|
|
||||||
for (const userSettings of allUserSettings) {
|
for (const userSettings of allUserSettings) {
|
||||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||||
}
|
}
|
||||||
@@ -293,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
const language = settings.language;
|
const language = settings.language;
|
||||||
const tr = getTranslations(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)
|
// Check if any intake reminder notifications are enabled (granular check)
|
||||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||||
|
|
||||||
if (!emailEnabled && !shoutrrrEnabled) {
|
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
|
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
|
// 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 rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
|
||||||
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = loadIntakeReminderState();
|
logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
|
||||||
const allUpcoming: UpcomingIntake[] = [];
|
|
||||||
const locale = getDateLocale(language);
|
|
||||||
|
|
||||||
// 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) {
|
for (const med of medsWithReminders) {
|
||||||
const blisters = parseBlisters(med);
|
const blisters = parseBlistersFromRow(med);
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
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) {
|
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)
|
// Determine which doses need reminders (new or repeated)
|
||||||
const newReminders = allUpcoming.filter(intake => {
|
const nowMs = Date.now();
|
||||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
let remindersToSend: typeof allUpcoming = [];
|
||||||
return !state.sentReminders.includes(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newReminders.length === 0) {
|
for (const intake of allUpcoming) {
|
||||||
return; // All reminders already sent
|
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 emailSuccess = false;
|
||||||
let shoutrrrSuccess = false;
|
let shoutrrrSuccess = false;
|
||||||
|
|
||||||
// Send email if enabled for intake reminders
|
// Send email if enabled for intake reminders
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language);
|
const result = await sendIntakeReminderEmail(
|
||||||
|
settings.notificationEmail!,
|
||||||
|
remindersToSend,
|
||||||
|
language,
|
||||||
|
isRepeatReminder,
|
||||||
|
settings.reminderRepeatIntervalMinutes
|
||||||
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
||||||
@@ -353,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Send Shoutrrr notification if enabled for intake reminders
|
// Send Shoutrrr notification if enabled for intake reminders
|
||||||
if (shoutrrrEnabled) {
|
if (shoutrrrEnabled) {
|
||||||
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
const title = isRepeatReminder
|
||||||
const message = newReminders
|
? (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) => {
|
.map((i) => {
|
||||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
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}`;
|
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}`;
|
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n") + repeatNote;
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
@@ -377,18 +444,32 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Update state if any notification was sent successfully
|
// Update state if any notification was sent successfully
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
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)
|
// Clean up old entries (remove doses from past days)
|
||||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
state.reminders = cleanOldIntakeReminders(state.reminders, tz);
|
||||||
const cleanedReminders = state.sentReminders.filter(key => {
|
|
||||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
|
||||||
return timestamp > oneDayAgo;
|
|
||||||
});
|
|
||||||
|
|
||||||
saveIntakeReminderState({
|
saveIntakeReminderState(state);
|
||||||
sentReminders: [...cleanedReminders, ...newKeys],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update global reminder state for UI display
|
// Update global reminder state for UI display
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||||
|
|||||||
@@ -1,155 +1,42 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
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 { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
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 };
|
// Import shared utilities
|
||||||
|
import {
|
||||||
type ReminderState = {
|
getTimezone,
|
||||||
lastAutoEmailSent: string | null; // ISO date string
|
formatInTimezone,
|
||||||
lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today
|
getCurrentHourInTimezone,
|
||||||
notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked)
|
getTodayInTimezone,
|
||||||
nextScheduledCheck: string | null; // ISO date string for when the next check is scheduled
|
getNextScheduledTime,
|
||||||
lastNotificationType: "stock" | "intake" | null; // Type of last notification
|
getMsUntilNextCheck,
|
||||||
lastNotificationChannel: "email" | "push" | "both" | null; // Channel used for last notification
|
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
|
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");
|
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
if (existsSync(reminderStateFile)) {
|
if (existsSync(reminderStateFile)) {
|
||||||
const saved = JSON.parse(readFileSync(reminderStateFile, "utf-8"));
|
return parseReminderState(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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null, lastNotificationType: null, lastNotificationChannel: null };
|
return createDefaultReminderState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveReminderState(state: ReminderState): void {
|
function saveReminderState(state: ReminderState): void {
|
||||||
@@ -188,39 +75,8 @@ export async function updateUserReminderSentTime(
|
|||||||
.where(eq(userSettings.userId, userId));
|
.where(eq(userSettings.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||||
try {
|
return parseBlisters(row);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
@@ -236,8 +92,8 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
|
|||||||
const lowStock: LowStockItem[] = [];
|
const lowStock: LowStockItem[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlisters(row);
|
const blisters = parseBlistersFromRow(row);
|
||||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
|
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||||
|
|
||||||
// Check if medication runs out within reminderDaysBefore days
|
// Check if medication runs out within reminderDaysBefore days
|
||||||
@@ -486,8 +342,8 @@ async function checkAndSendReminderForUser(
|
|||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
||||||
const msUntilNext = getMsUntilNextCheck();
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||||
const nextTime = getNextScheduledTime();
|
const nextTime = getNextScheduledTime(REMINDER_HOUR);
|
||||||
|
|
||||||
// Save next scheduled time to state
|
// Save next scheduled time to state
|
||||||
const state = loadReminderState();
|
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,637 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Import the exported utility functions from client.ts
|
||||||
|
import {
|
||||||
|
buildDbUrl,
|
||||||
|
getDbPaths,
|
||||||
|
ensureDataDirectory,
|
||||||
|
runDrizzleMigrations,
|
||||||
|
runAlterMigrations,
|
||||||
|
ensureDefaultUser,
|
||||||
|
} from "../db/client.js";
|
||||||
|
|
||||||
|
// Import the exported utility functions from migrate.ts
|
||||||
|
import {
|
||||||
|
splitSQLStatements,
|
||||||
|
executeMigration,
|
||||||
|
getStatementPreview,
|
||||||
|
} from "../db/migrate.js";
|
||||||
|
|
||||||
|
// Get migrations folder path
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
describe("Migration Script Utilities", () => {
|
||||||
|
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).toBeGreaterThan(0);
|
||||||
|
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' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' 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");
|
||||||
|
expect(tableNames).toContain("refill_history");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be idempotent", async () => {
|
||||||
|
await executeMigration(client);
|
||||||
|
const result = await executeMigration(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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 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("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("runDrizzleMigrations", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create all tables successfully", async () => {
|
||||||
|
const db = drizzle(client);
|
||||||
|
const result = await runDrizzleMigrations(db);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be idempotent (run twice without errors)", async () => {
|
||||||
|
const db = drizzle(client);
|
||||||
|
await runDrizzleMigrations(db);
|
||||||
|
const result = await runDrizzleMigrations(db);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create all 7 tables", async () => {
|
||||||
|
const db = drizzle(client);
|
||||||
|
await runDrizzleMigrations(db);
|
||||||
|
|
||||||
|
const tables = await client.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' 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");
|
||||||
|
expect(tableNames).toContain("refill_history");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runAlterMigrations", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should run without errors on a fresh database", async () => {
|
||||||
|
const result = await runAlterMigrations(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be idempotent", async () => {
|
||||||
|
await runAlterMigrations(client);
|
||||||
|
const result = await runAlterMigrations(client);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureDefaultUser", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 via Drizzle Migrations", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have users table with correct columns", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(users)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("username");
|
||||||
|
expect(columnNames).toContain("password_hash");
|
||||||
|
expect(columnNames).toContain("auth_provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have medications table with correct columns", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(medications)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("user_id");
|
||||||
|
expect(columnNames).toContain("name");
|
||||||
|
expect(columnNames).toContain("taken_by_json");
|
||||||
|
expect(columnNames).toContain("pack_count");
|
||||||
|
expect(columnNames).toContain("usage_json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have user_settings table with correct columns", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(user_settings)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("user_id");
|
||||||
|
expect(columnNames).toContain("email_enabled");
|
||||||
|
expect(columnNames).toContain("language");
|
||||||
|
expect(columnNames).toContain("stock_calculation_mode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have refresh_tokens table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(refresh_tokens)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("user_id");
|
||||||
|
expect(columnNames).toContain("token_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have share_tokens table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(share_tokens)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("token");
|
||||||
|
expect(columnNames).toContain("taken_by");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have dose_tracking table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(dose_tracking)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("dose_id");
|
||||||
|
expect(columnNames).toContain("marked_by");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have refill_history table", async () => {
|
||||||
|
const columns = await client.execute("PRAGMA table_info(refill_history)");
|
||||||
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
|
|
||||||
|
expect(columnNames).toContain("id");
|
||||||
|
expect(columnNames).toContain("medication_id");
|
||||||
|
expect(columnNames).toContain("packs_added");
|
||||||
|
expect(columnNames).toContain("loose_pills_added");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Default Values", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
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'");
|
||||||
|
// SQLite stores booleans as integers
|
||||||
|
expect(result.rows[0].is_active).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User Settings Defaults", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
// SQLite stores booleans as integers (false = 0)
|
||||||
|
expect(result.rows[0].email_enabled).toBeFalsy();
|
||||||
|
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", 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).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
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("Unique Constraints", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enforce unique constraint on username", async () => {
|
||||||
|
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("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
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 User Creation (Auth Disabled)", () => {
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,939 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
|
last_stock_correction_at integer,
|
||||||
|
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,394 @@
|
|||||||
|
/**
|
||||||
|
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
|
||||||
|
* Tests adding refills to medication stock and retrieving refill history.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
buildTestApp,
|
||||||
|
closeTestApp,
|
||||||
|
clearTestData,
|
||||||
|
createTestUser,
|
||||||
|
createTestMedication,
|
||||||
|
TestContext,
|
||||||
|
} from "./setup.js";
|
||||||
|
|
||||||
|
// Store userId at module level so routes can access it
|
||||||
|
let currentUserId = 1;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Route Registration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
async function registerRefillRoutes(ctx: TestContext) {
|
||||||
|
const { app, client } = ctx;
|
||||||
|
|
||||||
|
// POST /medications/:id/refill - Add stock and record history
|
||||||
|
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
||||||
|
"/medications/:id/refill",
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = currentUserId;
|
||||||
|
const medId = parseInt(request.params.id, 10);
|
||||||
|
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (packsAdded < 0 || loosePillsAdded < 0) {
|
||||||
|
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
||||||
|
}
|
||||||
|
if (packsAdded === 0 && loosePillsAdded === 0) {
|
||||||
|
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check medication exists and belongs to user
|
||||||
|
const medResult = await client.execute({
|
||||||
|
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
||||||
|
FROM medications WHERE id = ? AND user_id = ?`,
|
||||||
|
args: [medId, userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (medResult.rows.length === 0) {
|
||||||
|
return reply.status(404).send({ error: "Medication not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const med = medResult.rows[0];
|
||||||
|
const newPackCount = (med.pack_count as number) + packsAdded;
|
||||||
|
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
||||||
|
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
||||||
|
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||||||
|
|
||||||
|
// Update medication stock
|
||||||
|
await client.execute({
|
||||||
|
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
||||||
|
args: [newPackCount, newLooseTablets, medId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record refill history
|
||||||
|
await client.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, packsAdded, loosePillsAdded],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pillsAdded: totalPillsAdded,
|
||||||
|
newPackCount,
|
||||||
|
newLooseTablets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /medications/:id/refills - Get refill history
|
||||||
|
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
||||||
|
const userId = currentUserId;
|
||||||
|
const medId = parseInt(request.params.id, 10);
|
||||||
|
|
||||||
|
// Check medication exists and belongs to user
|
||||||
|
const medResult = await client.execute({
|
||||||
|
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||||
|
args: [medId, userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (medResult.rows.length === 0) {
|
||||||
|
return reply.status(404).send({ error: "Medication not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refill history, newest first
|
||||||
|
const refillResult = await client.execute({
|
||||||
|
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
||||||
|
FROM refill_history
|
||||||
|
WHERE medication_id = ? AND user_id = ?
|
||||||
|
ORDER BY refill_date DESC`,
|
||||||
|
args: [medId, userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
refills: refillResult.rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
packsAdded: r.packs_added,
|
||||||
|
loosePillsAdded: r.loose_pills_added,
|
||||||
|
refillDate: r.refill_date,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("Refill API", () => {
|
||||||
|
let ctx: TestContext;
|
||||||
|
let userId: number;
|
||||||
|
let medId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await buildTestApp();
|
||||||
|
await registerRefillRoutes(ctx);
|
||||||
|
await ctx.app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeTestApp(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTestData(ctx.client);
|
||||||
|
// Create test user
|
||||||
|
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||||
|
// Update the module-level userId so routes use the correct one
|
||||||
|
currentUserId = userId;
|
||||||
|
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
||||||
|
medId = await createTestMedication(ctx.client, {
|
||||||
|
userId,
|
||||||
|
name: "Test Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 10,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /medications/:id/refill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /medications/:id/refill", () => {
|
||||||
|
it("should add packs to medication stock", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
||||||
|
expect(data.newPackCount).toBe(3); // 1 + 2
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].pack_count).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add loose pills to medication stock", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { loosePillsAdded: 15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.pillsAdded).toBe(15);
|
||||||
|
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].loose_tablets).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add both packs and loose pills", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
||||||
|
expect(data.newPackCount).toBe(2);
|
||||||
|
expect(data.newLooseTablets).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should record refill in history", async () => {
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check history
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
expect(result.rows.length).toBe(1);
|
||||||
|
expect(result.rows[0].packs_added).toBe(2);
|
||||||
|
expect(result.rows[0].loose_pills_added).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject refill with zero amounts", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toContain("At least one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject refill with negative amounts", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: -1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toContain("non-negative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent medication", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/99999/refill`,
|
||||||
|
payload: { packsAdded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json().error).toBe("Medication not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /medications/:id/refills
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("GET /medications/:id/refills", () => {
|
||||||
|
it("should return empty array when no refills", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ refills: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return refill history newest first", async () => {
|
||||||
|
// Add two refills with different values so we can identify them
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
||||||
|
await new Promise((r) => setTimeout(r, 1100));
|
||||||
|
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.refills).toHaveLength(2);
|
||||||
|
|
||||||
|
// Newest first (loose pills - added second)
|
||||||
|
expect(data.refills[0].packsAdded).toBe(0);
|
||||||
|
expect(data.refills[0].loosePillsAdded).toBe(20);
|
||||||
|
|
||||||
|
// Older (packs - added first)
|
||||||
|
expect(data.refills[1].packsAdded).toBe(1);
|
||||||
|
expect(data.refills[1].loosePillsAdded).toBe(0);
|
||||||
|
|
||||||
|
// Each entry should have an id and refillDate
|
||||||
|
for (const refill of data.refills) {
|
||||||
|
expect(refill.id).toBeTypeOf("number");
|
||||||
|
expect(refill.refillDate).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent medication", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/99999/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json().error).toBe("Medication not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cascade Delete Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Cascade Delete", () => {
|
||||||
|
it("should delete refill history when medication is deleted", async () => {
|
||||||
|
// Add a refill
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify refill exists
|
||||||
|
let result = await ctx.client.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].count).toBe(1);
|
||||||
|
|
||||||
|
// Delete medication
|
||||||
|
await ctx.client.execute({
|
||||||
|
sql: `DELETE FROM medications WHERE id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify refill history was cascade deleted
|
||||||
|
result = await ctx.client.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||||
|
args: [medId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete refill history when user is deleted", async () => {
|
||||||
|
// Add a refill
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify refill exists
|
||||||
|
let result = await ctx.client.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].count).toBe(1);
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
await ctx.client.execute({
|
||||||
|
sql: `DELETE FROM users WHERE id = ?`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify refill history was cascade deleted
|
||||||
|
result = await ctx.client.execute({
|
||||||
|
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,304 @@
|
|||||||
|
/**
|
||||||
|
* 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 { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Get migrations folder path
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
// 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 using drizzle-kit migrations
|
||||||
|
*/
|
||||||
|
async function runTestMigrations(client: Client): Promise<void> {
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 refill_history");
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- backend_node_modules:/app/node_modules
|
- backend_node_modules:/app/node_modules
|
||||||
- ./backend/data:/app/data
|
- ./data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -12,8 +12,8 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Allow larger file uploads (for medication images)
|
# Allow larger file uploads (for medication images and data import/export)
|
||||||
client_max_body_size 10M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1713,9 +1713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -1735,12 +1735,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.11.0"
|
"react-router": "7.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
{t("auth.register", "Create Account")}
|
{t("auth.register", "Create Account")}
|
||||||
</h2>
|
</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>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{onSwitchToLogin && (
|
{onSwitchToLogin && (
|
||||||
<div className="auth-links">
|
<div className="auth-links">
|
||||||
|
|||||||
@@ -38,7 +38,14 @@
|
|||||||
"pastDaysCount": "{{count}} Tag",
|
"pastDaysCount": "{{count}} Tag",
|
||||||
"pastDaysCount_other": "{{count}} Tage",
|
"pastDaysCount_other": "{{count}} Tage",
|
||||||
"missedDoses": "{{count}} verpasste Dosis",
|
"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": {
|
"reminders": {
|
||||||
"active": "Automatische Erinnerungen aktiv",
|
"active": "Automatische Erinnerungen aktiv",
|
||||||
@@ -91,8 +98,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Eintrag bearbeiten",
|
"editEntry": "Medikament bearbeiten",
|
||||||
"newEntry": "Neuer Eintrag",
|
"newEntry": "Neues Medikament",
|
||||||
"badge": "Packungen + lose Tabletten",
|
"badge": "Packungen + lose Tabletten",
|
||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
@@ -157,7 +164,15 @@
|
|||||||
"push": "Push",
|
"push": "Push",
|
||||||
"stockReminders": "Bestands-Erinnerungen",
|
"stockReminders": "Bestands-Erinnerungen",
|
||||||
"intakeReminders": "Einnahme-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": {
|
"email": {
|
||||||
"recipient": "Empfänger",
|
"recipient": "Empfänger",
|
||||||
@@ -165,7 +180,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"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": {
|
"schedule": {
|
||||||
"title": "Erinnerungsplan",
|
"title": "Erinnerungsplan",
|
||||||
@@ -209,7 +226,7 @@
|
|||||||
"pillWeight": "Tablettengewicht",
|
"pillWeight": "Tablettengewicht",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"intakeSchedule": "Einnahmeplan",
|
"intakeSchedule": "Einnahmeplan",
|
||||||
"coverageStatus": "Reichweite",
|
"coverageStatus": "Bestand",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"runsOut": "Aufgebraucht",
|
"runsOut": "Aufgebraucht",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -313,7 +330,8 @@
|
|||||||
"fullBlister": "voller Blister",
|
"fullBlister": "voller Blister",
|
||||||
"fullBlisters": "volle Blister",
|
"fullBlisters": "volle Blister",
|
||||||
"inBlister": "in 1 Blister",
|
"inBlister": "in 1 Blister",
|
||||||
"total": "gesamt"
|
"total": "gesamt",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Teilen",
|
"button": "Teilen",
|
||||||
@@ -340,5 +358,88 @@
|
|||||||
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
||||||
"expiredOn": "Abgelaufen am: {{date}}"
|
"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.",
|
||||||
|
"includeImages": "Medikamentenbilder einschließen",
|
||||||
|
"includeImagesHint": "Bilder vergrößern die Datei erheblich. Deaktivieren für kleinere Exports (~50 KB statt mehrere MB).",
|
||||||
|
"exportOptions": "Export-Optionen",
|
||||||
|
"exportWithImages": "Mit Bildern",
|
||||||
|
"exportWithImagesDesc": "Vollständiges Backup mit allen Medikamentenbildern. Größere Datei.",
|
||||||
|
"exportDataOnly": "Nur Daten",
|
||||||
|
"exportDataOnlyDesc": "Kompaktes Backup ohne Bilder. Viel kleinere Datei (~50 KB).",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"refill": {
|
||||||
|
"title": "Nachfüllen",
|
||||||
|
"packs": "Packungen hinzufügen",
|
||||||
|
"loosePills": "Lose Tabletten hinzufügen",
|
||||||
|
"pillsPerPack": "1 Packung = {{count}} Tabletten",
|
||||||
|
"addToStock": "Zum Bestand hinzufügen",
|
||||||
|
"adding": "Wird hinzugefügt...",
|
||||||
|
"success": "{{pills}} Tabletten zum Bestand hinzugefügt",
|
||||||
|
"history": "Nachfüll-Verlauf",
|
||||||
|
"noHistory": "Noch keine Nachfüllungen erfasst",
|
||||||
|
"packsAdded": "{{count}} Packung",
|
||||||
|
"packsAdded_other": "{{count}} Packungen",
|
||||||
|
"pillsAdded": "{{count}} Tablette",
|
||||||
|
"pillsAdded_other": "{{count}} Tabletten",
|
||||||
|
"button": "Nachfüllen"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Bestand korrigieren",
|
||||||
|
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
||||||
|
"fullBlisters": "Volle Blister",
|
||||||
|
"partialBlisterPills": "Angebrochener Blister",
|
||||||
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
|
"currentTotal": "Aktueller Bestand",
|
||||||
|
"newTotal": "Neuer Bestand",
|
||||||
|
"difference": "Differenz",
|
||||||
|
"save": "Korrektur speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"success": "Bestand erfolgreich korrigiert"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Über",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-Source Medikamentenverwaltung und Planungsanwendung für selbst gehostete Umgebungen.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
|
"checking": "Prüfe...",
|
||||||
|
"upToDate": "Du bist auf dem neuesten Stand!",
|
||||||
|
"updateAvailable": "Update verfügbar",
|
||||||
|
"viewOnGitHub": "Auf GitHub ansehen",
|
||||||
|
"downloadUpdate": "Update herunterladen",
|
||||||
|
"checkFailed": "Update-Prüfung fehlgeschlagen",
|
||||||
|
"lastChecked": "Zuletzt geprüft",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT-Lizenz",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
||||||
|
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
"pastDaysCount": "{{count}} day",
|
"pastDaysCount": "{{count}} day",
|
||||||
"pastDaysCount_other": "{{count}} days",
|
"pastDaysCount_other": "{{count}} days",
|
||||||
"missedDoses": "{{count}} missed dose",
|
"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": {
|
"reminders": {
|
||||||
"active": "Automatic reminders active",
|
"active": "Automatic reminders active",
|
||||||
@@ -93,8 +100,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Edit entry",
|
"editEntry": "Edit medication",
|
||||||
"newEntry": "New entry",
|
"newEntry": "New medication",
|
||||||
"badge": "Packs + loose pills",
|
"badge": "Packs + loose pills",
|
||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
@@ -159,7 +166,15 @@
|
|||||||
"push": "Push",
|
"push": "Push",
|
||||||
"stockReminders": "Stock Reminders",
|
"stockReminders": "Stock Reminders",
|
||||||
"intakeReminders": "Intake 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": {
|
"email": {
|
||||||
"recipient": "Recipient",
|
"recipient": "Recipient",
|
||||||
@@ -167,7 +182,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"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": {
|
"schedule": {
|
||||||
"title": "Reminder Schedule",
|
"title": "Reminder Schedule",
|
||||||
@@ -315,7 +332,8 @@
|
|||||||
"fullBlister": "full blister",
|
"fullBlister": "full blister",
|
||||||
"fullBlisters": "full blisters",
|
"fullBlisters": "full blisters",
|
||||||
"inBlister": "in 1 blister",
|
"inBlister": "in 1 blister",
|
||||||
"total": "total"
|
"total": "total",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Share",
|
"button": "Share",
|
||||||
@@ -342,5 +360,88 @@
|
|||||||
"contact": "Please contact {{username}} to request a new link.",
|
"contact": "Please contact {{username}} to request a new link.",
|
||||||
"expiredOn": "Expired on: {{date}}"
|
"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.",
|
||||||
|
"includeImages": "Include medication images",
|
||||||
|
"includeImagesHint": "Images significantly increase file size. Uncheck for smaller exports (~50 KB instead of several MB).",
|
||||||
|
"exportOptions": "Export Options",
|
||||||
|
"exportWithImages": "With Images",
|
||||||
|
"exportWithImagesDesc": "Full backup including all medication images. Larger file size.",
|
||||||
|
"exportDataOnly": "Data Only",
|
||||||
|
"exportDataOnlyDesc": "Compact backup without images. Much smaller file size (~50 KB).",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"refill": {
|
||||||
|
"title": "Refill",
|
||||||
|
"packs": "Packs to add",
|
||||||
|
"loosePills": "Loose pills to add",
|
||||||
|
"pillsPerPack": "1 pack = {{count}} pills",
|
||||||
|
"addToStock": "Add to Stock",
|
||||||
|
"adding": "Adding...",
|
||||||
|
"success": "Added {{pills}} pills to stock",
|
||||||
|
"history": "Refill History",
|
||||||
|
"noHistory": "No refills recorded yet",
|
||||||
|
"packsAdded": "{{count}} pack",
|
||||||
|
"packsAdded_other": "{{count}} packs",
|
||||||
|
"pillsAdded": "{{count}} pill",
|
||||||
|
"pillsAdded_other": "{{count}} pills",
|
||||||
|
"button": "Refill"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Correct Stock",
|
||||||
|
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
||||||
|
"fullBlisters": "Full blisters",
|
||||||
|
"partialBlisterPills": "Partial blister",
|
||||||
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
|
"currentTotal": "Current total",
|
||||||
|
"newTotal": "New total",
|
||||||
|
"difference": "Difference",
|
||||||
|
"save": "Save Correction",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"success": "Stock corrected successfully"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-source medication tracking and planning application for self-hosted environments.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Check for Updates",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"upToDate": "You're up to date!",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"viewOnGitHub": "View on GitHub",
|
||||||
|
"downloadUpdate": "Download Update",
|
||||||
|
"checkFailed": "Could not check for updates",
|
||||||
|
"lastChecked": "Last checked",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT License",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Made with ❤️ for better health management",
|
||||||
|
"techStack": "Built with React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,46 @@ button.secondary:hover {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Success button (Refill, etc.) */
|
||||||
|
button.success {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.success:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
button.success:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary/Accent button (New entry, Add intake, etc.) */
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info button (Edit, secondary actions) */
|
||||||
|
button.info {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
button.info:hover {
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ghost button (Cancel, etc.) */
|
/* Ghost button (Cancel, etc.) */
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -637,6 +677,7 @@ textarea.auto-resize {
|
|||||||
.past-days-toggle {
|
.past-days-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -662,6 +703,7 @@ textarea.auto-resize {
|
|||||||
.past-days-icon {
|
.past-days-icon {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-label {
|
.past-days-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -669,6 +711,8 @@ textarea.auto-resize {
|
|||||||
.past-days-count {
|
.past-days-count {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-warning {
|
.past-days-warning {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -690,6 +734,35 @@ textarea.auto-resize {
|
|||||||
background: rgba(234, 179, 8, 0.08);
|
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 */
|
/* Past day blocks styling */
|
||||||
.day-block.past {
|
.day-block.past {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.language-row {
|
.setting-row.language-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row.language-row .setting-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-select {
|
.language-select {
|
||||||
width: auto;
|
flex: 1 1 auto;
|
||||||
min-width: 160px;
|
min-width: 140px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-tooltip:hover::after,
|
.info-tooltip:hover::after,
|
||||||
.info-tooltip:hover::before {
|
.info-tooltip:hover::before,
|
||||||
|
.info-tooltip:focus::after,
|
||||||
|
.info-tooltip:focus::before,
|
||||||
|
.info-tooltip.tooltip-active::after,
|
||||||
|
.info-tooltip.tooltip-active::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile tooltip - disable hover, use click only */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.info-tooltip:hover::after,
|
||||||
|
.info-tooltip:hover::before {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip.tooltip-active::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
bottom: auto;
|
||||||
|
right: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
width: max-content;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tooltip::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Channels Overview */
|
/* Channels Overview */
|
||||||
.channels-overview {
|
.channels-overview {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
|
|||||||
.schedule-row {
|
.schedule-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.schedule-label {
|
.schedule-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-value {
|
.schedule-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: stack schedule rows vertically when text is long */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.schedule-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.schedule-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy support for old channel-btn (can remove later) */
|
/* Legacy support for old channel-btn (can remove later) */
|
||||||
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact Setting Row */
|
/* Compact Setting Row - for inline toggles without card styling */
|
||||||
.setting-row.compact {
|
.setting-row.compact {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.compact .setting-label {
|
.setting-row.compact .setting-label {
|
||||||
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
|
|||||||
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
||||||
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
||||||
|
|
||||||
|
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
|
||||||
|
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||||
|
|
||||||
/* Table/Timeline cells with avatar */
|
/* Table/Timeline cells with avatar */
|
||||||
.cell-with-avatar {
|
.cell-with-avatar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-image {
|
.lightbox-image {
|
||||||
max-width: 90vw;
|
max-width: 50vw;
|
||||||
max-height: 85vh;
|
max-height: 50vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
animation: zoomIn 0.3s ease;
|
animation: zoomIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: larger lightbox image */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lightbox-image {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
from { opacity: 0; transform: scale(0.8); }
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer > button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer .footer-actions {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-detail-footer button {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-grid {
|
.med-detail-grid {
|
||||||
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu:hover .user-dropdown {
|
/* Only use hover on devices that support it (not touch) */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.user-menu:hover .user-dropdown {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click-based open for all devices */
|
||||||
|
.user-menu.open .user-dropdown {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
@@ -3560,6 +3729,239 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
About Modal
|
||||||
|
============================================================================= */
|
||||||
|
.about-modal {
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-rgb, 59, 130, 246), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tagline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-versions {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row:not(:last-child) {
|
||||||
|
border-bottom: 1px dashed var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-section {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-primary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.up-to-date {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.update-available {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-last-checked {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-footer {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-copyright {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-license {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
Share Dialog
|
Share Dialog
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -3688,6 +4090,299 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Refill Modal & History
|
||||||
|
============================================================================= */
|
||||||
|
.refill-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-med-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-footer-right .refill-preview {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refill modal footer mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.refill-modal .modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer > button,
|
||||||
|
.refill-modal .modal-footer .refill-footer-right {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer .refill-footer-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-modal .modal-footer .refill-footer-right button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline refill form in edit modal */
|
||||||
|
.refill-form-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline label input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-form-inline button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-preview {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--success);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section {
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem 1rem 1rem 1.25rem;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button:hover:not(:disabled) {
|
||||||
|
background: var(--success-hover, #3aa865);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-section .refill-form-inline button:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Edit Stock Modal (Correction)
|
||||||
|
============================================================================= */
|
||||||
|
.edit-stock-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-med-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-bg);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(252, 211, 77, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label .hint-text {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.positive span:last-child {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.negative span:last-child {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable section header (for expand/collapse) */
|
||||||
|
.section-header-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-clickable:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refill history in detail modal */
|
||||||
|
.refill-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-header .collapse-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-header .refill-count {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-list {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-date {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-details {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested modal overlay */
|
||||||
|
.modal-overlay.nested {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
Shared Schedule Page (Public)
|
Shared Schedule Page (Public)
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -3977,3 +4672,60 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action Cards (for Export/Import etc.) - similar to radio-card */
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
border-color: var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card button,
|
||||||
|
.action-card .btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.action-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card button,
|
||||||
|
.action-card .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
// Read version from package.json at build time
|
||||||
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(packageJson.version || "unknown"),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
|
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
|
||||||
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
||||||
# ./scripts/release.sh 1.2.3 # explicit version
|
# ./scripts/release.sh 1.2.3 # explicit version
|
||||||
|
#
|
||||||
|
# This script creates a PR for the version bump (required due to branch protection),
|
||||||
|
# waits for CI, merges it, and then creates a signed tag for the release.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -18,14 +21,28 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Secondary remote (self-hosted git)
|
# GitHub repo
|
||||||
SECONDARY_REMOTE="git@git.danielvolz.org:daniel/medassist-ng.git"
|
GITHUB_REPO="DanielVolz/medassist-ng"
|
||||||
|
|
||||||
# Get script directory and project root
|
# Get script directory and project root
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Check for gh CLI
|
||||||
|
if ! command -v gh &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
||||||
|
echo "Install it with: brew install gh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check gh authentication
|
||||||
|
if ! gh auth status &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
||||||
|
echo "Run: gh auth login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Check for uncommitted changes
|
# Check for uncommitted changes
|
||||||
if [[ -n $(git status --porcelain) ]]; then
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
||||||
@@ -33,6 +50,11 @@ if [[ -n $(git status --porcelain) ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Make sure we're on main and up to date
|
||||||
|
echo -e "${BLUE}Updating main branch...${NC}"
|
||||||
|
git checkout main
|
||||||
|
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||||
|
|
||||||
# Get current version from backend/package.json
|
# Get current version from backend/package.json
|
||||||
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||||
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
||||||
@@ -77,6 +99,19 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Branch name for the release
|
||||||
|
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
||||||
|
|
||||||
|
# Check if branch already exists
|
||||||
|
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
||||||
|
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
||||||
|
git branch -D "${RELEASE_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create release branch
|
||||||
|
echo -e "${BLUE}Creating release branch...${NC}"
|
||||||
|
git checkout -b "${RELEASE_BRANCH}"
|
||||||
|
|
||||||
# Update version in package.json files
|
# Update version in package.json files
|
||||||
echo -e "${BLUE}Updating package.json files...${NC}"
|
echo -e "${BLUE}Updating package.json files...${NC}"
|
||||||
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
||||||
@@ -87,34 +122,73 @@ echo -e "${BLUE}Committing version bump...${NC}"
|
|||||||
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
||||||
git commit -m "chore: release v${NEW_VERSION}"
|
git commit -m "chore: release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Check if tag exists
|
# Push branch to GitHub
|
||||||
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
|
||||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists. Overwriting...${NC}"
|
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
|
||||||
git tag -d "v${NEW_VERSION}"
|
|
||||||
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
# Create PR
|
||||||
|
echo -e "${BLUE}Creating Pull Request...${NC}"
|
||||||
|
PR_URL=$(gh pr create \
|
||||||
|
--repo "${GITHUB_REPO}" \
|
||||||
|
--head "${RELEASE_BRANCH}" \
|
||||||
|
--title "chore: release v${NEW_VERSION}" \
|
||||||
|
--body "## Release v${NEW_VERSION}
|
||||||
|
|
||||||
|
Automated version bump for release v${NEW_VERSION}.
|
||||||
|
|
||||||
|
This PR was created by the release script." \
|
||||||
|
2>&1)
|
||||||
|
|
||||||
|
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
||||||
|
|
||||||
|
# Extract PR number
|
||||||
|
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||||
|
|
||||||
|
# Wait for CI checks
|
||||||
|
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
|
||||||
|
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
|
||||||
|
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create and push tag
|
echo -e "${GREEN}CI checks passed!${NC}"
|
||||||
|
|
||||||
|
# Merge PR
|
||||||
|
echo -e "${BLUE}Merging PR...${NC}"
|
||||||
|
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
||||||
|
|
||||||
|
# Switch back to main and pull
|
||||||
|
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
|
||||||
|
git checkout main
|
||||||
|
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if tag exists and delete it
|
||||||
|
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
||||||
|
git tag -d "v${NEW_VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if remote tag exists
|
||||||
|
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
|
||||||
|
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
||||||
|
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
|
||||||
|
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||||
|
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create signed tag
|
||||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Push
|
# Push tag
|
||||||
echo -e "${BLUE}Pushing to origin (GitHub)...${NC}"
|
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
|
||||||
git push origin main
|
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
|
||||||
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 ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||||
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
||||||
echo -e "Track progress: ${YELLOW}https://github.com/DanielVolz/medassist-ng/actions${NC}"
|
echo -e "Track progress: ${YELLOW}https://github.com/${GITHUB_REPO}/actions${NC}"
|
||||||
|
echo -e "Release page: ${YELLOW}https://github.com/${GITHUB_REPO}/releases/tag/v${NEW_VERSION}${NC}"
|
||||||
|
|||||||