Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc636eb98b | |||
| 8c77a87bc5 | |||
| 908e4e724f | |||
| ef78e51b4e | |||
| b57dc0fb35 | |||
| 99160c14ed | |||
| 63b07e0da8 | |||
| 8ec7d3ae3d | |||
| c38c6efb6d | |||
| 9d605a1855 | |||
| 0160ef3ddf | |||
| 816888a697 | |||
| e0fb77d494 | |||
| fd3134be24 | |||
| d0837a7281 | |||
| 3fda41e501 | |||
| c13bfad16f | |||
| dd8ddb64e6 | |||
| 75196e5fa8 | |||
| 5264c761cf | |||
| e0a50d01bb | |||
| 4d5edb7c76 | |||
| 07bfa78386 | |||
| 8d37fd0cb5 | |||
| 890449d756 | |||
| 192e611668 | |||
| 4de3b80aba | |||
| fd17288109 | |||
| c59fdfb92b | |||
| c0507c4c4b | |||
| 105eb7bc0d | |||
| 733fe2f38a | |||
| 2db49e427a | |||
| 0e4d7f71e4 | |||
| 8594e175f1 | |||
| 8e29219cd1 | |||
| 0be472bf38 | |||
| e8279bd521 | |||
| 4136252a20 | |||
| 36d50c0736 | |||
| d7d4bf39a0 | |||
| 5b6c6abb69 | |||
| 30c97e2f0d | |||
| de1a508e52 | |||
| 54d26e0241 | |||
| ac47fc001d | |||
| 4936929849 | |||
| 6672fb78c9 | |||
| b349e26833 | |||
| 56d244aa61 | |||
| 1a348c62f5 | |||
| 067a8c166b | |||
| 8fdd79ff33 | |||
| cd8263e607 | |||
| e6a097d81d | |||
| f4723c6f99 | |||
| aad6b143ef | |||
| da004b5c3e | |||
| cd18581bdd | |||
| 508bc764d5 | |||
| 9e8a6315e7 | |||
| 8efd99d738 | |||
| dc98dfda44 | |||
| 8aaeca6b26 | |||
| 7accb2aad6 | |||
| 2f2edfa479 | |||
| b009d9e158 | |||
| 8e4cb5dcd4 | |||
| 7f26dca7a7 | |||
| 46d768dd4e | |||
| c62b6d7893 | |||
| 1668eb935c | |||
| 1ea4919323 | |||
| ba0ab672b9 | |||
| 57c998ba09 | |||
| cc22f80209 | |||
| 6b27d234d9 | |||
| 19ba4bb7d2 | |||
| 8b3901c1e1 | |||
| fd7cc56bb7 | |||
| aabe58d05f | |||
| b35101d339 | |||
| 8420c74a55 | |||
| 872b63f665 | |||
| f599ac45ab | |||
| f36d56c523 | |||
| f0496e8ca5 | |||
| de300ad919 | |||
| 06bf608913 | |||
| a47bde0956 | |||
| d02f16af3a | |||
| dbdf3b61cb | |||
| aa29d1c699 | |||
| bfc9aaaa6d | |||
| 2a9ca39c24 | |||
| 691550fb33 | |||
| 0fded0d42f | |||
| badee6067c | |||
| 6161c14a7b | |||
| 96b2a0c96f | |||
| 7a32b2045e | |||
| 26475fd3d0 | |||
| 63cd9ef19b | |||
| f15c2dd79f | |||
| b0c5d48095 | |||
| 05226cc500 | |||
| 3e4f1440a9 | |||
| d64a833bda | |||
| ba36f67371 | |||
| 2aa6b1f406 | |||
| 3238a22fd6 | |||
| b139660241 | |||
| 259f00e7a0 | |||
| e9f2760815 | |||
| d0e2ee0783 | |||
| c620146c4b | |||
| 33c1095e77 | |||
| 5d657558f7 | |||
| 0c28999c89 | |||
| 2296303236 | |||
| 9a2d42b8b9 | |||
| 088a6c1a05 | |||
| 228fd4cd7e | |||
| e346d60f39 | |||
| afb8e5028c | |||
| 9ab077a037 | |||
| 976d7356ec | |||
| 943148fb49 | |||
| 94bd8bd6e8 | |||
| 0cf1c5353e | |||
| 98cf1ce1d2 | |||
| 75c201cab5 | |||
| 74f079d13e | |||
| fd3b770a81 | |||
| 612aa007aa | |||
| 02af93ec55 |
+26
-2
@@ -11,15 +11,32 @@ PGID=1000
|
|||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=warn
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
|
#
|
||||||
|
# Behavior per level:
|
||||||
|
# debug — all app logs + all HTTP request logs (including polling endpoints)
|
||||||
|
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
|
||||||
|
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
|
||||||
|
# warn — only warnings and errors
|
||||||
|
# error — only errors
|
||||||
|
# silent — no logs
|
||||||
|
|
||||||
# Rate limit: max requests per minute per IP (default: 100)
|
# Rate limit: max requests per minute per IP (default: 100)
|
||||||
# Increase for development/testing environments
|
# Increase for development/testing environments
|
||||||
# RATE_LIMIT_MAX=100
|
# RATE_LIMIT_MAX=100
|
||||||
|
|
||||||
|
# API documentation UI + OpenAPI JSON
|
||||||
|
# Default behavior: enabled outside production, disabled in production
|
||||||
|
# When enabled, docs are available on /docs and /docs/json.
|
||||||
|
# Recommended:
|
||||||
|
# development/staging: OPENAPI_DOCS_ENABLED=true
|
||||||
|
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||||
|
# OPENAPI_DOCS_ENABLED=true
|
||||||
|
|
||||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
@@ -32,6 +49,9 @@ AUTH_ENABLED=false
|
|||||||
# Allow new user registrations (auto-enabled when no users exist)
|
# Allow new user registrations (auto-enabled when no users exist)
|
||||||
# REGISTRATION_ENABLED=false
|
# REGISTRATION_ENABLED=false
|
||||||
|
|
||||||
|
# Disable username/password form login (useful for OIDC-only setups)
|
||||||
|
# FORM_LOGIN_ENABLED=true
|
||||||
|
|
||||||
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
|
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
# JWT_SECRET=
|
# JWT_SECRET=
|
||||||
@@ -102,12 +122,14 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# DEFAULT_NOTIFICATION_EMAIL=
|
# DEFAULT_NOTIFICATION_EMAIL=
|
||||||
# DEFAULT_EMAIL_STOCK_REMINDERS=true
|
# DEFAULT_EMAIL_STOCK_REMINDERS=true
|
||||||
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
||||||
|
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
|
||||||
|
|
||||||
# Push notifications (ntfy/gotify via Shoutrrr)
|
# Push notifications (ntfy/gotify via Shoutrrr)
|
||||||
# DEFAULT_SHOUTRRR_ENABLED=false
|
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||||
# DEFAULT_SHOUTRRR_URL=
|
# DEFAULT_SHOUTRRR_URL=
|
||||||
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||||
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
|
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
|
||||||
|
# DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS=true
|
||||||
|
|
||||||
# Repeat/nagging reminders for missed doses
|
# Repeat/nagging reminders for missed doses
|
||||||
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
|
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
|
||||||
@@ -126,4 +148,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# UI defaults
|
# UI defaults
|
||||||
# DEFAULT_LANGUAGE=en # en or de
|
# DEFAULT_LANGUAGE=en # en or de
|
||||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||||
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
|
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
|
||||||
|
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||||
|
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# MedAssist ownership
|
||||||
|
# This routes review requests automatically to the maintainer.
|
||||||
|
|
||||||
|
* @DanielVolz
|
||||||
|
|
||||||
|
# Explicit domains for clarity
|
||||||
|
/backend/ @DanielVolz
|
||||||
|
/frontend/ @DanielVolz
|
||||||
|
/.github/ @DanielVolz
|
||||||
|
/doku/ @DanielVolz
|
||||||
|
/docs/ @DanielVolz
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
name: 🐛 Bug Report
|
name: Bug Report
|
||||||
description: Report a bug or unexpected behavior
|
description: Report a bug or unexpected behavior
|
||||||
|
title: "[Bug]: "
|
||||||
labels: ["bug", "triage"]
|
labels: ["bug", "triage"]
|
||||||
|
assignees:
|
||||||
|
- DanielVolz
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
Thanks for taking the time to report a bug! Please fill out the sections below.
|
||||||
|
|
||||||
|
Before submitting, please reproduce the issue on the latest released version.
|
||||||
|
Even better: verify it on the current `main` image/tag.
|
||||||
|
The issue may already be fixed in newer builds.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
@@ -57,6 +64,18 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: version_info
|
||||||
|
attributes:
|
||||||
|
label: Version / Image Information
|
||||||
|
description: Provide the app version and, if using Docker, the exact image tag you are running.
|
||||||
|
placeholder: |
|
||||||
|
App version (Settings -> About): vX.Y.Z
|
||||||
|
Docker image tag (if applicable): latest or main
|
||||||
|
Tag guidance: use `latest` for the newest release, or `main` for the newest changes from the main branch (`main` is always as new as or newer than `latest`).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,8 +1 @@
|
|||||||
blank_issues_enabled: true
|
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
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name: ✨ Feature Request
|
name: Feature Request
|
||||||
description: Suggest a new feature or improvement
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
labels: ["enhancement", "triage"]
|
labels: ["enhancement", "triage"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: 'Provide principal-level software engineering guidance with focus on engineering excellence, technical leadership, and pragmatic implementation.'
|
||||||
|
name: 'Principal software engineer'
|
||||||
|
tools: ['changes', 'search/codebase', 'edit/editFiles', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runTasks', 'runTests', 'search', 'search/searchResults', 'runCommands/terminalLastCommand', 'runCommands/terminalSelection', 'testFailure', 'usages', 'vscodeAPI', 'github']
|
||||||
|
---
|
||||||
|
# Principal software engineer mode instructions
|
||||||
|
|
||||||
|
You are in principal software engineer mode. Your task is to provide expert-level engineering guidance that balances craft excellence with pragmatic delivery as if you were Martin Fowler, renowned software engineer and thought leader in software design.
|
||||||
|
|
||||||
|
## Core Engineering Principles
|
||||||
|
|
||||||
|
You will provide guidance on:
|
||||||
|
|
||||||
|
- **Engineering Fundamentals**: Gang of Four design patterns, SOLID principles, DRY, YAGNI, and KISS - applied pragmatically based on context
|
||||||
|
- **Clean Code Practices**: Readable, maintainable code that tells a story and minimizes cognitive load
|
||||||
|
- **Test Automation**: Comprehensive testing strategy including unit, integration, and end-to-end tests with clear test pyramid implementation
|
||||||
|
- **Quality Attributes**: Balancing testability, maintainability, scalability, performance, security, and understandability
|
||||||
|
- **Technical Leadership**: Clear feedback, improvement recommendations, and mentoring through code reviews
|
||||||
|
|
||||||
|
## Implementation Focus
|
||||||
|
|
||||||
|
- **Requirements Analysis**: Carefully review requirements, document assumptions explicitly, identify edge cases and assess risks
|
||||||
|
- **Implementation Excellence**: Implement the best design that meets architectural requirements without over-engineering
|
||||||
|
- **Pragmatic Craft**: Balance engineering excellence with delivery needs - good over perfect, but never compromising on fundamentals
|
||||||
|
- **Forward Thinking**: Anticipate future needs, identify improvement opportunities, and proactively address technical debt
|
||||||
|
|
||||||
|
## Technical Debt Management
|
||||||
|
|
||||||
|
When technical debt is incurred or identified:
|
||||||
|
|
||||||
|
- **MUST** offer to create GitHub Issues using the `create_issue` tool to track remediation
|
||||||
|
- Clearly document consequences and remediation plans
|
||||||
|
- Regularly recommend GitHub Issues for requirements gaps, quality issues, or design improvements
|
||||||
|
- Assess long-term impact of untended technical debt
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- Clear, actionable feedback with specific improvement recommendations
|
||||||
|
- Risk assessments with mitigation strategies
|
||||||
|
- Edge case identification and testing strategies
|
||||||
|
- Explicit documentation of assumptions and decisions
|
||||||
|
- Technical debt remediation plans with GitHub Issue creation
|
||||||
@@ -12,10 +12,17 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
|||||||
|
|
||||||
## Critical Safety Rules
|
## Critical Safety Rules
|
||||||
|
|
||||||
|
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
|
||||||
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
||||||
|
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
|
||||||
|
- **Use GitHub MCP for all GitHub remote operations. Never use `gh` CLI.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and releases must go through GitHub MCP tools only.
|
||||||
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
||||||
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
||||||
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
||||||
|
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
|
||||||
|
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
|
||||||
|
- **Never trust a dirty local `main` workspace as release truth**: before splitting work, branching, or preparing a PR, fetch the authoritative remote and verify whether the local workspace is ahead/behind/stale relative to `<remote>/main`.
|
||||||
|
- **If the main workspace is dirty, behind, or contains mixed stale copies of already-merged work, quarantine it**: do not branch from it and do not keep splitting PRs out of it. Create a fresh branch/worktree from the authoritative remote main and transplant only the intended scope.
|
||||||
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
|
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
|
||||||
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
|
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
|
||||||
|
|
||||||
@@ -44,16 +51,24 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
|||||||
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
|
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
|
||||||
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
|
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
|
||||||
|
|
||||||
## GitHub CLI Safety (Non-Interactive Only)
|
## GitHub Operations (GitHub MCP Only)
|
||||||
|
|
||||||
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
|
- Never use `gh` CLI in this agent.
|
||||||
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
|
- Use GitHub MCP tools for all GitHub actions: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, release publishing, and branch/PR metadata lookup.
|
||||||
- Do not use these commands in agent flows:
|
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
|
||||||
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
|
|
||||||
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
|
## Workspace Hygiene And Source-Of-Truth Rules
|
||||||
- Use safe variants instead:
|
|
||||||
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
|
- The authoritative comparison target is the actual remote default branch used for shipping, normally `github/main` or `origin/main`. Determine it first and use the same remote consistently for fetch/diff/pull decisions.
|
||||||
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
|
- Before any PR split or branch creation, run a source-of-truth audit:
|
||||||
|
1. fetch the authoritative remote
|
||||||
|
2. inspect `git status`
|
||||||
|
3. compare local `main` against `<remote>/main`
|
||||||
|
4. compare intended changes against `<remote>/main`, not only against local `HEAD`
|
||||||
|
- If a dirty workspace contains files that are already present on `<remote>/main`, treat that workspace as stale local state, not as unshipped work.
|
||||||
|
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
|
||||||
|
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
|
||||||
|
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,8 +133,13 @@ When code changes (features or bug fixes) are complete:
|
|||||||
|
|
||||||
### Step 1: Verify Readiness
|
### Step 1: Verify Readiness
|
||||||
|
|
||||||
1. Check for uncommitted changes: `git status`
|
1. Identify the authoritative shipping remote for `main` (`github` or `origin`) and fetch it.
|
||||||
2. Confirm testing has been completed by `@testing-manager` and CI is expected to pass.
|
2. Check for uncommitted changes: `git status`.
|
||||||
|
3. Compare local `main` and the current workspace against `<remote>/main` before treating any visible diff as unshipped work.
|
||||||
|
4. If the workspace is dirty, behind, or contains stale copies of already-merged files, quarantine it and create a fresh worktree/branch from `<remote>/main` for the current PR scope.
|
||||||
|
5. Confirm testing has been completed by `@testing-manager`.
|
||||||
|
6. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
|
||||||
|
7. Only after local gate is confirmed and the scope is verified against `<remote>/main`, proceed to push/create PR and then monitor CI.
|
||||||
|
|
||||||
### Step 2: Create Feature Branch
|
### Step 2: Create Feature Branch
|
||||||
|
|
||||||
@@ -127,11 +147,13 @@ When code changes (features or bug fixes) are complete:
|
|||||||
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
|
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
|
||||||
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
|
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
|
||||||
- Chore: `chore/short-description`
|
- Chore: `chore/short-description`
|
||||||
2. Create and switch to the branch:
|
2. Create the branch from a clean base that matches `<remote>/main`. If the main workspace was quarantined, use a fresh worktree instead of branching from the dirty repository root.
|
||||||
|
3. Create and switch to the branch:
|
||||||
```bash
|
```bash
|
||||||
git checkout -b feat/short-description
|
git checkout -b feat/short-description
|
||||||
```
|
```
|
||||||
3. Stage and commit changes with a conventional commit message:
|
4. Move only the intended scope into that branch/worktree. Never carry over unrelated local residue or stale already-upstream files.
|
||||||
|
5. Stage and commit changes with a conventional commit message:
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "fix: short description of what was fixed"
|
git commit -m "fix: short description of what was fixed"
|
||||||
@@ -140,39 +162,29 @@ When code changes (features or bug fixes) are complete:
|
|||||||
|
|
||||||
### Step 3: Push and Create PR
|
### Step 3: Push and Create PR
|
||||||
|
|
||||||
1. Push the branch:
|
1. Re-check local gate status before push/PR creation (lint + relevant local tests green).
|
||||||
|
2. Push the branch:
|
||||||
```bash
|
```bash
|
||||||
git push -u origin feat/short-description
|
git push -u origin feat/short-description
|
||||||
```
|
```
|
||||||
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
3. Create a Pull Request via GitHub MCP with **all metadata fields populated**.
|
||||||
```bash
|
- Set the title to the conventional change summary (for example `fix: short description`).
|
||||||
gh pr create \
|
- Set the body to include `Closes #<ISSUE_NUMBER>` plus a short description of changes.
|
||||||
--title "fix: short description" \
|
- Set assignee to `DanielVolz`.
|
||||||
--body "Closes #<ISSUE_NUMBER>
|
- Set the label to match the change type (`enhancement`, `bug`, or `documentation`).
|
||||||
|
- Link the PR to `@DanielVolz's MedAssist-ng project`.
|
||||||
Description of changes" \
|
|
||||||
--assignee DanielVolz \
|
|
||||||
--label bug \
|
|
||||||
--project "@DanielVolz's MedAssist-ng project"
|
|
||||||
```
|
|
||||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
|
||||||
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||||
- The `--project` flag links the PR to the Project board.
|
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
|
||||||
3. **Present the PR URL to the user and wait for confirmation.**
|
4. **Present the PR URL to the user and wait for confirmation.**
|
||||||
|
|
||||||
### Step 4: Wait for CI and Merge
|
### Step 4: Wait for CI and Merge
|
||||||
|
|
||||||
1. Monitor CI status:
|
1. Monitor CI status via GitHub MCP until all required checks complete.
|
||||||
```bash
|
|
||||||
gh pr checks <PR_NUMBER> --watch
|
|
||||||
```
|
|
||||||
Required checks: all repository-required checks must pass.
|
Required checks: all repository-required checks must pass.
|
||||||
2. If CI fails: analyze the failure, fix it, push again, and re-check.
|
2. If CI fails: analyze the failure, fix it, push again, and re-check.
|
||||||
3. Once CI is green, **ask the user for merge confirmation**, then:
|
3. Once CI is green, **ask the user for merge confirmation**, then merge the PR via GitHub MCP using squash merge and branch deletion.
|
||||||
```bash
|
4. Re-sync the authoritative local `main` before using it again as a source of truth for any next PR or release step. Do not continue from a previously dirty workspace without another source-of-truth audit.
|
||||||
gh pr merge <PR_NUMBER> --squash --delete-branch
|
5. Switch back to main and pull:
|
||||||
```
|
|
||||||
4. Switch back to main and pull:
|
|
||||||
```bash
|
```bash
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull origin main
|
git pull origin main
|
||||||
@@ -241,6 +253,8 @@ The script performs these steps in order:
|
|||||||
6. Merges the PR (squash + delete branch)
|
6. Merges the PR (squash + delete branch)
|
||||||
7. Creates a signed tag `vX.Y.Z` and pushes it
|
7. Creates a signed tag `vX.Y.Z` and pushes it
|
||||||
|
|
||||||
|
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
|
||||||
|
|
||||||
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
|
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
|
||||||
|
|
||||||
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
|
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
|
||||||
@@ -385,10 +399,7 @@ Existing installations need to:
|
|||||||
|
|
||||||
### Step 3: Publish
|
### Step 3: Publish
|
||||||
|
|
||||||
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish via:
|
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish the release via GitHub MCP.
|
||||||
```bash
|
|
||||||
gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -438,30 +449,15 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
|||||||
|
|
||||||
### Workflow During PRs
|
### Workflow During PRs
|
||||||
|
|
||||||
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one:
|
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
|
||||||
```bash
|
|
||||||
gh issue create --title "fix: description" --label bug
|
|
||||||
```
|
|
||||||
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
||||||
|
|
||||||
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
||||||
|
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
|
||||||
|
|
||||||
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify issue/project status via GitHub MCP.
|
||||||
```bash
|
|
||||||
GH_PAGER=cat gh issue view <ISSUE_NUMBER> --json state,projectItems --jq '{state, projects: [.projectItems[] | {title: .title, status: .status.name}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual fallback** — if the workflow fails or the item wasn't moved, use GraphQL:
|
**Manual fallback** — if the workflow fails or the item wasn't moved, use GitHub MCP GraphQL/project mutation support with the project/item/field IDs below.
|
||||||
```bash
|
|
||||||
GH_PAGER=cat gh api graphql -f query='mutation {
|
|
||||||
updateProjectV2ItemFieldValue(input: {
|
|
||||||
projectId: "PVT_kwHOADH82s4BO2OT"
|
|
||||||
itemId: "<ITEM_ID>"
|
|
||||||
fieldId: "PVTSSF_lAHOADH82s4BO2OTzg9bdkE"
|
|
||||||
value: { singleSelectOptionId: "ca45af98" }
|
|
||||||
}) { projectV2Item { id } }
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Known Project field IDs (Status):**
|
**Known Project field IDs (Status):**
|
||||||
| Status | Option ID |
|
| Status | Option ID |
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
|||||||
|
|
||||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
||||||
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
||||||
|
- **Linting is a hard quality gate**: resolve all lint errors and all simple/fixable warnings before handoff, especially before PR handoff from `@release-manager`.
|
||||||
|
- **Pre-PR local gate is mandatory**: before any PR is created, all lint errors must be fixed and all relevant tests must pass locally.
|
||||||
|
- **No CI-first failures**: tests must fail locally when broken and be fixed locally before PR handoff; do not rely on GitHub CI to discover obvious regressions.
|
||||||
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
||||||
|
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
|
||||||
|
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
|
||||||
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
||||||
|
- **Use GitHub MCP for all GitHub workflow/PR inspection. Never use `gh` CLI.** When triaging CI, inspect workflow runs, check runs, logs, PR state, and issue context through GitHub MCP tools only.
|
||||||
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
||||||
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
||||||
|
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
|
||||||
|
- **Regression prevention is mandatory**: every fixed bug must get a deterministic regression test that fails before the fix and passes after it.
|
||||||
|
|
||||||
## CI/CD Ownership Boundary
|
## CI/CD Ownership Boundary
|
||||||
|
|
||||||
@@ -27,9 +35,10 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
|||||||
|
|
||||||
## Test Stack & Locations
|
## Test Stack & Locations
|
||||||
|
|
||||||
- **Backend**: Vitest 2.1 + v8 coverage
|
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
|
||||||
- **Frontend unit/integration**: Vitest
|
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
|
||||||
- **E2E**: Playwright
|
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
|
||||||
|
- **Static quality gates**: TypeScript via `tsc --noEmit` and Biome via `npx biome check .`
|
||||||
|
|
||||||
Primary locations:
|
Primary locations:
|
||||||
|
|
||||||
@@ -37,37 +46,81 @@ Primary locations:
|
|||||||
- Frontend tests: `frontend/src/test/**`
|
- Frontend tests: `frontend/src/test/**`
|
||||||
- Playwright E2E: `frontend/e2e/**`
|
- Playwright E2E: `frontend/e2e/**`
|
||||||
|
|
||||||
|
## Testing Strategy Defaults
|
||||||
|
|
||||||
|
- **Default to targeted validation, not shotgun runs**: start with the smallest test command that exercises the changed behavior.
|
||||||
|
- **Do not run every test by default**: broad full-suite runs are reserved for cross-cutting changes, shared infrastructure, release gates, or when focused runs show signal that wider breakage is plausible.
|
||||||
|
- **Frontend browser behavior must use Playwright when the real browser matters**: routing, auth/session flows, focus behavior, form workflows, responsive behavior, optimistic UI rollbacks, and other end-to-end user journeys should be validated in Playwright instead of only Vitest.
|
||||||
|
- **Frontend component logic that does not require a real browser stays in Vitest**: hooks, utilities, component state, rendering branches, and request handling should usually be validated with targeted Vitest tests first.
|
||||||
|
- **Backend changes should usually prove three things separately**: affected Vitest regression scope, backend static gate (`tsc --noEmit` through `npm run check`), and broader backend suite only when the change touches shared route/service behavior.
|
||||||
|
- **Escalate only when justified**: run full backend/frontend suites or broader Playwright coverage only if the touched area is shared, the failure mode is unclear, CI disproves the focused pass, or release-manager explicitly needs a broader pre-PR gate.
|
||||||
|
|
||||||
## Required Test Workflow
|
## Required Test Workflow
|
||||||
|
|
||||||
1. Identify changed behavior and expected outcomes.
|
1. Identify changed behavior and expected outcomes.
|
||||||
2. Add/update tests near the affected feature.
|
2. Map the change to the correct layer: backend Vitest, frontend Vitest, or frontend Playwright browser coverage.
|
||||||
3. Run the smallest relevant subset first.
|
3. Add/update tests near the affected feature.
|
||||||
4. Expand to broader suites if subset passes.
|
4. Run the smallest relevant subset first.
|
||||||
5. Report what was run, what passed, and any remaining known failures.
|
5. Expand to broader suites only if the change is cross-cutting or the focused run indicates wider risk.
|
||||||
|
6. Run lint + required local test/build gates before PR handoff.
|
||||||
|
7. Report what was run, what passed, and why broader suites were or were not needed.
|
||||||
|
|
||||||
|
## Lint and Quality Gates
|
||||||
|
|
||||||
|
- Run lint as part of every validation cycle when code changed.
|
||||||
|
- Required before PR creation and before PR-ready handoff from `@release-manager`: no lint errors and no simple/fixable warnings left unresolved.
|
||||||
|
- If lint fails, fix root causes first, then re-run affected tests.
|
||||||
|
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
|
||||||
|
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
|
||||||
|
- Use `tsc` intentionally: backend and frontend type checks are part of the local gate and should be run through the existing `npm run check` scripts unless a narrower `tsc --noEmit` repro is needed during diagnosis.
|
||||||
|
|
||||||
|
Recommended commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
cd backend && npm run check
|
||||||
|
cd frontend && npm run check
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && CI=true npm test
|
cd backend && npx tsc --version
|
||||||
|
cd backend && npx vitest --version
|
||||||
|
cd backend && CI=true npm run test:run -- src/test/doses.test.ts
|
||||||
|
cd backend && CI=true npm run test:run
|
||||||
cd backend && CI=true npm run test:coverage
|
cd backend && CI=true npm run test:coverage
|
||||||
cd backend && CI=true npm test -- -t "test name"
|
cd backend && CI=true npm run test:run -- src/test/doses.test.ts src/test/integration.test.ts
|
||||||
|
cd backend && CI=true npm run test:run -- -t "test name"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && CI=true npm test
|
cd frontend && npx tsc --version
|
||||||
|
cd frontend && npx vitest --version
|
||||||
|
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx
|
||||||
|
cd frontend && CI=true npm run test:run
|
||||||
|
cd frontend && CI=true npm run test:coverage
|
||||||
|
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx src/test/hooks/useDoses.test.ts
|
||||||
|
cd frontend && CI=true npm run test:run -- -t "test name"
|
||||||
cd frontend && npm run lint
|
cd frontend && npm run lint
|
||||||
|
cd frontend && npm run check
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Playwright E2E
|
### Playwright E2E
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm run test:e2e
|
cd frontend && npx playwright --version
|
||||||
cd frontend && npm run test:e2e -- --project=chromium
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --grep "schedule"
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- frontend/e2e/schedule.spec.ts
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
|
||||||
# Never use interactive UI/headed/report-server commands in agent runs.
|
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||||
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||||
```
|
```
|
||||||
@@ -78,13 +131,31 @@ cd frontend && npm run test:e2e -- --project=chromium
|
|||||||
- Validate both status codes and response payloads.
|
- Validate both status codes and response payloads.
|
||||||
- Add regression tests for every fixed bug.
|
- Add regression tests for every fixed bug.
|
||||||
- Keep tests deterministic and isolated.
|
- Keep tests deterministic and isolated.
|
||||||
|
- Validate observable behavior, not implementation details.
|
||||||
|
|
||||||
## E2E Test Patterns
|
## E2E Test Patterns
|
||||||
|
|
||||||
- Use stable selectors and explicit assertions.
|
- Use stable selectors and explicit assertions.
|
||||||
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
|
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
|
||||||
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
|
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
|
||||||
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
|
- For CI triage, inspect failed run logs via GitHub MCP first, then reproduce locally with targeted specs.
|
||||||
|
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
|
||||||
|
- Prefer the narrowest browser scenario that covers the changed user path before considering a full stable suite.
|
||||||
|
|
||||||
|
## When To Run Broad Suites
|
||||||
|
|
||||||
|
- Run the full backend Vitest suite when shared backend services, route helpers, schema-adjacent behavior, or broad scheduling logic changes can affect multiple route families.
|
||||||
|
- Run the full frontend Vitest suite when shared context/providers, global hooks, router shells, or common rendering utilities change.
|
||||||
|
- Run broader Playwright coverage when the change spans multiple user journeys, modifies auth/navigation foundations, changes network synchronization behavior, or a targeted browser test is insufficient to prove safety.
|
||||||
|
- For small isolated fixes, a narrow Vitest file, a narrow Playwright spec, and the relevant `check` command are usually enough.
|
||||||
|
|
||||||
|
## Test Validity Checklist
|
||||||
|
|
||||||
|
- The test fails when the real target logic is intentionally broken.
|
||||||
|
- The assertion verifies functional behavior, not just mocked calls.
|
||||||
|
- Mocks/stubs are minimal and do not replace the unit under test.
|
||||||
|
- The test is deterministic across repeated local and CI runs.
|
||||||
|
- The test protects against the specific regression that was fixed.
|
||||||
|
|
||||||
## CI Failure Triage
|
## CI Failure Triage
|
||||||
|
|
||||||
@@ -115,6 +186,9 @@ When test checks fail:
|
|||||||
Testing work is complete when:
|
Testing work is complete when:
|
||||||
|
|
||||||
- Required tests exist and validate intended behavior.
|
- Required tests exist and validate intended behavior.
|
||||||
|
- Tests are proven valid (not fake-green) and reliable.
|
||||||
|
- Lint is clean: no errors and no simple/fixable warnings left.
|
||||||
|
- Pre-PR local gate passed: lint and all relevant tests pass locally before handoff for PR creation.
|
||||||
- Relevant local test commands pass.
|
- Relevant local test commands pass.
|
||||||
- CI test failures are resolved or clearly documented with rationale.
|
- CI test failures are resolved or clearly documented with rationale.
|
||||||
- No temporary debugging files remain in the workspace.
|
- No temporary debugging files remain in the workspace.
|
||||||
|
|||||||
@@ -1,77 +1,19 @@
|
|||||||
# MedAssist-ng - AI Coding Instructions
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## Purpose
|
## VERY IMPORTANT
|
||||||
|
|
||||||
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
## Project Orientation (Read First)
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
|
## Required Startup Steps
|
||||||
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
|
|
||||||
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
|
|
||||||
- **Primary backend modules**:
|
|
||||||
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
|
|
||||||
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
|
|
||||||
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
|
|
||||||
- **Primary frontend modules**:
|
|
||||||
- Pages: `frontend/src/pages/`
|
|
||||||
- Shared app state: `frontend/src/context/AppContext.tsx`
|
|
||||||
- Domain hooks: `frontend/src/hooks/`
|
|
||||||
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
|
|
||||||
|
|
||||||
Use this orientation for quick navigation before applying the rules below.
|
1. Read `AGENTS.md` first.
|
||||||
|
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
||||||
|
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||||
|
|
||||||
## Always-On Rules
|
## Scope
|
||||||
|
|
||||||
- English only for project artifacts.
|
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
||||||
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
|
|
||||||
- Testing work belongs to `@testing-manager`.
|
|
||||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
|
||||||
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
|
||||||
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
|
|
||||||
- Remove obsolete code when re-implementing — never leave dead code behind.
|
|
||||||
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
|
||||||
|
|
||||||
## MedAssist Essentials
|
|
||||||
|
|
||||||
- Frontend calls backend through `/api/*`.
|
|
||||||
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Skills (MANDATORY — read before every task)
|
|
||||||
|
|
||||||
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
|
|
||||||
|
|
||||||
| Skill | Trigger | File |
|
|
||||||
|---|---|---|
|
|
||||||
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
|
|
||||||
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
|
|
||||||
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
|
|
||||||
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
|
|
||||||
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
|
|
||||||
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
|
|
||||||
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
|
|
||||||
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
|
|
||||||
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
|
|
||||||
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
|
|
||||||
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
|
|
||||||
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
|
|
||||||
|
|
||||||
### Non-negotiable parity rules (always apply)
|
|
||||||
|
|
||||||
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
|
|
||||||
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Delegation
|
|
||||||
|
|
||||||
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
|
|
||||||
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
|
|
||||||
|
|
||||||
## Key References
|
|
||||||
|
|
||||||
- Canonical governance: `AGENTS.md`
|
|
||||||
- Skill files: `.github/skills/*/SKILL.md`
|
|
||||||
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:20"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "backend"
|
||||||
groups:
|
groups:
|
||||||
minor-and-patch:
|
minor-and-patch:
|
||||||
update-types:
|
update-types:
|
||||||
@@ -22,9 +24,11 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:10"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "frontend"
|
||||||
groups:
|
groups:
|
||||||
minor-and-patch:
|
minor-and-patch:
|
||||||
update-types:
|
update-types:
|
||||||
@@ -37,9 +41,16 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:00"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "root"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
# GitHub Actions
|
# GitHub Actions
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
@@ -47,7 +58,13 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:30"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "ci"
|
- "ci"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
|
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
|
||||||
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
|
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
|
||||||
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
|
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
|
||||||
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
|
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: medassist-karpathy-core
|
||||||
|
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill Instructions
|
||||||
|
|
||||||
|
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
|
||||||
|
|
||||||
|
## Use When
|
||||||
|
|
||||||
|
- The request is ambiguous and assumptions must be made explicit.
|
||||||
|
- The change can easily balloon in scope.
|
||||||
|
- A bug fix or feature needs explicit success criteria and verification.
|
||||||
|
- You need to keep diffs minimal and directly tied to the request.
|
||||||
|
|
||||||
|
## Do Not Use When
|
||||||
|
|
||||||
|
- The task is trivial and can be completed safely without extra process overhead.
|
||||||
|
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
|
||||||
|
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Think Before Coding
|
||||||
|
|
||||||
|
- Do not assume silently.
|
||||||
|
- State assumptions explicitly.
|
||||||
|
- If multiple interpretations exist, present them instead of picking one invisibly.
|
||||||
|
- If uncertain or blocked by ambiguity, stop and ask.
|
||||||
|
- If a simpler approach exists, call it out.
|
||||||
|
|
||||||
|
### 2. Simplicity First
|
||||||
|
|
||||||
|
- Implement the minimum code required to solve the asked problem.
|
||||||
|
- Do not add speculative features, abstractions, or configurability.
|
||||||
|
- Avoid defensive handling for impossible scenarios.
|
||||||
|
- If the solution feels overcomplicated, simplify before finalizing.
|
||||||
|
|
||||||
|
### 3. Surgical Changes
|
||||||
|
|
||||||
|
- Touch only lines required for the request.
|
||||||
|
- Do not refactor unrelated areas.
|
||||||
|
- Match existing local style and patterns.
|
||||||
|
- Remove only unused code introduced by your own change.
|
||||||
|
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
|
||||||
|
|
||||||
|
### 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
- Translate requests into verifiable outcomes before implementation.
|
||||||
|
- For multi-step tasks, define short steps with checks.
|
||||||
|
- Verify the requested behavior explicitly before declaring done.
|
||||||
|
|
||||||
|
Example execution frame:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. [Step] -> verify: [check]
|
||||||
|
2. [Step] -> verify: [check]
|
||||||
|
3. [Step] -> verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
When this skill is used, report briefly:
|
||||||
|
|
||||||
|
- Assumptions made (or clarifications requested)
|
||||||
|
- Why the chosen approach is the simplest viable one
|
||||||
|
- What was changed (and what was intentionally not changed)
|
||||||
|
- Verification performed and result
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
|
|||||||
- Avoid custom inline modal/button patterns that diverge from project design.
|
- Avoid custom inline modal/button patterns that diverge from project design.
|
||||||
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
||||||
|
|
||||||
|
### Modal requirements (non-negotiable)
|
||||||
|
|
||||||
|
Every modal/overlay **must** follow these rules:
|
||||||
|
|
||||||
|
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
|
||||||
|
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
|
||||||
|
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
|
||||||
|
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
|
||||||
|
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
|
||||||
|
|
||||||
## Decision Heuristics
|
## Decision Heuristics
|
||||||
|
|
||||||
1. If an equivalent component exists, reuse it.
|
1. If an equivalent component exists, reuse it.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Close inactive issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Mark and close stale issues
|
||||||
|
uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
days-before-issue-stale: 30
|
||||||
|
days-before-issue-close: 14
|
||||||
|
stale-issue-label: stale
|
||||||
|
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||||
|
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
exempt-issue-labels: pinned,security
|
||||||
|
operations-per-run: 200
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Dependabot Automerge
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
enable-automerge:
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Read Dependabot metadata
|
||||||
|
id: metadata
|
||||||
|
uses: dependabot/fetch-metadata@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Enable auto-merge for safe updates
|
||||||
|
if: >-
|
||||||
|
(steps.metadata.outputs.package-ecosystem == 'npm' ||
|
||||||
|
steps.metadata.outputs.package-ecosystem == 'github_actions') &&
|
||||||
|
(steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
|
||||||
|
steps.metadata.outputs.update-type == 'version-update:semver-patch')
|
||||||
|
uses: peter-evans/enable-pull-request-automerge@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pull-request-number: ${{ github.event.pull_request.number }}
|
||||||
|
merge-method: squash
|
||||||
@@ -4,12 +4,27 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'docker-compose.dev.yml'
|
||||||
|
- '.github/workflows/docker-build.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Image tag (leave empty for "latest")'
|
description: 'Image/release tag (e.g. v1.19.1 or latest)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
create_release:
|
||||||
|
description: 'Create GitHub release entry (requires tag starting with v)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
# Default minimal permissions
|
# Default minimal permissions
|
||||||
permissions:
|
permissions:
|
||||||
@@ -48,10 +63,10 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -59,7 +74,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
|
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -70,7 +85,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
push: true
|
push: true
|
||||||
@@ -83,12 +98,12 @@ jobs:
|
|||||||
sbom: false
|
sbom: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Create GitHub Release (only on tag push)
|
# Create GitHub Release (on tag push or manual dispatch with create_release)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -98,10 +113,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch all history for changelog generation
|
fetch-depth: 0 # Fetch all history for changelog generation
|
||||||
|
|
||||||
|
- name: Resolve current tag
|
||||||
|
id: current_tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
CURRENT_TAG="${{ github.event.inputs.tag }}"
|
||||||
|
else
|
||||||
|
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CURRENT_TAG" ]; then
|
||||||
|
echo "Release tag is required. Provide workflow_dispatch input 'tag'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CURRENT_TAG" != v* ]]; then
|
||||||
|
echo "Release tag must start with 'v' (example: v1.19.1)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Check if release exists
|
- name: Check if release exists
|
||||||
id: check_release
|
id: check_release
|
||||||
run: |
|
run: |
|
||||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||||
if gh release view "$CURRENT_TAG" &>/dev/null; then
|
if gh release view "$CURRENT_TAG" &>/dev/null; then
|
||||||
echo "exists=true" >> $GITHUB_OUTPUT
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
echo "Release $CURRENT_TAG already exists, skipping creation"
|
echo "Release $CURRENT_TAG already exists, skipping creation"
|
||||||
@@ -115,25 +151,36 @@ jobs:
|
|||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
id: prev_tag
|
id: prev_tag
|
||||||
run: |
|
run: |
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true)
|
||||||
|
else
|
||||||
|
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
|
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||||
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
||||||
|
|
||||||
echo "## What's Changed" > changelog.md
|
echo "## What's New" > changelog.md
|
||||||
|
echo "" >> changelog.md
|
||||||
|
echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md
|
||||||
|
echo "" >> changelog.md
|
||||||
|
echo "### Highlights" >> changelog.md
|
||||||
echo "" >> changelog.md
|
echo "" >> changelog.md
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ]; then
|
if [ -n "$PREV_TAG" ]; then
|
||||||
# Get commits between tags
|
echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md
|
||||||
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md
|
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md
|
||||||
else
|
else
|
||||||
# First release - get recent commits
|
echo "Recent shipped commits:" >> changelog.md
|
||||||
git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md
|
git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "" >> changelog.md
|
echo "" >> changelog.md
|
||||||
@@ -151,6 +198,8 @@ jobs:
|
|||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
tag_name: ${{ steps.current_tag.outputs.value }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
body_path: changelog.md
|
body_path: changelog.md
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
draft: false
|
draft: false
|
||||||
|
|||||||
@@ -3,18 +3,33 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- '.github/workflows/e2e.yml'
|
|
||||||
|
|
||||||
# Minimal permissions for security
|
# Minimal permissions for security
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
changes:
|
||||||
|
name: Detect E2E relevance
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
outputs:
|
||||||
|
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
|
||||||
|
steps:
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
e2e_relevant:
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'backend/**'
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: Playwright E2E
|
name: Playwright E2E
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.e2e_relevant == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
permissions:
|
permissions:
|
||||||
@@ -50,11 +65,13 @@ jobs:
|
|||||||
run: npx playwright test --project=chromium
|
run: npx playwright test --project=chromium
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
PLAYWRIGHT_WORKERS: 1
|
||||||
|
PLAYWRIGHT_HTML_OPEN: never
|
||||||
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
||||||
SESSION_SECRET: e2e-test-session-secret-long-enough
|
SESSION_SECRET: e2e-test-session-secret-long-enough
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
@@ -62,7 +79,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-results
|
name: playwright-results
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
name: Sync Project Fields
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, labeled, unlabeled, reopened]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-fields:
|
||||||
|
name: Sync Type/Priority fields from labels
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Sync fields
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
|
script: |
|
||||||
|
const projectId = 'PVT_kwHOADH82s4BO2OT';
|
||||||
|
const issueNodeId = context.payload.issue.node_id;
|
||||||
|
const issueNumber = context.payload.issue.number;
|
||||||
|
const labels = (context.payload.issue.labels || []).map(l => l.name.toLowerCase());
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const getProjectItem = async () => {
|
||||||
|
const data = await github.graphql(`
|
||||||
|
query($nodeId: ID!) {
|
||||||
|
node(id: $nodeId) {
|
||||||
|
... on Issue {
|
||||||
|
projectItems(first: 20) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
project { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { nodeId: issueNodeId });
|
||||||
|
|
||||||
|
const items = data.node?.projectItems?.nodes || [];
|
||||||
|
return items.find(item => item.project.id === projectId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let projectItem = await getProjectItem();
|
||||||
|
|
||||||
|
// add-to-project may run in parallel; retry briefly before giving up
|
||||||
|
for (let i = 0; !projectItem && i < 6; i++) {
|
||||||
|
console.log(`Issue #${issueNumber} not in project yet. Retry ${i + 1}/6...`);
|
||||||
|
await sleep(10000);
|
||||||
|
projectItem = await getProjectItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectItem) {
|
||||||
|
console.log(`Issue #${issueNumber} is not in project board. Skipping field sync.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsData = await github.graphql(`
|
||||||
|
query($projectId: ID!) {
|
||||||
|
node(id: $projectId) {
|
||||||
|
... on ProjectV2 {
|
||||||
|
fields(first: 50) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { projectId });
|
||||||
|
|
||||||
|
const singleSelectFields = fieldsData.node?.fields?.nodes || [];
|
||||||
|
const byName = new Map(singleSelectFields.map(f => [f.name, f]));
|
||||||
|
|
||||||
|
const typeField = byName.get('Type');
|
||||||
|
const priorityField = byName.get('Priority');
|
||||||
|
|
||||||
|
if (!typeField && !priorityField) {
|
||||||
|
console.log('Neither Type nor Priority field found. Nothing to update.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickOptionId = (field, optionName) => {
|
||||||
|
if (!field || !optionName) return null;
|
||||||
|
const opt = (field.options || []).find(o => o.name.toLowerCase() === optionName.toLowerCase());
|
||||||
|
return opt?.id || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let typeName = null;
|
||||||
|
if (labels.includes('bug')) typeName = 'Bug';
|
||||||
|
else if (labels.includes('enhancement')) typeName = 'Feature';
|
||||||
|
else if (labels.includes('documentation')) typeName = 'Documentation';
|
||||||
|
|
||||||
|
let priorityName = null;
|
||||||
|
if (labels.includes('priority/high')) priorityName = 'High';
|
||||||
|
else if (labels.includes('priority/low')) priorityName = 'Low';
|
||||||
|
else if (labels.includes('priority/medium')) priorityName = 'Medium';
|
||||||
|
else if (labels.includes('triage')) priorityName = 'Medium';
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const typeOptionId = pickOptionId(typeField, typeName);
|
||||||
|
if (typeField && typeOptionId) {
|
||||||
|
updates.push({ fieldId: typeField.id, optionId: typeOptionId, fieldName: 'Type', valueName: typeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityOptionId = pickOptionId(priorityField, priorityName);
|
||||||
|
if (priorityField && priorityOptionId) {
|
||||||
|
updates.push({ fieldId: priorityField.id, optionId: priorityOptionId, fieldName: 'Priority', valueName: priorityName });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
await github.graphql(`
|
||||||
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||||
|
updateProjectV2ItemFieldValue(input: {
|
||||||
|
projectId: $projectId
|
||||||
|
itemId: $itemId
|
||||||
|
fieldId: $fieldId
|
||||||
|
value: { singleSelectOptionId: $optionId }
|
||||||
|
}) {
|
||||||
|
projectV2Item { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
projectId,
|
||||||
|
itemId: projectItem.id,
|
||||||
|
fieldId: update.fieldId,
|
||||||
|
optionId: update.optionId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Issue #${issueNumber}: set ${update.fieldName} = ${update.valueName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
console.log(`Issue #${issueNumber}: no matching field updates for labels [${labels.join(', ')}]`);
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: npm run test:coverage
|
run: npm run test:coverage
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Weekly Triage Report
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 7 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weekly-report:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Build weekly summary
|
||||||
|
id: summary
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
const weekLabel = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const q = async (query) => {
|
||||||
|
const res = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 });
|
||||||
|
return res.data.total_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openIssues = await q(`repo:${owner}/${repo} is:issue is:open`);
|
||||||
|
const newIssues = await q(`repo:${owner}/${repo} is:issue created:>=${since}`);
|
||||||
|
const bugs = await q(`repo:${owner}/${repo} is:issue is:open label:bug`);
|
||||||
|
const enhancements = await q(`repo:${owner}/${repo} is:issue is:open label:enhancement`);
|
||||||
|
const triage = await q(`repo:${owner}/${repo} is:issue is:open label:triage`);
|
||||||
|
const stale = await q(`repo:${owner}/${repo} is:issue is:open label:stale`);
|
||||||
|
const unassigned = await q(`repo:${owner}/${repo} is:issue is:open no:assignee`);
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`## Weekly Triage Report (${weekLabel})`,
|
||||||
|
'',
|
||||||
|
`- Open issues: **${openIssues}**`,
|
||||||
|
`- New issues (last 7 days): **${newIssues}**`,
|
||||||
|
`- Open bugs: **${bugs}**`,
|
||||||
|
`- Open enhancements: **${enhancements}**`,
|
||||||
|
`- In triage: **${triage}**`,
|
||||||
|
`- Stale: **${stale}**`,
|
||||||
|
`- Unassigned: **${unassigned}**`,
|
||||||
|
'',
|
||||||
|
'### Quick Links',
|
||||||
|
`- Triage queue: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Atriage`,
|
||||||
|
`- Stale issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Astale`,
|
||||||
|
`- Unassigned issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
core.setOutput('title', `Weekly Triage Report - ${weekLabel}`);
|
||||||
|
core.setOutput('body', body);
|
||||||
|
|
||||||
|
- name: Publish report issue
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const title = `${{ steps.summary.outputs.title }}`;
|
||||||
|
const body = `${{ steps.summary.outputs.body }}`;
|
||||||
|
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
labels: ['triage']
|
||||||
|
});
|
||||||
+7
-1
@@ -79,6 +79,12 @@ Thumbs.db
|
|||||||
.turbo/
|
.turbo/
|
||||||
.roo/
|
.roo/
|
||||||
.roomodes
|
.roomodes
|
||||||
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku/
|
||||||
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
plan/
|
||||||
|
.copilot-tracking/
|
||||||
|
.playwright-cli/
|
||||||
Vendored
+87
-48
@@ -1,49 +1,88 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "E2E stable",
|
"label": "E2E stable",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm",
|
"command": "npm",
|
||||||
"args": ["run", "test:e2e"],
|
"args": [
|
||||||
"options": {
|
"run",
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"test:e2e"
|
||||||
},
|
],
|
||||||
"group": "test",
|
"options": {
|
||||||
"problemMatcher": []
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
},
|
},
|
||||||
{
|
"group": "test",
|
||||||
"label": "E2E stable + merged video",
|
"problemMatcher": []
|
||||||
"type": "shell",
|
},
|
||||||
"command": "npm",
|
{
|
||||||
"args": ["run", "test:e2e:with-video"],
|
"label": "E2E stable + merged video",
|
||||||
"options": {
|
"type": "shell",
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"command": "npm",
|
||||||
},
|
"args": [
|
||||||
"group": "test",
|
"run",
|
||||||
"problemMatcher": []
|
"test:e2e:with-video"
|
||||||
},
|
],
|
||||||
{
|
"options": {
|
||||||
"label": "E2E all browsers",
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
"type": "shell",
|
},
|
||||||
"command": "npm",
|
"group": "test",
|
||||||
"args": ["run", "test:e2e:all"],
|
"problemMatcher": []
|
||||||
"options": {
|
},
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
{
|
||||||
},
|
"label": "E2E all browsers",
|
||||||
"group": "test",
|
"type": "shell",
|
||||||
"problemMatcher": []
|
"command": "npm",
|
||||||
},
|
"args": [
|
||||||
{
|
"run",
|
||||||
"label": "E2E all browsers + merged video",
|
"test:e2e:all"
|
||||||
"type": "shell",
|
],
|
||||||
"command": "npm",
|
"options": {
|
||||||
"args": ["run", "test:e2e:all:with-video"],
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
"options": {
|
},
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"group": "test",
|
||||||
},
|
"problemMatcher": []
|
||||||
"group": "test",
|
},
|
||||||
"problemMatcher": []
|
{
|
||||||
}
|
"label": "E2E all browsers + merged video",
|
||||||
]
|
"type": "shell",
|
||||||
}
|
"command": "npm",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"test:e2e:all:with-video"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E stable non-interactive",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
|
||||||
|
"isBackground": false,
|
||||||
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Targeted frontend vitest",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm test -- --run src/test/context/AppContext.test.tsx src/test/utils/schedule.test.ts",
|
||||||
|
"isBackground": false,
|
||||||
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Focused frontend shared schedule test",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run test:run -- --maxWorkers=1 src/test/components/SharedSchedule.test.tsx",
|
||||||
|
"isBackground": false,
|
||||||
|
"group": "test"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "PR3 targeted validation",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "git --no-pager diff --check -- .github/agents/release-manager.agent.md .github/agents/testing-manager.agent.md .gitignore .vscode/tasks.json && node -e \"JSON.parse(require('fs').readFileSync('.vscode/tasks.json','utf8')); console.log('tasks.json valid')\"",
|
||||||
|
"isBackground": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react" alt="React 18" />
|
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
|
||||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
|
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
|
||||||
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
|
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
|
||||||
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
|
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-558%2F558-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-614%2F614-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-776%2F776-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-807%2F807-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
|
|
||||||
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
|
> This app was 100% coded with [Claude Opus 4.6](https://www.anthropic.com/claude) and [GPT-5.3 Codex](https://openai.com/index/gpt-5/). Use at your own risk.
|
||||||
|
|
||||||
### ⚠️ Disclaimer
|
### ⚠️ Disclaimer
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@ Share your medication schedule with others via a public link.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
- Manual stock correction supports partial blisters and loose pills
|
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with pack or loose pill options
|
||||||
@@ -141,7 +141,7 @@ Share your medication schedule with others via a public link.
|
|||||||
- Intake reminders via push notifications
|
- Intake reminders via push notifications
|
||||||
|
|
||||||
### Trip Planner
|
### Trip Planner
|
||||||
- Calculate how many pills you need for a trip or date range
|
- Calculate medication demand for a trip or date range with package-aware units
|
||||||
- Plan ahead for vacations, business trips, or hospital stays
|
- Plan ahead for vacations, business trips, or hospital stays
|
||||||
- Send demand reports via email or push notification
|
- Send demand reports via email or push notification
|
||||||
|
|
||||||
@@ -152,6 +152,7 @@ Share your medication schedule with others via a public link.
|
|||||||
### Multi-Person Support
|
### Multi-Person Support
|
||||||
- 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
|
||||||
|
- Optionally embed the medication overview directly on shared links via a settings toggle
|
||||||
|
|
||||||
### Data Export & Import
|
### Data Export & Import
|
||||||
- Export all your data (medications, dose history, settings) as JSON
|
- Export all your data (medications, dose history, settings) as JSON
|
||||||
@@ -177,7 +178,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
|
|||||||
git clone https://github.com/DanielVolz/medassist-ng.git
|
git clone https://github.com/DanielVolz/medassist-ng.git
|
||||||
cd medassist-ng
|
cd medassist-ng
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose -p medassist-ng up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:4174` and start tracking your medications.
|
Open `http://localhost:4174` and start tracking your medications.
|
||||||
@@ -194,9 +195,24 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
|||||||
| `PGID` | `1000` | Group ID for container file permissions |
|
| `PGID` | `1000` | Group ID for container file permissions |
|
||||||
| `PORT` | `3000` | Backend API port |
|
| `PORT` | `3000` | Backend API port |
|
||||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
|
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
||||||
|
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
|
||||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||||
|
|
||||||
|
Recommended values for API docs by environment:
|
||||||
|
|
||||||
|
| Environment | Recommendation |
|
||||||
|
|-------------|----------------|
|
||||||
|
| Development | `OPENAPI_DOCS_ENABLED=true` |
|
||||||
|
| Staging/Test | `OPENAPI_DOCS_ENABLED=true` |
|
||||||
|
| Production | leave it unset, or set `OPENAPI_DOCS_ENABLED=false` |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- If `OPENAPI_DOCS_ENABLED` is not set, docs are enabled outside production and disabled in production.
|
||||||
|
- If `OPENAPI_DOCS_ENABLED=true`, docs are available on `/docs` and `/docs/json`.
|
||||||
|
- If `OPENAPI_DOCS_ENABLED=false`, only the docs are disabled. The API still works normally.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -211,6 +227,43 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
|||||||
|
|
||||||
Generate secrets with: `openssl rand -hex 32`
|
Generate secrets with: `openssl rand -hex 32`
|
||||||
|
|
||||||
|
### API Keys (Programmatic API Access)
|
||||||
|
|
||||||
|
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer ma_...
|
||||||
|
```
|
||||||
|
|
||||||
|
Available scopes:
|
||||||
|
|
||||||
|
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
|
||||||
|
- `write`: read + write access
|
||||||
|
|
||||||
|
Essential notes:
|
||||||
|
|
||||||
|
- Create keys in the app when authentication is enabled.
|
||||||
|
- The token is shown only once after creation.
|
||||||
|
- Creating a new key automatically deactivates previously active keys for the same user.
|
||||||
|
- API keys are stored hashed in the database.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/settings \
|
||||||
|
-H "Authorization: Bearer ma_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
API reference:
|
||||||
|
|
||||||
|
- Interactive docs: `/docs`
|
||||||
|
- OpenAPI JSON: `/docs/json`
|
||||||
|
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
|
||||||
|
- Key management endpoints for authenticated users:
|
||||||
|
- `GET /auth/api-keys`
|
||||||
|
- `POST /auth/api-keys`
|
||||||
|
- `DELETE /auth/api-keys/:id`
|
||||||
|
|
||||||
### OIDC / SSO
|
### OIDC / SSO
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -250,7 +303,9 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
|
|
||||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
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/).
|
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
|
||||||
|
|
||||||
|
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
|
||||||
|
|
||||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||||
|
|
||||||
@@ -265,9 +320,9 @@ Configure push notifications in Settings → Push, or set defaults via environme
|
|||||||
|
|
||||||
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
|
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
Complete list and details:
|
||||||
|----------|---------|-------------|
|
|
||||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
|
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
||||||
|
|
||||||
#### URL Examples
|
#### URL Examples
|
||||||
|
|
||||||
@@ -288,6 +343,7 @@ Get your keys at [pushover.net](https://pushover.net/):
|
|||||||
**Gotify** (self-hosted):
|
**Gotify** (self-hosted):
|
||||||
```
|
```
|
||||||
gotify://your-server.com/TOKEN
|
gotify://your-server.com/TOKEN
|
||||||
|
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Discord**:
|
**Discord**:
|
||||||
@@ -298,6 +354,7 @@ discord://TOKEN@WEBHOOK_ID
|
|||||||
**Telegram**:
|
**Telegram**:
|
||||||
```
|
```
|
||||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||||
|
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||||
```
|
```
|
||||||
|
|
||||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
@@ -305,11 +362,21 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up
|
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173` (hot reload)
|
- Frontend: `http://localhost:5173` (hot reload)
|
||||||
- Backend: `http://localhost:3000`
|
- Backend: `http://localhost:3000`
|
||||||
|
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||||
|
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||||
|
|
||||||
|
Useful local commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
cd backend && npm run test:run
|
||||||
|
cd frontend && npm run test:run
|
||||||
|
```
|
||||||
|
|
||||||
# Acknowledgements
|
# Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `api_keys` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`name` text(100) NOT NULL,
|
||||||
|
`key_hash` text(128) NOT NULL,
|
||||||
|
`token_prefix` text(24) DEFAULT '' NOT NULL,
|
||||||
|
`scope` text(10) DEFAULT 'write' NOT NULL,
|
||||||
|
`is_active` integer DEFAULT true NOT NULL,
|
||||||
|
`last_used_at` integer,
|
||||||
|
`expires_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP 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 UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,34 @@
|
|||||||
"when": 1771164000000,
|
"when": 1771164000000,
|
||||||
"tag": "0009_add_medication_start_date",
|
"tag": "0009_add_medication_start_date",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771694832866,
|
||||||
|
"tag": "0010_add_dose_tracking_taken_source",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772219947541,
|
||||||
|
"tag": "0011_add_medication_form_lifecycle_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772881208026,
|
||||||
|
"tag": "0012_add_api_keys_and_package_amount_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773348659979,
|
||||||
|
"tag": "0013_add_share_medication_overview",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+911
-170
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.12.0",
|
"version": "1.20.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,22 +25,26 @@
|
|||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/sensible": "^6.0.4",
|
"@fastify/sensible": "^6.0.4",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@fastify/swagger": "^9.7.0",
|
||||||
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.8.2",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.1",
|
"@biomejs/biome": "^2.4.6",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.3.5",
|
||||||
"@types/nodemailer": "^7.0.10",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ async function runMigrations() {
|
|||||||
const migrateResult = await runDrizzleMigrations(db);
|
const migrateResult = await runDrizzleMigrations(db);
|
||||||
if (!migrateResult.success) {
|
if (!migrateResult.success) {
|
||||||
log.error(`[DB] Migration error: ${migrateResult.error}`);
|
log.error(`[DB] Migration error: ${migrateResult.error}`);
|
||||||
} else if (migrateResult.warning) {
|
|
||||||
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
|
|
||||||
} else {
|
|
||||||
log.debug(`[DB] Drizzle migrations completed`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ALTER TABLE migrations for backward compatibility
|
// Run ALTER TABLE migrations for backward compatibility
|
||||||
|
|||||||
@@ -88,13 +88,12 @@ export async function runDrizzleMigrations(
|
|||||||
await migrate(database, { migrationsFolder });
|
await migrate(database, { migrationsFolder });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// If the error is about existing schema objects, the DB is already up-to-date
|
const msg = (err as Error).message ?? "";
|
||||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
// Duplicate column / already exists = DB is already up-to-date (expected for existing DBs)
|
||||||
// or when tables were created before drizzle migrations were introduced
|
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
||||||
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
return { success: true };
|
||||||
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
|
||||||
}
|
}
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, error: msg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +110,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`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
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for intake automation auditability (manual vs automatic taken)
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
`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
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
@@ -124,6 +125,14 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||||
// Added for explicit medication lifecycle start date
|
// Added for explicit medication lifecycle start date
|
||||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||||
|
// Added for form/lifecycle modeling (V1 medication forms)
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN pill_form text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
|
||||||
// Added for more detailed reminder info display
|
// Added for more detailed reminder info display
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||||
@@ -140,6 +149,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
// Added for share stock visibility toggle
|
// Added for share stock visibility toggle
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
|
// Added for integrated share overview visibility on shared links
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
||||||
// Added for timeline visibility toggles (dashboard + shared schedule)
|
// Added for timeline visibility toggles (dashboard + shared schedule)
|
||||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||||
@@ -180,7 +191,21 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
|
// Added in v1.20.x - API key authentication for programmatic access
|
||||||
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL DEFAULT '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'write',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of createTableMigrations) {
|
for (const sql of createTableMigrations) {
|
||||||
@@ -198,6 +223,9 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
const createIndexMigrations = [
|
const createIndexMigrations = [
|
||||||
// Added in v1.6.x - case-insensitive unique usernames
|
// Added in v1.6.x - case-insensitive unique usernames
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
|
// Added in v1.20.x - fast API key lookup and ownership filtering
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of createIndexMigrations) {
|
for (const sql of createIndexMigrations) {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
|
|||||||
genericName: text("generic_name", { length: 100 }),
|
genericName: text("generic_name", { length: 100 }),
|
||||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||||
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
||||||
|
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
|
||||||
|
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
|
||||||
|
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
|
||||||
|
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
|
||||||
|
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
|
||||||
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),
|
||||||
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
|
|||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||||
|
medicationEndDate: text("medication_end_date"),
|
||||||
|
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
@@ -100,6 +109,8 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// Whether shared schedule links also embed the medication overview section
|
||||||
|
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||||
// UI timeline visibility preferences
|
// UI timeline visibility preferences
|
||||||
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
@@ -137,6 +148,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
|
|||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Keys - Personal access tokens for programmatic API access
|
||||||
|
// =============================================================================
|
||||||
|
export const apiKeys = sqliteTable("api_keys", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name", { length: 100 }).notNull(),
|
||||||
|
keyHash: text("key_hash", { length: 128 }).notNull().unique(),
|
||||||
|
tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""),
|
||||||
|
scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write'
|
||||||
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Share Tokens - For public schedule sharing by takenBy person
|
// Share Tokens - For public schedule sharing by takenBy person
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -163,6 +193,7 @@ 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
|
||||||
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ type TranslationKeys = {
|
|||||||
common: {
|
common: {
|
||||||
pill: string;
|
pill: string;
|
||||||
pills: string;
|
pills: string;
|
||||||
|
units: string;
|
||||||
|
ml: string;
|
||||||
blister: string;
|
blister: string;
|
||||||
blisters: string;
|
blisters: string;
|
||||||
day: string;
|
day: string;
|
||||||
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "pill",
|
pill: "pill",
|
||||||
pills: "pills",
|
pills: "pills",
|
||||||
|
units: "units",
|
||||||
|
ml: "ml",
|
||||||
blister: "blister",
|
blister: "blister",
|
||||||
blisters: "blisters",
|
blisters: "blisters",
|
||||||
day: "day",
|
day: "day",
|
||||||
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "Tablette",
|
pill: "Tablette",
|
||||||
pills: "Tabletten",
|
pills: "Tabletten",
|
||||||
|
units: "Einheiten",
|
||||||
|
ml: "ml",
|
||||||
blister: "Blister",
|
blister: "Blister",
|
||||||
blisters: "Blister",
|
blisters: "Blister",
|
||||||
day: "Tag",
|
day: "Tag",
|
||||||
|
|||||||
+105
-4
@@ -1,4 +1,6 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import type { IncomingHttpHeaders } from "node:http";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
@@ -8,10 +10,13 @@ import fastifyMultipart from "@fastify/multipart";
|
|||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import fastifySwagger from "@fastify/swagger";
|
||||||
|
import fastifySwaggerUi from "@fastify/swagger-ui";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { migrationsReady } from "./db/client.js";
|
import { migrationsReady } from "./db/client.js";
|
||||||
import { getDataDir } from "./db/db-utils.js";
|
import { getDataDir } from "./db/db-utils.js";
|
||||||
import { env } from "./plugins/env.js";
|
import { env } from "./plugins/env.js";
|
||||||
|
import { apiKeyRoutes } from "./routes/api-keys.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
import { exportRoutes } from "./routes/export.js";
|
import { exportRoutes } from "./routes/export.js";
|
||||||
@@ -25,6 +30,7 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
|
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Re-export utilities from server-config for external use
|
// Re-export utilities from server-config for external use
|
||||||
export {
|
export {
|
||||||
@@ -45,6 +51,81 @@ import {
|
|||||||
parseCorsOrigins,
|
parseCorsOrigins,
|
||||||
} from "./utils/server-config.js";
|
} from "./utils/server-config.js";
|
||||||
|
|
||||||
|
function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
|
||||||
|
const rawHeader = headers["x-correlation-id"];
|
||||||
|
if (typeof rawHeader !== "string") return null;
|
||||||
|
const trimmed = rawHeader.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (trimmed.length > 128) return null;
|
||||||
|
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoggerOptions(level: string) {
|
||||||
|
const runtimeEnv = process.env.NODE_ENV ?? "production";
|
||||||
|
const base = {
|
||||||
|
level,
|
||||||
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
|
};
|
||||||
|
// Human-readable logs in development, structured JSON in production/test
|
||||||
|
if (runtimeEnv === "development") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
await app.register(fastifySwagger, {
|
||||||
|
openapi: {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "MedAssist-ng API",
|
||||||
|
description: "MedAssist-ng backend API",
|
||||||
|
version: process.env.npm_package_version ?? "dev",
|
||||||
|
},
|
||||||
|
servers: [{ url: "/", description: "Current server" }],
|
||||||
|
tags: [
|
||||||
|
{ name: "health", description: "Service health endpoints" },
|
||||||
|
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||||
|
{ name: "api-keys", description: "Programmatic API key management" },
|
||||||
|
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "API key or JWT",
|
||||||
|
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
|
||||||
|
},
|
||||||
|
cookieAuth: {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "cookie",
|
||||||
|
name: "access_token",
|
||||||
|
description: "Session cookie set by login.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hideUntagged: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(fastifySwaggerUi, {
|
||||||
|
routePrefix: "/docs",
|
||||||
|
staticCSP: true,
|
||||||
|
transformSpecificationClone: true,
|
||||||
|
uiConfig: {
|
||||||
|
docExpansion: "list",
|
||||||
|
deepLinking: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Create and configure Fastify app (without starting) */
|
/** Create and configure Fastify app (without starting) */
|
||||||
export async function createApp(options?: {
|
export async function createApp(options?: {
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
@@ -57,6 +138,7 @@ export async function createApp(options?: {
|
|||||||
refreshTtlDays?: number;
|
refreshTtlDays?: number;
|
||||||
isProduction?: boolean;
|
isProduction?: boolean;
|
||||||
imagesDir?: string;
|
imagesDir?: string;
|
||||||
|
openApiDocsEnabled?: boolean;
|
||||||
}): Promise<FastifyInstance> {
|
}): Promise<FastifyInstance> {
|
||||||
const opts = {
|
const opts = {
|
||||||
logLevel: options?.logLevel ?? "info",
|
logLevel: options?.logLevel ?? "info",
|
||||||
@@ -69,10 +151,19 @@ export async function createApp(options?: {
|
|||||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||||
isProduction: options?.isProduction ?? false,
|
isProduction: options?.isProduction ?? false,
|
||||||
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
|
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
|
||||||
|
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: opts.logLevel },
|
logger: buildLoggerOptions(opts.logLevel),
|
||||||
|
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||||
|
ajv: documentationSchemaAjv,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook("onRequest", (request, reply, done) => {
|
||||||
|
request.correlationId = request.id;
|
||||||
|
reply.header("x-correlation-id", request.id);
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build config
|
// Build config
|
||||||
@@ -98,6 +189,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(jwt, jwtConfig);
|
await app.register(jwt, jwtConfig);
|
||||||
|
|
||||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||||
|
await registerApiDocs(app, opts.openApiDocsEnabled);
|
||||||
|
|
||||||
// Only register static if directory exists
|
// Only register static if directory exists
|
||||||
if (existsSync(opts.imagesDir)) {
|
if (existsSync(opts.imagesDir)) {
|
||||||
@@ -111,6 +203,7 @@ export async function createApp(options?: {
|
|||||||
// Register routes
|
// Register routes
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
|
await app.register(apiKeyRoutes);
|
||||||
await app.register(oidcRoutes);
|
await app.register(oidcRoutes);
|
||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
@@ -138,9 +231,15 @@ log.info("[DB] Migrations complete, starting server...");
|
|||||||
const imagesDir = ensureImagesDirectory();
|
const imagesDir = ensureImagesDirectory();
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: buildLoggerOptions(env.LOG_LEVEL),
|
||||||
level: env.LOG_LEVEL,
|
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||||
},
|
ajv: documentationSchemaAjv,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook("onRequest", (request, reply, done) => {
|
||||||
|
request.correlationId = request.id;
|
||||||
|
reply.header("x-correlation-id", request.id);
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||||
@@ -176,6 +275,7 @@ const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
|
|||||||
await app.register(jwt, jwtConfig);
|
await app.register(jwt, jwtConfig);
|
||||||
|
|
||||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
||||||
|
await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: imagesDir,
|
root: imagesDir,
|
||||||
prefix: "/images/",
|
prefix: "/images/",
|
||||||
@@ -184,6 +284,7 @@ await app.register(fastifyStatic, {
|
|||||||
|
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
|
await app.register(apiKeyRoutes);
|
||||||
await app.register(oidcRoutes);
|
await app.register(oidcRoutes);
|
||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
|
|||||||
+127
-6
@@ -1,7 +1,8 @@
|
|||||||
import { count, eq, sql } from "drizzle-orm";
|
import { pbkdf2Sync } from "node:crypto";
|
||||||
|
import { and, count, eq, sql } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { users } from "../db/schema.js";
|
import { apiKeys, users } from "../db/schema.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -47,7 +48,7 @@ export async function getAnonymousUserId(): Promise<number> {
|
|||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
localAuthEnabled: boolean;
|
formLoginEnabled: boolean;
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
@@ -59,15 +60,18 @@ export async function getAuthState(): Promise<AuthState> {
|
|||||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||||
const hasUsers = result.count > 0;
|
const hasUsers = result.count > 0;
|
||||||
|
|
||||||
|
const needsSetup = env.AUTH_ENABLED && !hasUsers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authEnabled: env.AUTH_ENABLED,
|
authEnabled: env.AUTH_ENABLED,
|
||||||
// Registration: enabled via ENV OR no users exist (first-time setup)
|
// Registration: enabled via ENV OR no users exist (first-time setup)
|
||||||
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
||||||
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
|
// Form login: enabled when auth + form login are both on, or forced on for first-user setup
|
||||||
|
formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED),
|
||||||
oidcEnabled: env.OIDC_ENABLED,
|
oidcEnabled: env.OIDC_ENABLED,
|
||||||
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
||||||
hasUsers,
|
hasUsers,
|
||||||
needsSetup: env.AUTH_ENABLED && !hasUsers,
|
needsSetup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,84 @@ export interface RequestUser {
|
|||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
|
||||||
|
function isMutationMethod(method: string): boolean {
|
||||||
|
return !READ_ONLY_METHODS.has(method.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKeyPepper(): string {
|
||||||
|
return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashApiKeyToken(token: string): string {
|
||||||
|
return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(request: FastifyRequest): string | null {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader) return null;
|
||||||
|
|
||||||
|
const [scheme, value] = authHeader.split(" ");
|
||||||
|
if (!scheme || !value) return null;
|
||||||
|
if (scheme.toLowerCase() !== "bearer") return null;
|
||||||
|
|
||||||
|
const token = value.trim();
|
||||||
|
return token.length > 0 ? token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||||
|
const bearerToken = getBearerToken(request);
|
||||||
|
if (!bearerToken) return false;
|
||||||
|
|
||||||
|
if (!bearerToken.startsWith("ma_")) {
|
||||||
|
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
|
||||||
|
throw new Error("INVALID_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyHash = hashApiKeyToken(bearerToken);
|
||||||
|
const [keyRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||||
|
|
||||||
|
if (!keyRow) {
|
||||||
|
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
|
||||||
|
throw new Error("INVALID_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
|
||||||
|
reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" });
|
||||||
|
throw new Error("API_KEY_EXPIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||||
|
throw new Error("USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = keyRow.scope === "read" ? "read" : "write";
|
||||||
|
if (scope === "read" && isMutationMethod(request.method)) {
|
||||||
|
reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||||
|
throw new Error("API_KEY_SCOPE_FORBIDDEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = { id: user.id, username: user.username };
|
||||||
|
request.authContext = {
|
||||||
|
method: "api_key",
|
||||||
|
scope,
|
||||||
|
apiKeyId: keyRow.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Auth Middleware Functions
|
// Auth Middleware Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -91,6 +173,28 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bearerToken = getBearerToken(request);
|
||||||
|
if (bearerToken?.startsWith("ma_")) {
|
||||||
|
const keyHash = hashApiKeyToken(bearerToken);
|
||||||
|
const [keyRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||||
|
if (!keyRow) return;
|
||||||
|
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return;
|
||||||
|
|
||||||
|
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||||
|
if (userByKey?.isActive) {
|
||||||
|
request.user = { id: userByKey.id, username: userByKey.username };
|
||||||
|
request.authContext = {
|
||||||
|
method: "api_key",
|
||||||
|
scope: keyRow.scope === "read" ? "read" : "write",
|
||||||
|
apiKeyId: keyRow.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const token = request.cookies.access_token;
|
const token = request.cookies.access_token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
@@ -104,6 +208,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
};
|
};
|
||||||
|
request.authContext = {
|
||||||
|
method: "session",
|
||||||
|
scope: "write",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid token, continue as anonymous
|
// Invalid token, continue as anonymous
|
||||||
@@ -118,6 +226,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await tryApiKeyAuth(request, reply)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const token = request.cookies.access_token;
|
const token = request.cookies.access_token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
|
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
|
||||||
@@ -142,11 +254,20 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
};
|
};
|
||||||
|
request.authContext = {
|
||||||
|
method: "session",
|
||||||
|
scope: "write",
|
||||||
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Re-throw our own errors
|
// Re-throw our own errors
|
||||||
if (
|
if (
|
||||||
err instanceof Error &&
|
err instanceof Error &&
|
||||||
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
|
(err.message === "AUTH_REQUIRED" ||
|
||||||
|
err.message === "USER_NOT_FOUND" ||
|
||||||
|
err.message === "ACCOUNT_DISABLED" ||
|
||||||
|
err.message === "INVALID_API_KEY" ||
|
||||||
|
err.message === "API_KEY_EXPIRED" ||
|
||||||
|
err.message === "API_KEY_SCOPE_FORBIDDEN")
|
||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const EnvSchema = z.object({
|
|||||||
.default("3000"),
|
.default("3000"),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
OPENAPI_DOCS_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional(),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Auth Configuration
|
// Auth Configuration
|
||||||
@@ -28,7 +32,11 @@ const EnvSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default("false"),
|
||||||
// Disable local auth when using SSO only
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
|
FORM_LOGIN_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default("true"),
|
||||||
|
|
||||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
@@ -65,10 +73,13 @@ const EnvSchema = z.object({
|
|||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof EnvSchema>;
|
type ParsedEnv = z.infer<typeof EnvSchema>;
|
||||||
|
export type Env = ParsedEnv & {
|
||||||
|
OPENAPI_DOCS_ENABLED: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// Parse and validate
|
// Parse and validate
|
||||||
let parsed: z.infer<typeof EnvSchema>;
|
let parsed: ParsedEnv;
|
||||||
try {
|
try {
|
||||||
parsed = EnvSchema.parse(process.env);
|
parsed = EnvSchema.parse(process.env);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,4 +139,30 @@ if (parsed.OIDC_ENABLED) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = parsed;
|
// Validate that at least one login method is available when auth is enabled
|
||||||
|
if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) {
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
console.error("AUTHENTICATION CONFIGURATION ERROR");
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
console.error("AUTH_ENABLED=true but no login method is available.");
|
||||||
|
console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in.");
|
||||||
|
console.error("");
|
||||||
|
console.error("To fix this, either:");
|
||||||
|
console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login");
|
||||||
|
console.error(" 2. Set OIDC_ENABLED=true to allow SSO login");
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about ineffective registration when form login is disabled
|
||||||
|
if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
|
||||||
|
console.warn(
|
||||||
|
"[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env: Env = {
|
||||||
|
...parsed,
|
||||||
|
// Docs UI/spec are enabled in non-production by default.
|
||||||
|
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { apiKeys } from "../db/schema.js";
|
||||||
|
import { hashApiKeyToken, requireAuth } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
|
const createApiKeySchema = z.object({
|
||||||
|
name: z.string().trim().min(3).max(100),
|
||||||
|
scope: z.enum(["read", "write"]).default("write"),
|
||||||
|
expiresInDays: z.number().int().min(1).max(3650).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const idParamSchema = z.object({
|
||||||
|
id: z.string().regex(/^\d+$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||||
|
{ bearerAuth: [] },
|
||||||
|
{ cookieAuth: [] },
|
||||||
|
];
|
||||||
|
const genericErrorSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKeyMetadataSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
name: { type: "string" },
|
||||||
|
tokenPrefix: { type: "string" },
|
||||||
|
scope: { type: "string", enum: ["read", "write"] },
|
||||||
|
isActive: { type: "boolean" },
|
||||||
|
lastUsedAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDateTime(value: unknown): string | null {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeApiKeyMetadata<
|
||||||
|
T extends {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tokenPrefix: string;
|
||||||
|
scope: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastUsedAt: unknown;
|
||||||
|
expiresAt: unknown;
|
||||||
|
createdAt: unknown;
|
||||||
|
updatedAt: unknown;
|
||||||
|
},
|
||||||
|
>(key: T) {
|
||||||
|
return {
|
||||||
|
id: key.id,
|
||||||
|
name: key.name,
|
||||||
|
tokenPrefix: key.tokenPrefix,
|
||||||
|
scope: key.scope,
|
||||||
|
isActive: key.isActive,
|
||||||
|
lastUsedAt: normalizeDateTime(key.lastUsedAt),
|
||||||
|
expiresAt: normalizeDateTime(key.expiresAt),
|
||||||
|
createdAt: normalizeDateTime(key.createdAt),
|
||||||
|
updatedAt: normalizeDateTime(key.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiKeyRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/auth/api-keys",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["api-keys"],
|
||||||
|
summary: "List API keys for the current user",
|
||||||
|
description: "Returns API key metadata. Raw API key tokens are never returned.",
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
keys: {
|
||||||
|
type: "array",
|
||||||
|
items: apiKeyMetadataSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: genericErrorSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await db
|
||||||
|
.select({
|
||||||
|
id: apiKeys.id,
|
||||||
|
name: apiKeys.name,
|
||||||
|
tokenPrefix: apiKeys.tokenPrefix,
|
||||||
|
scope: apiKeys.scope,
|
||||||
|
isActive: apiKeys.isActive,
|
||||||
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
|
expiresAt: apiKeys.expiresAt,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
updatedAt: apiKeys.updatedAt,
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.userId, authUser.id))
|
||||||
|
.orderBy(desc(apiKeys.createdAt));
|
||||||
|
|
||||||
|
return { keys: keys.map(serializeApiKeyMetadata) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: z.infer<typeof createApiKeySchema> }>(
|
||||||
|
"/auth/api-keys",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["api-keys"],
|
||||||
|
summary: "Create and rotate API key",
|
||||||
|
description:
|
||||||
|
"Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.",
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["name"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 3, maxLength: 100 },
|
||||||
|
scope: { type: "string", enum: ["read", "write"], default: "write" },
|
||||||
|
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
name: "Home Assistant integration",
|
||||||
|
scope: "write",
|
||||||
|
expiresInDays: 365,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
201: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: apiKeyMetadataSchema,
|
||||||
|
token: { type: "string" },
|
||||||
|
note: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, { type: "object" }] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createApiKeySchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send(parsed.error.format());
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, scope, expiresInDays } = parsed.data;
|
||||||
|
const rawToken = `ma_${randomBytes(32).toString("hex")}`;
|
||||||
|
const tokenPrefix = `${rawToken.slice(0, 12)}...`;
|
||||||
|
const keyHash = hashApiKeyToken(rawToken);
|
||||||
|
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
|
||||||
|
|
||||||
|
// Keep a single active key per user: creating a new key invalidates old ones.
|
||||||
|
await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true)));
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(apiKeys)
|
||||||
|
.values({
|
||||||
|
userId: authUser.id,
|
||||||
|
name,
|
||||||
|
keyHash,
|
||||||
|
tokenPrefix,
|
||||||
|
scope,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: apiKeys.id,
|
||||||
|
name: apiKeys.name,
|
||||||
|
tokenPrefix: apiKeys.tokenPrefix,
|
||||||
|
scope: apiKeys.scope,
|
||||||
|
isActive: apiKeys.isActive,
|
||||||
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
|
expiresAt: apiKeys.expiresAt,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
updatedAt: apiKeys.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send({
|
||||||
|
key: serializeApiKeyMetadata(created),
|
||||||
|
token: rawToken,
|
||||||
|
note: "Store this token now. It cannot be retrieved again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/auth/api-keys/:id",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["api-keys"],
|
||||||
|
summary: "Deactivate API key",
|
||||||
|
description: "Deactivates one API key belonging to the current user.",
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", pattern: "^\\d+$" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
204: { type: "null" },
|
||||||
|
400: { anyOf: [genericErrorSchema, { type: "object" }] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = idParamSchema.safeParse(request.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return reply.status(400).send(parsedParams.error.format());
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = Number(parsedParams.data.id);
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: apiKeys.id, userId: apiKeys.userId })
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
|
||||||
|
if (!existing) {
|
||||||
|
return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
|
||||||
|
|
||||||
|
return reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+301
-60
@@ -1,4 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
|
|||||||
import { refreshTokens, users } from "../db/schema.js";
|
import { refreshTokens, users } from "../db/schema.js";
|
||||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_MIME_TYPES,
|
||||||
|
removeImageFiles,
|
||||||
|
streamToBuffer,
|
||||||
|
writeOptimizedImageSet,
|
||||||
|
} from "../utils/image-upload.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Argon2id Configuration - State of the Art Password Hashing
|
// Argon2id Configuration - State of the Art Password Hashing
|
||||||
@@ -53,6 +60,7 @@ const sensitiveRateLimitConfig = {
|
|||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
|
.trim()
|
||||||
.min(3, "Username must be at least 3 characters")
|
.min(3, "Username must be at least 3 characters")
|
||||||
.max(50, "Username must be at most 50 characters")
|
.max(50, "Username must be at most 50 characters")
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||||
@@ -63,7 +71,7 @@ const registerSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
username: z.string().min(1, "Username is required"),
|
username: z.string().trim().min(1, "Username is required"),
|
||||||
password: z.string().min(1, "Password is required"),
|
password: z.string().min(1, "Password is required"),
|
||||||
rememberMe: z.boolean().optional().default(false),
|
rememberMe: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
@@ -77,10 +85,44 @@ const updateProfileSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
|
||||||
|
const authErrorSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDateTime(value: unknown): string | null {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Auth Routes
|
// Auth Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
|
|
||||||
// Token TTLs
|
// Token TTLs
|
||||||
const accessTtlMinutes = 15;
|
const accessTtlMinutes = 15;
|
||||||
const refreshTtlDays = 14;
|
const refreshTtlDays = 14;
|
||||||
@@ -89,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// GET /auth/state - Public auth state (needed before login)
|
// GET /auth/state - Public auth state (needed before login)
|
||||||
// Exempt from rate limit - lightweight state check called frequently
|
// Exempt from rate limit - lightweight state check called frequently
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
|
app.get(
|
||||||
return getAuthState();
|
"/auth/state",
|
||||||
});
|
{
|
||||||
|
config: { rateLimit: false },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Get authentication state",
|
||||||
|
description: "Returns auth and login mode state before user login.",
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
authEnabled: { type: "boolean" },
|
||||||
|
registrationEnabled: { type: "boolean" },
|
||||||
|
formLoginEnabled: { type: "boolean" },
|
||||||
|
oidcEnabled: { type: "boolean" },
|
||||||
|
hasUsers: { type: "boolean" },
|
||||||
|
oidcProviderName: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return getAuthState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /auth/register - User registration
|
// POST /auth/register - User registration
|
||||||
@@ -100,6 +166,40 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
"/auth/register",
|
"/auth/register",
|
||||||
{
|
{
|
||||||
config: { rateLimit: sensitiveRateLimitConfig },
|
config: { rateLimit: sensitiveRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Register local user",
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["username", "password"],
|
||||||
|
properties: {
|
||||||
|
username: { type: "string", minLength: 3, maxLength: 50 },
|
||||||
|
password: { type: "string", minLength: 8, maxLength: 128 },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
username: "daniel",
|
||||||
|
password: "correct-horse-battery-staple",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
201: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
username: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: authErrorSchema,
|
||||||
|
409: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
// Check auth state
|
// Check auth state
|
||||||
@@ -113,8 +213,8 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.localAuthEnabled) {
|
if (!state.formLoginEnabled) {
|
||||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
@@ -167,6 +267,42 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
"/auth/login",
|
"/auth/login",
|
||||||
{
|
{
|
||||||
config: { rateLimit: sensitiveRateLimitConfig },
|
config: { rateLimit: sensitiveRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Login with username and password",
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["username", "password"],
|
||||||
|
properties: {
|
||||||
|
username: { type: "string" },
|
||||||
|
password: { type: "string" },
|
||||||
|
rememberMe: { type: "boolean" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
username: "daniel",
|
||||||
|
password: "correct-horse-battery-staple",
|
||||||
|
rememberMe: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
username: { type: "string" },
|
||||||
|
avatarUrl: { type: ["string", "null"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: authErrorSchema,
|
||||||
|
401: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const state = await getAuthState();
|
const state = await getAuthState();
|
||||||
@@ -175,8 +311,8 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.localAuthEnabled) {
|
if (!state.formLoginEnabled) {
|
||||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse(request.body);
|
const parsed = loginSchema.safeParse(request.body);
|
||||||
@@ -271,6 +407,15 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
"/auth/refresh",
|
"/auth/refresh",
|
||||||
{
|
{
|
||||||
config: { rateLimit: authRateLimitConfig },
|
config: { rateLimit: authRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Refresh access token",
|
||||||
|
description: "Requires refresh token cookie context.",
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||||
|
401: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const refreshTokenCookie = request.cookies.refresh_token;
|
const refreshTokenCookie = request.cookies.refresh_token;
|
||||||
@@ -340,6 +485,13 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
"/auth/logout",
|
"/auth/logout",
|
||||||
{
|
{
|
||||||
config: { rateLimit: authRateLimitConfig },
|
config: { rateLimit: authRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Logout and clear auth cookies",
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const refreshTokenCookie = request.cookies.refresh_token;
|
const refreshTokenCookie = request.cookies.refresh_token;
|
||||||
@@ -365,26 +517,56 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /auth/me - Get current user profile
|
// GET /auth/me - Get current user profile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
|
app.get(
|
||||||
const authUser = request.user as unknown as AuthUser | null;
|
"/auth/me",
|
||||||
if (!authUser) {
|
{
|
||||||
return reply.status(401).send({ error: "Not authenticated" });
|
preHandler: requireAuth,
|
||||||
}
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Get current user profile",
|
||||||
|
security: authEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
username: { type: "string" },
|
||||||
|
avatarUrl: { type: ["string", "null"] },
|
||||||
|
authProvider: { type: "string" },
|
||||||
|
createdAt: { type: "string", format: "date-time" },
|
||||||
|
lastLoginAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: authErrorSchema,
|
||||||
|
404: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
return reply.status(401).send({ error: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.status(404).send({ error: "User not found" });
|
return reply.status(404).send({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const createdAt =
|
||||||
id: user.id,
|
normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString();
|
||||||
username: user.username,
|
const lastLoginAt = normalizeDateTime(user.lastLoginAt);
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
authProvider: user.authProvider,
|
return {
|
||||||
createdAt: user.createdAt,
|
id: user.id,
|
||||||
lastLoginAt: user.lastLoginAt,
|
username: user.username,
|
||||||
};
|
avatarUrl: user.avatarUrl,
|
||||||
});
|
authProvider: user.authProvider ?? "local",
|
||||||
|
createdAt,
|
||||||
|
lastLoginAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PUT /auth/me - Update current user profile
|
// PUT /auth/me - Update current user profile
|
||||||
@@ -394,6 +576,34 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
preHandler: requireAuth,
|
preHandler: requireAuth,
|
||||||
config: { rateLimit: authRateLimitConfig },
|
config: { rateLimit: authRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Update current user profile",
|
||||||
|
security: authEndpointSecurity,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
newPassword: { type: "string", minLength: 8, maxLength: 128 },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
currentPassword: "current-password",
|
||||||
|
newPassword: "new-strong-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: authErrorSchema,
|
||||||
|
401: authErrorSchema,
|
||||||
|
404: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const authUser = request.user as unknown as AuthUser | null;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
@@ -452,6 +662,24 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
preHandler: requireAuth,
|
preHandler: requireAuth,
|
||||||
config: { rateLimit: authRateLimitConfig },
|
config: { rateLimit: authRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Upload user avatar",
|
||||||
|
description: "Uploads and optimizes a profile image using multipart/form-data.",
|
||||||
|
security: authEndpointSecurity,
|
||||||
|
consumes: ["multipart/form-data"],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
avatarUrl: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: authErrorSchema,
|
||||||
|
401: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const authUser = request.user as unknown as AuthUser | null;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
@@ -461,36 +689,35 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const data = await request.file();
|
const data = await request.file();
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return reply.status(400).send({ error: "No file uploaded" });
|
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||||
if (!allowedTypes.includes(data.mimetype)) {
|
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
let uploadBuffer: Buffer;
|
||||||
const ext = data.filename.split(".").pop() || "jpg";
|
try {
|
||||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
uploadBuffer = await streamToBuffer(data.file);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||||
|
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Save file
|
let filename: string;
|
||||||
const fs = await import("node:fs/promises");
|
try {
|
||||||
const path = await import("node:path");
|
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
|
||||||
const imagesDir = path.join(getDataDir(), "images");
|
} catch {
|
||||||
await fs.mkdir(imagesDir, { recursive: true });
|
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||||
|
}
|
||||||
const buffer = await data.toBuffer();
|
|
||||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
|
||||||
|
|
||||||
// Delete old avatar if exists
|
// Delete old avatar if exists
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||||
if (user?.avatarUrl) {
|
if (user?.avatarUrl) {
|
||||||
try {
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
@@ -508,6 +735,16 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
preHandler: requireAuth,
|
preHandler: requireAuth,
|
||||||
config: { rateLimit: authRateLimitConfig },
|
config: { rateLimit: authRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Delete user avatar",
|
||||||
|
security: authEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||||
|
401: authErrorSchema,
|
||||||
|
404: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const authUser = request.user as unknown as AuthUser | null;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
@@ -521,13 +758,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
const fs = await import("node:fs/promises");
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
const path = await import("node:path");
|
|
||||||
try {
|
|
||||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||||
@@ -544,6 +775,22 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
preHandler: requireAuth,
|
preHandler: requireAuth,
|
||||||
config: { rateLimit: sensitiveRateLimitConfig },
|
config: { rateLimit: sensitiveRateLimitConfig },
|
||||||
|
schema: {
|
||||||
|
tags: ["auth"],
|
||||||
|
summary: "Delete current user account",
|
||||||
|
description: "Deletes the current account and related data (cascade delete).",
|
||||||
|
security: authEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: authErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const authUser = request.user as unknown as AuthUser | null;
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
@@ -554,13 +801,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
// Delete avatar file if exists
|
// Delete avatar file if exists
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||||
if (user?.avatarUrl) {
|
if (user?.avatarUrl) {
|
||||||
const fs = await import("node:fs/promises");
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
const path = await import("node:path");
|
|
||||||
try {
|
|
||||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user - cascade delete handles all related data
|
// Delete user - cascade delete handles all related data
|
||||||
|
|||||||
+496
-80
@@ -2,10 +2,23 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } 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, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
tokenParamsSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
import {
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
parseTakenByJson,
|
||||||
|
personTakesMedication,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Validation Schemas
|
// Validation Schemas
|
||||||
@@ -22,6 +35,37 @@ const dismissDosesSchema = z.object({
|
|||||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||||
|
{ bearerAuth: [] },
|
||||||
|
{ cookieAuth: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
const doseReadResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doses: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string" },
|
||||||
|
takenAt: { type: "number" },
|
||||||
|
markedBy: { type: ["string", "null"] },
|
||||||
|
takenSource: { type: "string" },
|
||||||
|
dismissed: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function maskToken(token: string): string {
|
||||||
|
if (token.length <= 8) return token;
|
||||||
|
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 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: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -38,35 +82,224 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
return authUser.id;
|
return authUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParsedDoseId = {
|
||||||
|
medicationId: number;
|
||||||
|
intakeIndex: number;
|
||||||
|
timestampMs: number;
|
||||||
|
personSuffix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||||
|
const match = doseIdPattern.exec(doseId);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const medicationId = Number.parseInt(match[1], 10);
|
||||||
|
const intakeIndex = Number.parseInt(match[2], 10);
|
||||||
|
const timestampMs = Number.parseInt(match[3], 10);
|
||||||
|
const personSuffix = match[4] ? match[4].trim() : null;
|
||||||
|
|
||||||
|
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationId,
|
||||||
|
intakeIndex,
|
||||||
|
timestampMs,
|
||||||
|
personSuffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveShareToken(token: string): Promise<{
|
||||||
|
share: typeof shareTokens.$inferSelect | null;
|
||||||
|
reason: "not_found" | "expired" | "ok";
|
||||||
|
}> {
|
||||||
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
|
if (!share) return { share: null, reason: "not_found" };
|
||||||
|
|
||||||
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
|
return { share: null, reason: "expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { share, reason: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
|
||||||
|
const parsedDose = parseDoseId(doseId);
|
||||||
|
if (!parsedDose) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [medication] = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
|
||||||
|
|
||||||
|
if (!medication) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medTakenBy = parseTakenByJson(medication.takenByJson);
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
medication.intakesJson,
|
||||||
|
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||||
|
medication.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intake = intakes[parsedDose.intakeIndex];
|
||||||
|
if (!intake) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
|
||||||
|
if (expectedPersons.length === 0) {
|
||||||
|
return parsedDose.personSuffix === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedDose.personSuffix) {
|
||||||
|
return intake.takenBy === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedPersons.includes(parsedDose.personSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDoseOutOfStock(options: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
stockCalculationMode: "automatic" | "manual";
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const parsedDose = parseDoseId(options.doseId);
|
||||||
|
if (!parsedDose) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [medication] = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
|
||||||
|
|
||||||
|
if (!medication) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
medication.intakesJson,
|
||||||
|
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||||
|
medication.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
const intake = intakes[parsedDose.intakeIndex];
|
||||||
|
|
||||||
|
const scheduledOccurrenceMs = intake
|
||||||
|
? (() => {
|
||||||
|
const doseDate = new Date(parsedDose.timestampMs);
|
||||||
|
const intakeStart = parseLocalDateTime(intake.start);
|
||||||
|
return new Date(
|
||||||
|
doseDate.getFullYear(),
|
||||||
|
doseDate.getMonth(),
|
||||||
|
doseDate.getDate(),
|
||||||
|
intakeStart.getHours(),
|
||||||
|
intakeStart.getMinutes(),
|
||||||
|
intakeStart.getSeconds(),
|
||||||
|
intakeStart.getMilliseconds()
|
||||||
|
).getTime();
|
||||||
|
})()
|
||||||
|
: parsedDose.timestampMs;
|
||||||
|
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
|
||||||
|
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
|
||||||
|
return (
|
||||||
|
computeMedicationCurrentStock({
|
||||||
|
medication,
|
||||||
|
doses,
|
||||||
|
stockCalculationMode: options.stockCalculationMode,
|
||||||
|
nowMs: stockBeforeDoseMs,
|
||||||
|
}) <= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking Routes
|
// Dose Tracking Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function doseRoutes(app: FastifyInstance) {
|
export async function doseRoutes(app: FastifyInstance) {
|
||||||
|
applyOpenApiRouteStandards(app, {
|
||||||
|
tag: "doses",
|
||||||
|
protectedByDefault: false,
|
||||||
|
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
||||||
|
// Suppress request logs — polled every 5s by frontend
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
|
app.get(
|
||||||
const userId = await getUserId(request, reply);
|
"/doses/taken",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
logLevel: "warn",
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: doseReadResponseSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
// Get all taken doses for this user (no time limit)
|
// Get all taken doses for this user (no time limit)
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
doses: doses.map((d) => ({
|
doses: doses.map((d) => ({
|
||||||
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,
|
takenSource: d.takenSource ?? "manual",
|
||||||
})),
|
dismissed: d.dismissed ?? false,
|
||||||
};
|
})),
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /doses/taken - PROTECTED: Mark a dose as taken
|
// POST /doses/taken - PROTECTED: Mark a dose as taken
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||||
"/doses/taken",
|
"/doses/taken",
|
||||||
{ preHandler: requireAuth },
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
409: genericErrorSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
@@ -89,11 +322,22 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
return { success: true, message: "Already marked" };
|
return { success: true, message: "Already marked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
const outOfStock = await isDoseOutOfStock({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
|
});
|
||||||
|
if (outOfStock) {
|
||||||
|
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||||
|
}
|
||||||
|
|
||||||
// Insert new record
|
// Insert new record
|
||||||
await db.insert(doseTracking).values({
|
await db.insert(doseTracking).values({
|
||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: null, // Marked by the user themselves
|
markedBy: null, // Marked by the user themselves
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -105,7 +349,24 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.delete<{ Params: { doseId: string } }>(
|
app.delete<{ Params: { doseId: string } }>(
|
||||||
"/doses/taken/:doseId",
|
"/doses/taken/:doseId",
|
||||||
{ preHandler: requireAuth },
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["doseId"],
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
@@ -134,7 +395,33 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||||
"/doses/dismiss",
|
"/doses/dismiss",
|
||||||
{ preHandler: requireAuth },
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doseIds: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
dismissedCount: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
@@ -171,6 +458,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: null,
|
markedBy: null,
|
||||||
|
takenAt: new Date(0),
|
||||||
dismissed: true,
|
dismissed: true,
|
||||||
});
|
});
|
||||||
dismissedCount++;
|
dismissedCount++;
|
||||||
@@ -184,59 +472,123 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
|
app.delete(
|
||||||
const userId = await getUserId(request, reply);
|
"/doses/dismiss",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
clearedCount: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
// Delete all dismissed-only records (not taken ones)
|
// Delete all dismissed-only records (not taken ones)
|
||||||
// For taken+dismissed, just remove the dismissed flag
|
// For taken+dismissed, just remove the dismissed flag
|
||||||
const dismissed = await db
|
const dismissed = await db
|
||||||
.select()
|
.select()
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
|
||||||
|
|
||||||
for (const d of dismissed) {
|
for (const d of dismissed) {
|
||||||
if (d.markedBy !== null || d.takenAt) {
|
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(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));
|
if (d.markedBy !== null || hasRealTakenTimestamp) {
|
||||||
} else {
|
// This was also marked as taken - just remove dismissed flag
|
||||||
// This was only dismissed - delete it
|
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
|
||||||
await db.delete(doseTracking).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 };
|
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
|
||||||
|
// Suppress request logs — polled every 5s by SharedSchedule
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
app.get<{ Params: { token: string } }>(
|
||||||
const { token } = request.params;
|
"/share/:token/doses",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: tokenParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: doseReadResponseSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logLevel: "warn",
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: 60,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token } = request.params;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
if (!share) {
|
||||||
if (!share) {
|
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all taken doses for this user (no time limit)
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
doses: doses.map((d) => ({
|
||||||
|
doseId: d.doseId,
|
||||||
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// Get all taken doses for this user (no time limit)
|
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
|
||||||
|
|
||||||
return {
|
|
||||||
doses: doses.map((d) => ({
|
|
||||||
doseId: d.doseId,
|
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
|
||||||
markedBy: d.markedBy,
|
|
||||||
dismissed: d.dismissed ?? false,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||||
"/share/:token/doses",
|
"/share/:token/doses",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: tokenParamsSchema,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
409: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
|
||||||
@@ -249,12 +601,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const { doseId } = parsed.data;
|
const { doseId } = parsed.data;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
request.log.warn(
|
||||||
|
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||||
|
);
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already marked
|
// Check if already marked
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -262,16 +622,38 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
|
||||||
return { success: true, message: "Already marked" };
|
return { success: true, message: "Already marked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new record - marked by the takenBy person
|
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||||
|
const outOfStock = await isDoseOutOfStock({
|
||||||
|
userId: share.userId,
|
||||||
|
doseId,
|
||||||
|
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
|
});
|
||||||
|
if (outOfStock) {
|
||||||
|
request.log.info(
|
||||||
|
`[ShareDose] Rejected out-of-stock mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||||
|
);
|
||||||
|
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
|
||||||
|
const parsedShareDose = parseDoseId(doseId);
|
||||||
|
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
|
||||||
|
|
||||||
await db.insert(doseTracking).values({
|
await db.insert(doseTracking).values({
|
||||||
userId: share.userId,
|
userId: share.userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: share.takenBy, // e.g. "Daniel"
|
markedBy,
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||||
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -279,28 +661,62 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
|
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||||
const { token, doseId } = request.params;
|
"/share/:token/doses/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token", "doseId"],
|
||||||
|
properties: {
|
||||||
|
token: tokenParamsSchema.properties.token,
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||||
|
400: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token, doseId } = request.params;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
if (!share) {
|
||||||
if (!share) {
|
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
request.log.warn(
|
||||||
|
`[ShareDose] Rejected invalid doseId in unmark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||||
|
);
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this dose was dismissed
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
|
if (existing?.dismissed) {
|
||||||
|
// Already dismissed - keep the record as-is
|
||||||
|
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
|
||||||
|
} else {
|
||||||
|
// Not dismissed - delete the record entirely
|
||||||
|
await db
|
||||||
|
.delete(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
request.log.info(
|
||||||
|
`[ShareDose] Dose unmarked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// Check if this dose was dismissed
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(doseTracking)
|
|
||||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
|
||||||
|
|
||||||
if (existing?.dismissed) {
|
|
||||||
// Already dismissed - keep the record as-is
|
|
||||||
} else {
|
|
||||||
// Not dismissed - delete the record entirely
|
|
||||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+350
-210
@@ -10,6 +10,12 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } 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";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
@@ -17,7 +23,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.1";
|
const EXPORT_VERSION = "1.3";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -27,6 +33,7 @@ const scheduleSchema = z.object({
|
|||||||
usage: z.number().nonnegative(),
|
usage: z.number().nonnegative(),
|
||||||
every: z.number().int().min(1),
|
every: z.number().int().min(1),
|
||||||
start: z.string(), // ISO datetime string
|
start: z.string(), // ISO datetime string
|
||||||
|
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||||
remind: z.boolean().optional().default(false),
|
remind: z.boolean().optional().default(false),
|
||||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||||
});
|
});
|
||||||
@@ -38,7 +45,9 @@ const inventorySchema = z.object({
|
|||||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
packageType: z.enum(PACKAGE_TYPES).default("blister"),
|
||||||
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const medicationExportSchema = z.object({
|
const medicationExportSchema = z.object({
|
||||||
@@ -46,11 +55,16 @@ const medicationExportSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
genericName: z.string().nullable().optional(),
|
genericName: z.string().nullable().optional(),
|
||||||
takenBy: z.array(z.string()).default([]),
|
takenBy: z.array(z.string()).default([]),
|
||||||
|
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
|
||||||
|
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
|
||||||
|
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
||||||
inventory: inventorySchema,
|
inventory: inventorySchema,
|
||||||
pillWeightMg: z.number().int().nullable().optional(),
|
pillWeightMg: z.number().int().nullable().optional(),
|
||||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||||
schedules: z.array(scheduleSchema).default([]),
|
schedules: z.array(scheduleSchema).default([]),
|
||||||
medicationStartDate: z.string().nullable().optional(),
|
medicationStartDate: z.string().nullable().optional(),
|
||||||
|
medicationEndDate: z.string().nullable().optional(),
|
||||||
|
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
intakeRemindersEnabled: z.boolean().default(false),
|
intakeRemindersEnabled: z.boolean().default(false),
|
||||||
@@ -72,6 +86,7 @@ const doseHistorySchema = z.object({
|
|||||||
scheduledTime: z.string(), // ISO datetime
|
scheduledTime: z.string(), // ISO datetime
|
||||||
takenAt: z.string(), // ISO datetime
|
takenAt: z.string(), // ISO datetime
|
||||||
markedBy: z.string().nullable().optional(),
|
markedBy: z.string().nullable().optional(),
|
||||||
|
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||||
dismissed: z.boolean().default(false),
|
dismissed: z.boolean().default(false),
|
||||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
});
|
});
|
||||||
@@ -121,6 +136,7 @@ const settingsExportSchema = z
|
|||||||
language: z.string().default("en"),
|
language: z.string().default("en"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
shareStockStatus: z.boolean().default(true),
|
shareStockStatus: z.boolean().default(true),
|
||||||
|
shareMedicationOverview: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -135,6 +151,69 @@ const importDataSchema = z.object({
|
|||||||
shareLinks: z.array(shareLinkSchema).default([]),
|
shareLinks: z.array(shareLinkSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportQuerystringSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
includeSensitive: { type: "string", enum: ["true", "false"] },
|
||||||
|
includeImages: { type: "string", enum: ["true", "false"] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const exportResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
version: { type: "string" },
|
||||||
|
exportedAt: { type: "string", format: "date-time" },
|
||||||
|
includeSensitiveData: { type: "boolean" },
|
||||||
|
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
settings: { type: "object", additionalProperties: true },
|
||||||
|
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const importBodyOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["version", "exportedAt"],
|
||||||
|
properties: {
|
||||||
|
version: { type: "string" },
|
||||||
|
exportedAt: { type: "string", format: "date-time" },
|
||||||
|
includeSensitiveData: { type: "boolean" },
|
||||||
|
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
settings: { type: "object", additionalProperties: true },
|
||||||
|
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
version: "1.8.0",
|
||||||
|
exportedAt: "2026-03-11T10:15:00.000Z",
|
||||||
|
includeSensitiveData: true,
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
name: "Ibuprofen 400",
|
||||||
|
packageType: "box",
|
||||||
|
packCount: 1,
|
||||||
|
looseTablets: 8,
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 8,
|
||||||
|
start: "2026-03-11T08:00:00.000Z",
|
||||||
|
takenBy: "Daniel",
|
||||||
|
remind: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
||||||
|
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||||
|
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||||
|
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -154,9 +233,14 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse intakes from DB format to export format (with per-intake takenBy)
|
// Parse intakes from DB format to export format (with per-intake takenBy)
|
||||||
function parseIntakesForExport(
|
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||||
row: typeof medications.$inferSelect
|
usage: number;
|
||||||
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
|
every: number;
|
||||||
|
start: string;
|
||||||
|
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||||
|
remind: boolean;
|
||||||
|
takenBy: string | null;
|
||||||
|
}> {
|
||||||
// Use the new parseIntakesJson which falls back to legacy format
|
// Use the new parseIntakesJson which falls back to legacy format
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
@@ -168,6 +252,7 @@ function parseIntakesForExport(
|
|||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
every: intake.every,
|
every: intake.every,
|
||||||
start: intake.start,
|
start: intake.start,
|
||||||
|
intakeUnit: null,
|
||||||
remind: intake.intakeRemindersEnabled,
|
remind: intake.intakeRemindersEnabled,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy
|
takenBy: intake.takenBy, // Per-intake takenBy
|
||||||
}));
|
}));
|
||||||
@@ -256,235 +341,257 @@ function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: nu
|
|||||||
export async function exportRoutes(app: FastifyInstance) {
|
export async function exportRoutes(app: FastifyInstance) {
|
||||||
// All export routes require auth
|
// All export routes require auth
|
||||||
app.addHook("preHandler", requireAuth);
|
app.addHook("preHandler", requireAuth);
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "export", protectedByDefault: true });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /export - Export all user data
|
// GET /export - Export all user data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
|
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
|
||||||
const userId = await getUserId(request, reply);
|
"/export",
|
||||||
const includeSensitive = request.query.includeSensitive === "true";
|
{
|
||||||
const includeImages = request.query.includeImages !== "false"; // Default to true
|
schema: {
|
||||||
|
querystring: exportQuerystringSchema,
|
||||||
// 1. Load all medications
|
response: {
|
||||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
200: exportResponseSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
// 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 && !Number.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 = !Number.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,
|
|
||||||
totalPills: med.totalPills ?? null,
|
|
||||||
looseTablets: med.looseTablets ?? 0,
|
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
|
||||||
packageType: med.packageType ?? "blister",
|
|
||||||
},
|
},
|
||||||
pillWeightMg: med.pillWeightMg,
|
},
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
},
|
||||||
schedules: parseIntakesForExport(med),
|
async (request, reply) => {
|
||||||
medicationStartDate: med.medicationStartDate || null,
|
const userId = await getUserId(request, reply);
|
||||||
expiryDate: med.expiryDate,
|
const includeSensitive = request.query.includeSensitive === "true";
|
||||||
notes: med.notes,
|
const includeImages = request.query.includeImages !== "false"; // Default to true
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
|
||||||
isObsolete: med.isObsolete ?? false,
|
|
||||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
|
||||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
|
||||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
|
||||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
|
||||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
|
||||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
|
||||||
dismissedUntil: med.dismissedUntil ?? null,
|
|
||||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
|
||||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Load all dose tracking entries
|
// 1. Load all medications
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
|
|
||||||
const exportDoseHistory = doses
|
// Build medication ID to export ID mapping
|
||||||
.map((dose) => {
|
const medIdToExportId = new Map<number, string>();
|
||||||
const parsed = parseDoseId(dose.doseId);
|
const exportMedications = meds.map((med, index) => {
|
||||||
if (!parsed) return null;
|
const exportId = `med-${index + 1}`;
|
||||||
|
medIdToExportId.set(med.id, exportId);
|
||||||
|
|
||||||
const exportId = medIdToExportId.get(parsed.medicationId);
|
// Safely convert lastStockCorrectionAt to ISO string
|
||||||
if (!exportId) return null; // Orphaned dose, skip
|
let lastStockCorrectionAtIso: string | null = null;
|
||||||
|
if (med.lastStockCorrectionAt) {
|
||||||
|
try {
|
||||||
|
if (med.lastStockCorrectionAt instanceof Date && !Number.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 = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastStockCorrectionAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Safely convert takenAt to ISO string
|
return {
|
||||||
let takenAtIso: string;
|
_exportId: exportId,
|
||||||
try {
|
name: med.name,
|
||||||
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
|
genericName: med.genericName,
|
||||||
takenAtIso = dose.takenAt.toISOString();
|
takenBy: parseTakenByJson(med.takenByJson),
|
||||||
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
const d = new Date(dose.takenAt);
|
pillForm: med.pillForm ?? null,
|
||||||
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
} else {
|
inventory: {
|
||||||
|
packCount: med.packCount ?? 1,
|
||||||
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
|
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||||
|
totalPills: med.totalPills ?? null,
|
||||||
|
looseTablets: med.looseTablets ?? 0,
|
||||||
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
|
packageType: normalizePackageType(med.packageType),
|
||||||
|
packageAmountValue: med.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
|
},
|
||||||
|
pillWeightMg: med.pillWeightMg,
|
||||||
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
|
schedules: parseIntakesForExport(med),
|
||||||
|
medicationStartDate: med.medicationStartDate || null,
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
|
expiryDate: med.expiryDate,
|
||||||
|
notes: med.notes,
|
||||||
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
|
isObsolete: med.isObsolete ?? false,
|
||||||
|
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||||
|
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||||
|
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||||
|
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||||
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
|
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||||
|
dismissedUntil: med.dismissedUntil ?? null,
|
||||||
|
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 && !Number.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 = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
takenAtIso = new Date().toISOString();
|
takenAtIso = new Date().toISOString();
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
takenAtIso = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely convert scheduled time
|
// Safely convert scheduled time
|
||||||
let scheduledTimeIso: string;
|
let scheduledTimeIso: string;
|
||||||
try {
|
try {
|
||||||
const d = new Date(parsed.timestampMs);
|
const d = new Date(parsed.timestampMs);
|
||||||
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
} catch {
|
} catch {
|
||||||
scheduledTimeIso = new Date().toISOString();
|
scheduledTimeIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationRef: exportId,
|
||||||
|
scheduleIndex: parsed.blisterIndex,
|
||||||
|
scheduledTime: scheduledTimeIso,
|
||||||
|
takenAt: takenAtIso,
|
||||||
|
markedBy: dose.markedBy,
|
||||||
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
|
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,
|
||||||
|
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||||
|
// Only include sensitive data if requested
|
||||||
|
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
||||||
|
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
||||||
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||||
|
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,
|
||||||
|
expiryWarningDays: settings.expiryWarningDays,
|
||||||
|
language: settings.language,
|
||||||
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
shareStockStatus: settings.shareStockStatus,
|
||||||
|
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||||
|
}
|
||||||
|
: 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 && !Number.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 = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
expiresAtIso = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
medicationRef: exportId,
|
takenBy: share.takenBy,
|
||||||
scheduleIndex: parsed.blisterIndex,
|
scheduleDays: share.scheduleDays,
|
||||||
scheduledTime: scheduledTimeIso,
|
expiresAt: expiresAtIso,
|
||||||
takenAt: takenAtIso,
|
regenerateToken: true, // Always regenerate tokens on import for security
|
||||||
markedBy: dose.markedBy,
|
|
||||||
dismissed: dose.dismissed ?? false,
|
|
||||||
takenByPerson: parsed.person,
|
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
|
||||||
|
|
||||||
// 3. Load user settings
|
// 5. Load refill history
|
||||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
|
|
||||||
const exportSettings = settings
|
const exportRefillHistory = refills
|
||||||
? {
|
.map((refill) => {
|
||||||
emailEnabled: settings.emailEnabled,
|
const exportId = medIdToExportId.get(refill.medicationId);
|
||||||
notificationEmail: settings.notificationEmail,
|
if (!exportId) return null; // Orphaned refill, skip
|
||||||
emailStockReminders: settings.emailStockReminders,
|
|
||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
|
||||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
|
||||||
// Only include sensitive data if requested
|
|
||||||
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
|
||||||
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
|
||||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
|
||||||
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,
|
|
||||||
expiryWarningDays: settings.expiryWarningDays,
|
|
||||||
language: settings.language,
|
|
||||||
stockCalculationMode: settings.stockCalculationMode,
|
|
||||||
shareStockStatus: settings.shareStockStatus,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// 4. Load share links
|
// Safely convert refillDate to ISO string
|
||||||
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
let refillDateIso: string;
|
||||||
|
try {
|
||||||
const exportShareLinks = shares.map((share) => {
|
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||||
// Safely convert expiresAt to ISO string
|
refillDateIso = refill.refillDate.toISOString();
|
||||||
let expiresAtIso: string | null = null;
|
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||||
if (share.expiresAt) {
|
const d = new Date(refill.refillDate);
|
||||||
try {
|
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
|
} else {
|
||||||
expiresAtIso = share.expiresAt.toISOString();
|
refillDateIso = new Date().toISOString();
|
||||||
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
}
|
||||||
const d = new Date(share.expiresAt);
|
} catch {
|
||||||
expiresAtIso = !Number.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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Load refill history
|
|
||||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
|
||||||
|
|
||||||
const exportRefillHistory = refills
|
|
||||||
.map((refill) => {
|
|
||||||
const exportId = medIdToExportId.get(refill.medicationId);
|
|
||||||
if (!exportId) return null; // Orphaned refill, skip
|
|
||||||
|
|
||||||
// Safely convert refillDate to ISO string
|
|
||||||
let refillDateIso: string;
|
|
||||||
try {
|
|
||||||
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
|
||||||
refillDateIso = refill.refillDate.toISOString();
|
|
||||||
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
|
||||||
const d = new Date(refill.refillDate);
|
|
||||||
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
refillDateIso = new Date().toISOString();
|
refillDateIso = new Date().toISOString();
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
refillDateIso = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
medicationRef: exportId,
|
medicationRef: exportId,
|
||||||
packsAdded: refill.packsAdded ?? 0,
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
usedPrescription: refill.usedPrescription ?? false,
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
refillDate: refillDateIso,
|
refillDate: refillDateIso,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
// Build export object
|
// Build export object
|
||||||
const exportData = {
|
const exportData = {
|
||||||
version: EXPORT_VERSION,
|
version: EXPORT_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
includeSensitiveData: includeSensitive,
|
includeSensitiveData: includeSensitive,
|
||||||
medications: exportMedications,
|
medications: exportMedications,
|
||||||
doseHistory: exportDoseHistory,
|
doseHistory: exportDoseHistory,
|
||||||
refillHistory: exportRefillHistory,
|
refillHistory: exportRefillHistory,
|
||||||
settings: exportSettings,
|
settings: exportSettings,
|
||||||
shareLinks: exportShareLinks,
|
shareLinks: exportShareLinks,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set download headers
|
// Set download headers
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||||
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||||
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||||
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||||
reply.header("Content-Type", "application/json");
|
reply.header("Content-Type", "application/json");
|
||||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
return exportData;
|
return exportData;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /import - Import user data (replaces all existing data!)
|
// POST /import - Import user data (replaces all existing data!)
|
||||||
@@ -497,6 +604,29 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
rawBody: true,
|
rawBody: true,
|
||||||
},
|
},
|
||||||
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
||||||
|
schema: {
|
||||||
|
body: importBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
imported: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
medications: { type: "integer" },
|
||||||
|
doseHistory: { type: "integer" },
|
||||||
|
refillHistory: { type: "integer" },
|
||||||
|
settings: { type: "integer" },
|
||||||
|
shareLinks: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
@@ -553,6 +683,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
usage: s.usage,
|
usage: s.usage,
|
||||||
every: s.every,
|
every: s.every,
|
||||||
start: s.start,
|
start: s.start,
|
||||||
|
intakeUnit: s.intakeUnit ?? null,
|
||||||
takenBy: s.takenBy || null,
|
takenBy: s.takenBy || null,
|
||||||
intakeRemindersEnabled: s.remind ?? false,
|
intakeRemindersEnabled: s.remind ?? false,
|
||||||
}))
|
}))
|
||||||
@@ -568,7 +699,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName || null,
|
genericName: med.genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
packageType: med.inventory.packageType ?? "blister",
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm || null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(med.inventory.packageType),
|
||||||
|
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||||
packCount: med.inventory.packCount,
|
packCount: med.inventory.packCount,
|
||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
@@ -579,6 +715,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
medicationStartDate: med.medicationStartDate || "",
|
medicationStartDate: med.medicationStartDate || "",
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakesJson,
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
@@ -625,6 +763,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -656,6 +795,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
language: importData.settings.language ?? "en",
|
language: importData.settings.language ?? "en",
|
||||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||||
|
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
// Read version from package.json at startup
|
// Read version from package.json at startup
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -10,11 +11,31 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|||||||
const backendVersion = packageJson.version || "unknown";
|
const backendVersion = packageJson.version || "unknown";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
// Exempt from rate limit - lightweight health check
|
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
|
||||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
|
||||||
status: "ok",
|
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
|
||||||
version: backendVersion,
|
app.get(
|
||||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
"/health",
|
||||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
{
|
||||||
}));
|
config: { rateLimit: false },
|
||||||
|
logLevel: "warn",
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["ok"] },
|
||||||
|
version: { type: "string" },
|
||||||
|
smtpConfigured: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => ({
|
||||||
|
status: "ok",
|
||||||
|
version: backendVersion,
|
||||||
|
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1499
-741
File diff suppressed because it is too large
Load Diff
+81
-48
@@ -5,6 +5,7 @@ import * as client from "openid-client";
|
|||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { refreshTokens, users } from "../db/schema.js";
|
import { refreshTokens, users } from "../db/schema.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// OIDC Configuration Cache
|
// OIDC Configuration Cache
|
||||||
@@ -49,12 +50,14 @@ function getFrontendUrl(): string {
|
|||||||
// OIDC Routes
|
// OIDC Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function oidcRoutes(app: FastifyInstance) {
|
export async function oidcRoutes(app: FastifyInstance) {
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "auth", protectedByDefault: false });
|
||||||
|
|
||||||
if (!env.OIDC_ENABLED) {
|
if (!env.OIDC_ENABLED) {
|
||||||
// Register a disabled route that returns an error
|
// Register a disabled route that returns an error
|
||||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
|
||||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||||
});
|
});
|
||||||
app.get("/auth/oidc/callback", async (_request, reply) => {
|
app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
|
||||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -63,64 +66,91 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /auth/oidc/login - Initiates OIDC flow
|
// GET /auth/oidc/login - Initiates OIDC flow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
app.get(
|
||||||
try {
|
"/auth/oidc/login",
|
||||||
const config = await getOIDCConfig();
|
{
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
302: { type: "null", description: "Redirect to OIDC provider" },
|
||||||
|
500: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = await getOIDCConfig();
|
||||||
|
|
||||||
// Generate PKCE values
|
// Generate PKCE values
|
||||||
const codeVerifier = generateCodeVerifier();
|
const codeVerifier = generateCodeVerifier();
|
||||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
const state = generateState();
|
const state = generateState();
|
||||||
|
|
||||||
// Store PKCE verifier and state in signed cookies (short-lived)
|
// Store PKCE verifier and state in signed cookies (short-lived)
|
||||||
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 600, // 10 minutes
|
maxAge: 600, // 10 minutes
|
||||||
signed: true,
|
signed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.setCookie("oidc_state", state, {
|
reply.setCookie("oidc_state", state, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === "production",
|
secure: env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 600,
|
maxAge: 600,
|
||||||
signed: true,
|
signed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build authorization URL
|
// Build authorization URL
|
||||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||||
const scope = env.OIDC_SCOPES;
|
const scope = env.OIDC_SCOPES;
|
||||||
|
|
||||||
const authUrl = client.buildAuthorizationUrl(config, {
|
const authUrl = client.buildAuthorizationUrl(config, {
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope,
|
scope,
|
||||||
state,
|
state,
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.redirect(authUrl.href);
|
return reply.redirect(authUrl.href);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[OIDC] Login error:", err);
|
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
||||||
"/auth/oidc/callback",
|
"/auth/oidc/callback",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
code: { type: "string" },
|
||||||
|
state: { type: "string" },
|
||||||
|
error: { type: "string" },
|
||||||
|
error_description: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
302: { type: "null", description: "Redirect back to frontend" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { code, state, error, error_description } = request.query;
|
const { code, state, error, error_description } = request.query;
|
||||||
|
|
||||||
// Handle OIDC provider errors
|
// Handle OIDC provider errors
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +161,14 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Verify state
|
// Verify state
|
||||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||||
if (!storedState.valid || storedState.value !== state) {
|
if (!storedState.valid || storedState.value !== state) {
|
||||||
console.error("[OIDC] State mismatch");
|
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get code verifier
|
// Get code verifier
|
||||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||||
console.error("[OIDC] Missing code verifier");
|
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +189,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Get user info
|
// Get user info
|
||||||
const sub = tokens.claims()?.sub;
|
const sub = tokens.claims()?.sub;
|
||||||
if (!sub) {
|
if (!sub) {
|
||||||
console.error("[OIDC] Missing sub claim in token");
|
request.log.error("[OIDC] Missing sub claim in token response");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||||
}
|
}
|
||||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||||
@@ -174,7 +204,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
const oidcSubject = userInfo.sub;
|
const oidcSubject = userInfo.sub;
|
||||||
|
|
||||||
if (!username || !oidcSubject) {
|
if (!username || !oidcSubject) {
|
||||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
request.log.error(
|
||||||
|
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||||
|
"[OIDC] Missing required user info"
|
||||||
|
);
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +247,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("[OIDC] Callback error:", err);
|
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +288,7 @@ async function findOrCreateOIDCUser(
|
|||||||
|
|
||||||
// Check if auto-create is enabled
|
// Check if auto-create is enabled
|
||||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
if (!env.OIDC_AUTO_CREATE_USERS) {
|
||||||
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
// No logger is available in this helper, route-level logs already capture callback failures.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+910
-513
File diff suppressed because it is too large
Load Diff
+258
-109
@@ -6,6 +6,13 @@ import { medications, refillHistory } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } 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";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
idParamsSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
|
|
||||||
const refillSchema = z
|
const refillSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -17,9 +24,72 @@ const refillSchema = z
|
|||||||
message: "Must add at least one pack or some loose pills",
|
message: "Must add at least one pack or some loose pills",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refillBodyOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
packsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
|
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
|
usePrescription: { type: "boolean", default: false },
|
||||||
|
},
|
||||||
|
description: "Provide at least one pack or some loose pills.",
|
||||||
|
example: {
|
||||||
|
packsAdded: 1,
|
||||||
|
loosePillsAdded: 4,
|
||||||
|
usePrescription: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const refillResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
refill: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
packsAdded: { type: "integer" },
|
||||||
|
loosePillsAdded: { type: "integer" },
|
||||||
|
totalPillsAdded: { type: "number" },
|
||||||
|
refillDate: { type: "string", format: "date-time" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newStock: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
packCount: { type: "integer" },
|
||||||
|
looseTablets: { type: "integer" },
|
||||||
|
totalPills: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prescription: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
used: { type: "boolean" },
|
||||||
|
remainingRefills: { type: "integer" },
|
||||||
|
authorizedRefills: { type: "integer" },
|
||||||
|
lowRefillThreshold: { type: "integer" },
|
||||||
|
enabled: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const refillHistoryItemSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
packsAdded: { type: "integer" },
|
||||||
|
loosePillsAdded: { type: "integer" },
|
||||||
|
totalPillsAdded: { type: "number" },
|
||||||
|
usedPrescription: { type: "boolean" },
|
||||||
|
refillDate: { type: "string", format: "date-time" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export async function refillRoutes(app: FastifyInstance) {
|
export async function refillRoutes(app: FastifyInstance) {
|
||||||
// All refill routes require auth
|
// All refill routes require auth
|
||||||
app.addHook("preHandler", requireAuth);
|
app.addHook("preHandler", requireAuth);
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true });
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -35,139 +105,218 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /medications/:id/refill - Add stock to medication
|
// POST /medications/:id/refill - Add stock to medication
|
||||||
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
app.post<{ Params: { id: string } }>(
|
||||||
const parsed = refillSchema.safeParse(req.body);
|
"/medications/:id/refill",
|
||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
{
|
||||||
|
schema: {
|
||||||
|
params: idParamsSchema,
|
||||||
|
body: refillBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: refillResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
409: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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);
|
const medId = Number(req.params.id);
|
||||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const [med] = await db
|
const [med] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
if (!med) return reply.notFound("Medication not found");
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
const packageType = normalizePackageType(med.packageType);
|
||||||
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
const isBottle = packageType === "bottle";
|
||||||
const effectiveLoosePillsAdded = loosePillsAdded;
|
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
||||||
|
|
||||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
||||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
const fallbackAmountPerPackage = Math.max(
|
||||||
}
|
1,
|
||||||
|
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
|
||||||
|
);
|
||||||
|
const amountPerPackage =
|
||||||
|
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
|
||||||
|
? configuredAmountPerPackage
|
||||||
|
: fallbackAmountPerPackage;
|
||||||
|
|
||||||
if (usePrescription) {
|
const requestedPackAdds = Math.max(0, packsAdded);
|
||||||
if (!(med.prescriptionEnabled ?? false)) {
|
const requestedAmountAdds = Math.max(0, loosePillsAdded);
|
||||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
||||||
|
|
||||||
|
let effectivePacksAdded = requestedPackAdds;
|
||||||
|
if (isBottle) {
|
||||||
|
effectivePacksAdded = 0;
|
||||||
|
} else if (isCountBasedAmountPackage) {
|
||||||
|
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
|
||||||
}
|
}
|
||||||
if (remainingPrescriptionRefills <= 0) {
|
const effectiveLoosePillsAdded = isCountBasedAmountPackage
|
||||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
? effectivePacksAdded * amountPerPackage
|
||||||
|
: requestedAmountAdds;
|
||||||
|
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||||
|
|
||||||
|
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||||
|
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||||
}
|
}
|
||||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
|
||||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
if (usePrescription) {
|
||||||
|
if (!(med.prescriptionEnabled ?? false)) {
|
||||||
|
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||||
|
}
|
||||||
|
if (remainingPrescriptionRefills <= 0) {
|
||||||
|
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||||
|
}
|
||||||
|
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||||
|
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update medication stock
|
// Update medication stock
|
||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const newPackCount = med.packCount + effectivePacksAdded;
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
const previousAmountBase = med.totalPills ?? med.looseTablets;
|
||||||
|
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
|
let consumedRefills = 0;
|
||||||
const newRemainingRefills = usePrescription
|
if (usePrescription) {
|
||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
}
|
||||||
|
const newRemainingRefills = usePrescription
|
||||||
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
await db
|
const updatePayload: {
|
||||||
.update(medications)
|
packCount: number;
|
||||||
.set({
|
looseTablets: number;
|
||||||
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
prescriptionRemainingRefills: number | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
} = {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
};
|
||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
|
||||||
|
|
||||||
// Create refill history entry
|
if (isCountBasedAmountPackage) {
|
||||||
const [refill] = await db
|
updatePayload.totalPills = newTotalAmount;
|
||||||
.insert(refillHistory)
|
updatePayload.packageAmountValue = amountPerPackage;
|
||||||
.values({
|
}
|
||||||
medicationId: medId,
|
|
||||||
userId,
|
|
||||||
packsAdded: effectivePacksAdded,
|
|
||||||
loosePillsAdded: effectiveLoosePillsAdded,
|
|
||||||
usedPrescription: usePrescription,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Calculate pills added for response (packageType-aware)
|
await db
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
.update(medications)
|
||||||
const totalPillsAdded = isBottle
|
.set(updatePayload)
|
||||||
? effectiveLoosePillsAdded
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
|
||||||
const newTotalPills = isBottle
|
|
||||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
|
||||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
|
|
||||||
return {
|
// Create refill history entry
|
||||||
success: true,
|
const [refill] = await db
|
||||||
refill: {
|
.insert(refillHistory)
|
||||||
id: refill.id,
|
.values({
|
||||||
packsAdded: effectivePacksAdded,
|
medicationId: medId,
|
||||||
loosePillsAdded: effectiveLoosePillsAdded,
|
userId,
|
||||||
totalPillsAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
refillDate: refill.refillDate,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
},
|
usedPrescription: usePrescription,
|
||||||
newStock: {
|
})
|
||||||
packCount: newPackCount,
|
.returning();
|
||||||
looseTablets: newLooseTablets,
|
|
||||||
totalPills: newTotalPills,
|
// Calculate pills added for response (packageType-aware)
|
||||||
},
|
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
prescription: {
|
const totalPillsAdded = isAmountBased
|
||||||
used: usePrescription,
|
? effectiveLoosePillsAdded
|
||||||
remainingRefills: newRemainingRefills,
|
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||||
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||||
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
if (isCountBasedAmountPackage) {
|
||||||
enabled: med.prescriptionEnabled ?? false,
|
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
|
||||||
},
|
} else if (isBottle) {
|
||||||
};
|
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
refill: {
|
||||||
|
id: refill.id,
|
||||||
|
packsAdded: effectivePacksAdded,
|
||||||
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
|
totalPillsAdded,
|
||||||
|
refillDate: refill.refillDate,
|
||||||
|
},
|
||||||
|
newStock: {
|
||||||
|
packCount: newPackCount,
|
||||||
|
looseTablets: newLooseTablets,
|
||||||
|
totalPills: newTotalPills,
|
||||||
|
},
|
||||||
|
prescription: {
|
||||||
|
used: usePrescription,
|
||||||
|
remainingRefills: newRemainingRefills,
|
||||||
|
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||||
|
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
|
enabled: med.prescriptionEnabled ?? false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// GET /medications/:id/refills - Get refill history for a medication
|
// GET /medications/:id/refills - Get refill history for a medication
|
||||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
app.get<{ Params: { id: string } }>(
|
||||||
const medId = Number(req.params.id);
|
"/medications/:id/refills",
|
||||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
{
|
||||||
|
schema: {
|
||||||
|
params: idParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: { type: "array", items: refillHistoryItemSchema },
|
||||||
|
400: genericErrorSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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);
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const [med] = await db
|
const [med] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
if (!med) return reply.notFound("Medication not found");
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
// Get refill history, newest first
|
// Get refill history, newest first
|
||||||
const refills = await db
|
const refills = await db
|
||||||
.select()
|
.select()
|
||||||
.from(refillHistory)
|
.from(refillHistory)
|
||||||
.where(eq(refillHistory.medicationId, medId))
|
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
|
||||||
.orderBy(desc(refillHistory.refillDate));
|
.orderBy(desc(refillHistory.refillDate));
|
||||||
|
|
||||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
const packageType = normalizePackageType(med.packageType);
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
const isBottle = packageType === "bottle";
|
||||||
|
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||||
|
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
|
||||||
return refills.map((r) => ({
|
return refills.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate,
|
refillDate: r.refillDate,
|
||||||
}));
|
}));
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-64
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@@ -6,13 +6,61 @@ import { doseTracking, medications, refillHistory } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } 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";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
const reportDataSchema = z.object({
|
const reportDataSchema = z.object({
|
||||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reportDataBodyOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["medicationIds"],
|
||||||
|
properties: {
|
||||||
|
medicationIds: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 100,
|
||||||
|
items: { type: "integer", minimum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
medicationIds: [1, 3, 5],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const reportDataResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
dosesTaken: { type: "integer" },
|
||||||
|
automaticDosesTaken: { type: "integer" },
|
||||||
|
dosesDismissed: { type: "integer" },
|
||||||
|
firstDoseAt: { type: "string" },
|
||||||
|
lastDoseAt: { type: "string" },
|
||||||
|
refills: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
packsAdded: { type: "integer" },
|
||||||
|
loosePillsAdded: { type: "integer" },
|
||||||
|
usedPrescription: { type: "boolean" },
|
||||||
|
refillDate: { type: "string", format: "date-time" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export async function reportRoutes(app: FastifyInstance) {
|
export async function reportRoutes(app: FastifyInstance) {
|
||||||
app.addHook("preHandler", requireAuth);
|
app.addHook("preHandler", requireAuth);
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
|
||||||
|
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
@@ -27,79 +75,104 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
||||||
app.post("/medications/report-data", async (req, reply) => {
|
app.post(
|
||||||
const parsed = reportDataSchema.safeParse(req.body);
|
"/medications/report-data",
|
||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
{
|
||||||
|
schema: {
|
||||||
|
body: reportDataBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: reportDataResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
403: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = reportDataSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
const { medicationIds } = parsed.data;
|
const { medicationIds } = parsed.data;
|
||||||
|
|
||||||
// Verify all medications belong to this user
|
// Verify all medications belong to this user
|
||||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
for (const id of medicationIds) {
|
for (const id of medicationIds) {
|
||||||
if (!userMedIds.has(id)) {
|
if (!userMedIds.has(id)) {
|
||||||
return reply.status(403).send({ error: "Access denied to medication" });
|
return reply.status(403).send({ error: "Access denied to medication" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch dose tracking for all requested medications
|
// Fetch dose tracking for all requested medications
|
||||||
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||||
const allDoses = await db
|
const allDoses = await db
|
||||||
.select({
|
.select({
|
||||||
doseId: doseTracking.doseId,
|
doseId: doseTracking.doseId,
|
||||||
takenAt: doseTracking.takenAt,
|
takenAt: doseTracking.takenAt,
|
||||||
dismissed: doseTracking.dismissed,
|
dismissed: doseTracking.dismissed,
|
||||||
})
|
takenSource: doseTracking.takenSource,
|
||||||
.from(doseTracking)
|
})
|
||||||
.where(eq(doseTracking.userId, userId));
|
.from(doseTracking)
|
||||||
|
.where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
// Group doses by medication ID
|
// Group doses by medication ID
|
||||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean }[]>();
|
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||||
for (const dose of allDoses) {
|
for (const dose of allDoses) {
|
||||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, dismissed: dose.dismissed });
|
dosesByMed.get(medId)!.push({
|
||||||
}
|
takenAt: dose.takenAt,
|
||||||
|
dismissed: dose.dismissed,
|
||||||
// Fetch refill history for requested medications
|
takenSource: dose.takenSource ?? "manual",
|
||||||
const result: Record<
|
});
|
||||||
number,
|
|
||||||
{
|
|
||||||
dosesTaken: number;
|
|
||||||
dosesDismissed: number;
|
|
||||||
firstDoseAt: string | null;
|
|
||||||
lastDoseAt: string | null;
|
|
||||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
|
||||||
}
|
}
|
||||||
> = {};
|
|
||||||
|
|
||||||
for (const medId of medicationIds) {
|
// Fetch refill history for requested medications
|
||||||
const doses = dosesByMed.get(medId) ?? [];
|
const result: Record<
|
||||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
number,
|
||||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
{
|
||||||
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
|
dosesDismissed: number;
|
||||||
|
firstDoseAt: string | null;
|
||||||
|
lastDoseAt: string | null;
|
||||||
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
for (const medId of medicationIds) {
|
||||||
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
|
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
// Get refills for this medication
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
|
|
||||||
|
|
||||||
result[medId] = {
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
dosesTaken: takenDoses.length,
|
const refills = await db
|
||||||
dosesDismissed: dismissedDoses.length,
|
.select()
|
||||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
.from(refillHistory)
|
||||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
|
||||||
refills: refills.map((r) => ({
|
|
||||||
packsAdded: r.packsAdded,
|
result[medId] = {
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
dosesTaken: takenDoses.length,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
dosesDismissed: dismissedDoses.length,
|
||||||
})),
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
};
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
|
refills: refills.map((r) => ({
|
||||||
|
packsAdded: r.packsAdded,
|
||||||
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+713
-220
File diff suppressed because it is too large
Load Diff
+395
-141
@@ -1,12 +1,20 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import { buildSharedMedicationOverview } from "../services/coverage.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
tokenParamsSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
import {
|
import {
|
||||||
getAllTakenByForMedication,
|
getAllTakenByForMedication,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -14,9 +22,6 @@ import {
|
|||||||
personTakesMedication,
|
personTakesMedication,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// Share token validity: 1 year in milliseconds
|
|
||||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Validation Schemas
|
// Validation Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -25,6 +30,77 @@ const createShareSchema = z.object({
|
|||||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||||
|
{ bearerAuth: [] },
|
||||||
|
{ cookieAuth: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||||
|
|
||||||
|
const createShareBodyOpenApiSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
takenBy: { type: "string" },
|
||||||
|
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
takenBy: "Daniel",
|
||||||
|
scheduleDays: 14,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const shareReadResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
takenBy: { type: "string" },
|
||||||
|
sharedBy: { type: "string" },
|
||||||
|
scheduleDays: { type: "integer" },
|
||||||
|
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
shareMedicationOverview: { type: "boolean" },
|
||||||
|
medicationOverview: {
|
||||||
|
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
|
||||||
|
},
|
||||||
|
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
|
||||||
|
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
||||||
|
upcomingTodayOnly: { type: "boolean" },
|
||||||
|
shareScheduleTodayOnly: { type: "boolean" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const shareExpiredResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
ownerUsername: { type: "string" },
|
||||||
|
takenBy: { type: "string" },
|
||||||
|
expiredAt: { type: "string", format: "date-time" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const shareOverviewExpiredResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
expiredAt: { type: "string", format: "date-time" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const shareOverviewResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
takenBy: { type: "string" },
|
||||||
|
sharedBy: { type: "string" },
|
||||||
|
generatedAt: { type: "string", format: "date-time" },
|
||||||
|
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function maskToken(token: string): string {
|
||||||
|
if (token.length <= 8) return token;
|
||||||
|
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 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: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -45,126 +121,263 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
// Share Routes
|
// Share Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function shareRoutes(app: FastifyInstance) {
|
export async function shareRoutes(app: FastifyInstance) {
|
||||||
|
applyOpenApiRouteStandards(app, {
|
||||||
|
tag: "share",
|
||||||
|
protectedByDefault: false,
|
||||||
|
protectedPaths: [/^\/share$/, /^\/share\/people$/],
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/:token - PUBLIC: Get shared schedule by token
|
// GET /share/:token - PUBLIC: Get shared schedule by token
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
|
app.get<{ Params: { token: string } }>(
|
||||||
const { token } = request.params;
|
"/share/:token",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: tokenParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: shareReadResponseSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
410: shareExpiredResponseSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: 60,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token } = request.params;
|
||||||
|
|
||||||
// Find share token
|
// Find share token
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
if (!share) {
|
||||||
return reply.status(404).send({
|
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
|
||||||
error: "Share link not found",
|
return reply.status(404).send({
|
||||||
code: "NOT_FOUND",
|
error: "Share link not found",
|
||||||
});
|
code: "NOT_FOUND",
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if token has expired
|
// Check if token has expired
|
||||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
// Get the username of the owner to show in the expired message
|
request.log.warn(
|
||||||
|
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||||
|
);
|
||||||
|
// Get the username of the owner to show in the expired message
|
||||||
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
|
return reply.status(410).send({
|
||||||
|
error: "Share link has expired",
|
||||||
|
code: "EXPIRED",
|
||||||
|
ownerUsername: owner?.username ?? "the owner",
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
expiredAt: share.expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user settings for stock thresholds
|
||||||
|
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||||
|
|
||||||
|
// Get the username of the owner who created this share link
|
||||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
return reply.status(410).send({
|
|
||||||
error: "Share link has expired",
|
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||||
code: "EXPIRED",
|
// Use SQLite JSON function to check if takenBy is in the array
|
||||||
ownerUsername: owner?.username ?? "the owner",
|
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||||
takenBy: share.takenBy,
|
|
||||||
expiredAt: share.expiresAt.toISOString(),
|
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
||||||
|
const meds = allMeds.filter((med) => {
|
||||||
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Get user settings for stock thresholds
|
// Parse blisters and build schedule data
|
||||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
const medicationsWithBlisters = meds.map((med) => {
|
||||||
|
// Parse intakes from new format, falling back to legacy
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
|
||||||
// Get the username of the owner who created this share link
|
// Convert to legacy blisters format for backward compat
|
||||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
const blisters = intakes.map((i) => ({
|
||||||
|
usage: i.usage,
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
|
|
||||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
// Parse takenBy JSON array
|
||||||
// Use SQLite JSON function to check if takenBy is in the array
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
|
||||||
|
|
||||||
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
const totalPills = isAmountBasedPackageType(med.packageType)
|
||||||
const meds = allMeds.filter((med) => {
|
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakesJson,
|
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
|
||||||
med.intakeRemindersEnabled ?? false
|
|
||||||
);
|
|
||||||
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse blisters and build schedule data
|
|
||||||
const medicationsWithBlisters = meds.map((med) => {
|
|
||||||
// Parse intakes from new format, falling back to legacy
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakesJson,
|
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
|
||||||
med.intakeRemindersEnabled ?? false
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to legacy blisters format for backward compat
|
|
||||||
const blisters = intakes.map((i) => ({
|
|
||||||
usage: i.usage,
|
|
||||||
every: i.every,
|
|
||||||
start: i.start,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Parse takenBy JSON array
|
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
|
||||||
|
|
||||||
const totalPills =
|
|
||||||
(med.packageType ?? "blister") === "bottle"
|
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
: 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,
|
||||||
genericName: med.genericName,
|
genericName: med.genericName,
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
imageUrl: med.imageUrl,
|
imageUrl: med.imageUrl,
|
||||||
totalPills,
|
totalPills,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: normalizePackageType(med.packageType),
|
||||||
packCount: med.packCount,
|
packCount: med.packCount,
|
||||||
blistersPerPack: med.blistersPerPack,
|
blistersPerPack: med.blistersPerPack,
|
||||||
looseTablets: med.looseTablets,
|
looseTablets: med.looseTablets,
|
||||||
pillsPerBlister: med.pillsPerBlister,
|
pillsPerBlister: med.pillsPerBlister,
|
||||||
takenBy: takenByArray,
|
takenBy: takenByArray,
|
||||||
intakes, // New unified format with per-intake takenBy
|
intakes, // New unified format with per-intake takenBy
|
||||||
blisters, // Legacy format for backward compat
|
blisters, // Legacy format for backward compat
|
||||||
dismissedUntil: med.dismissedUntil,
|
dismissedUntil: med.dismissedUntil,
|
||||||
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
||||||
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
|
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
|
||||||
takenBy: share.takenBy,
|
const medicationOverview = shareMedicationOverview
|
||||||
sharedBy: owner?.username ?? null,
|
? buildSharedMedicationOverview({
|
||||||
scheduleDays: share.scheduleDays,
|
medications: meds,
|
||||||
medications: medicationsWithBlisters,
|
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
|
||||||
stockThresholds: {
|
thresholdDays: settings?.lowStockDays ?? 30,
|
||||||
lowStockDays: settings?.lowStockDays ?? 30,
|
})
|
||||||
normalStockDays: settings?.normalStockDays ?? 60,
|
: null;
|
||||||
highStockDays: settings?.highStockDays ?? 90,
|
|
||||||
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
|
return {
|
||||||
expiryWarningDays: settings?.expiryWarningDays ?? 90,
|
takenBy: share.takenBy,
|
||||||
|
sharedBy: owner?.username ?? null,
|
||||||
|
scheduleDays: share.scheduleDays,
|
||||||
|
medications: medicationsWithBlisters,
|
||||||
|
shareMedicationOverview,
|
||||||
|
medicationOverview,
|
||||||
|
stockThresholds: {
|
||||||
|
lowStockDays: settings?.lowStockDays ?? 30,
|
||||||
|
normalStockDays: settings?.normalStockDays ?? 60,
|
||||||
|
highStockDays: settings?.highStockDays ?? 90,
|
||||||
|
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
|
||||||
|
expiryWarningDays: settings?.expiryWarningDays ?? 90,
|
||||||
|
},
|
||||||
|
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
|
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get<{ Params: { token: string } }>(
|
||||||
|
"/share/:token/overview",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: tokenParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: shareOverviewResponseSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
410: shareOverviewExpiredResponseSchema,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
config: {
|
||||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
rateLimit: {
|
||||||
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
max: 60,
|
||||||
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
timeWindow: "1 minute",
|
||||||
};
|
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
reply.header("Cache-Control", "no-store");
|
||||||
|
|
||||||
|
const { token } = request.params;
|
||||||
|
if (!shareTokenPattern.test(token)) {
|
||||||
|
request.log.warn(`[ShareOverview] Rejected invalid token format: ${maskToken(token)}`);
|
||||||
|
return reply.status(404).send({ error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareOverview] Unknown token requested: ${maskToken(token)}`);
|
||||||
|
return reply.status(404).send({ error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
|
request.log.warn(
|
||||||
|
`[ShareOverview] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||||
|
);
|
||||||
|
return reply.status(410).send({
|
||||||
|
error: "expired",
|
||||||
|
expiredAt: share.expiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||||
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
|
|
||||||
|
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||||
|
const meds = allMeds.filter((med) => {
|
||||||
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||||
|
|
||||||
|
const overview = buildSharedMedicationOverview({
|
||||||
|
medications: meds,
|
||||||
|
doses,
|
||||||
|
thresholdDays: settings?.lowStockDays ?? 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
sharedBy: owner?.username ?? null,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
medications: overview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /share - PROTECTED: Create a new share link
|
// POST /share - PROTECTED: Create a new share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.post<{ Body: z.infer<typeof createShareSchema> }>(
|
app.post<{ Body: z.infer<typeof createShareSchema> }>(
|
||||||
"/share",
|
"/share",
|
||||||
{ preHandler: requireAuth },
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["share"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
body: createShareBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
reused: { type: "boolean" },
|
||||||
|
token: { type: "string" },
|
||||||
|
shareUrl: { type: "string" },
|
||||||
|
expiresAt: { type: ["string", "null"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
@@ -197,25 +410,47 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique token (8 bytes = 16 hex chars)
|
// Keep exactly one active share link per person/user.
|
||||||
|
// If a link already exists, return the same token and only update settings.
|
||||||
|
const [existingShare] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareTokens)
|
||||||
|
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||||
|
|
||||||
|
if (existingShare) {
|
||||||
|
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reused: true,
|
||||||
|
token: existingShare.token,
|
||||||
|
shareUrl: `/share/${existingShare.token}`,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const token = randomBytes(8).toString("hex");
|
const token = randomBytes(8).toString("hex");
|
||||||
|
|
||||||
// Set expiration date (1 year from now)
|
|
||||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
|
||||||
|
|
||||||
// Create share token
|
|
||||||
await db.insert(shareTokens).values({
|
await db.insert(shareTokens).values({
|
||||||
userId: userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
takenBy,
|
takenBy,
|
||||||
scheduleDays,
|
scheduleDays,
|
||||||
expiresAt,
|
expiresAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
reused: false,
|
||||||
token,
|
token,
|
||||||
shareUrl: `/share/${token}`,
|
shareUrl: `/share/${token}`,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -223,37 +458,56 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
|
app.get(
|
||||||
const userId = await getUserId(request, reply);
|
"/share/people",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["share"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
people: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
// Get all unique takenBy values for this user (from both medication-level and intake-level)
|
// Get all unique takenBy values for this user (from both medication-level and intake-level)
|
||||||
const meds = await db
|
const meds = await db
|
||||||
.select({
|
.select({
|
||||||
takenByJson: medications.takenByJson,
|
takenByJson: medications.takenByJson,
|
||||||
intakesJson: medications.intakesJson,
|
intakesJson: medications.intakesJson,
|
||||||
usageJson: medications.usageJson,
|
usageJson: medications.usageJson,
|
||||||
everyJson: medications.everyJson,
|
everyJson: medications.everyJson,
|
||||||
startJson: medications.startJson,
|
startJson: medications.startJson,
|
||||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||||
})
|
})
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(eq(medications.userId, userId));
|
.where(eq(medications.userId, userId));
|
||||||
|
|
||||||
// Collect all unique person names from medication-level AND intake-level takenBy
|
// Collect all unique person names from medication-level AND intake-level takenBy
|
||||||
const allPeople = new Set<string>();
|
const allPeople = new Set<string>();
|
||||||
for (const med of meds) {
|
for (const med of meds) {
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
med.intakesJson,
|
med.intakesJson,
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
med.intakeRemindersEnabled ?? false
|
med.intakeRemindersEnabled ?? false
|
||||||
);
|
);
|
||||||
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
|
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
|
||||||
for (const person of allForMed) {
|
for (const person of allForMed) {
|
||||||
if (person) allPeople.add(person);
|
if (person) allPeople.add(person);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { people: [...allPeople].sort() };
|
return { people: [...allPeople].sort() };
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { doseTracking, medications } from "../db/schema.js";
|
||||||
|
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||||
|
import {
|
||||||
|
getTodayInTimezone,
|
||||||
|
type Intake,
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
type MedicationRow = typeof medications.$inferSelect;
|
||||||
|
type DoseRow = typeof doseTracking.$inferSelect;
|
||||||
|
|
||||||
|
export type SharedMedicationOverviewItem = {
|
||||||
|
name: string;
|
||||||
|
genericName: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
packageType: string;
|
||||||
|
packCount: number;
|
||||||
|
blistersPerPack: number;
|
||||||
|
pillsPerBlister: number;
|
||||||
|
totalPills: number | null;
|
||||||
|
looseTablets: number;
|
||||||
|
currentStock: number | null;
|
||||||
|
capacity: number | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
nextIntakeDate: string | null;
|
||||||
|
depletionDate: string | null;
|
||||||
|
priority: "normal" | "high" | "out-of-stock" | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
medicationStartDate: string | null;
|
||||||
|
prescriptionEnabled: boolean;
|
||||||
|
prescriptionRemainingRefills: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateOnlyString(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateOnly(dateOnly: string): Date {
|
||||||
|
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCapacity(medication: MedicationRow): number {
|
||||||
|
if (isAmountBasedPackageType(medication.packageType)) {
|
||||||
|
return medication.totalPills ?? medication.looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
|
||||||
|
return intakes.reduce((sum, intake) => {
|
||||||
|
if (intake.every <= 0) return sum;
|
||||||
|
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||||
|
return sum + normalizedUsage / intake.every;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
||||||
|
const today = parseDateOnly(todayDateOnly);
|
||||||
|
let nextDate: Date | null = null;
|
||||||
|
|
||||||
|
for (const intake of intakes) {
|
||||||
|
if (intake.every <= 0) continue;
|
||||||
|
|
||||||
|
const startDate = parseLocalDateTime(intake.start);
|
||||||
|
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
|
||||||
|
|
||||||
|
let candidate = startDateOnly;
|
||||||
|
if (candidate.getTime() < today.getTime()) {
|
||||||
|
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
|
||||||
|
const intervals = Math.ceil(elapsedDays / intake.every);
|
||||||
|
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
|
||||||
|
nextDate = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDate ? toDateOnlyString(nextDate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTakenAmount(
|
||||||
|
medication: MedicationRow,
|
||||||
|
intakes: Intake[],
|
||||||
|
dosesByMedication: Map<number, DoseRow[]>
|
||||||
|
): number {
|
||||||
|
const doseRows = dosesByMedication.get(medication.id) ?? [];
|
||||||
|
if (doseRows.length === 0) return 0;
|
||||||
|
|
||||||
|
const correctionDateOnlyMs = medication.lastStockCorrectionAt
|
||||||
|
? new Date(
|
||||||
|
medication.lastStockCorrectionAt.getFullYear(),
|
||||||
|
medication.lastStockCorrectionAt.getMonth(),
|
||||||
|
medication.lastStockCorrectionAt.getDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let takenAmount = 0;
|
||||||
|
for (const dose of doseRows) {
|
||||||
|
if (dose.dismissed) continue;
|
||||||
|
|
||||||
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const intakeIndex = Number.parseInt(match[2], 10);
|
||||||
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
|
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
|
||||||
|
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
|
||||||
|
|
||||||
|
const intake = intakes[intakeIndex];
|
||||||
|
if (!intake) continue;
|
||||||
|
|
||||||
|
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return takenAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableDate(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.trim() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeOverviewPriority(
|
||||||
|
currentStock: number,
|
||||||
|
daysLeft: number | null,
|
||||||
|
thresholdDays: number
|
||||||
|
): "normal" | "high" | "out-of-stock" {
|
||||||
|
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
|
||||||
|
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSharedMedicationOverview(options: {
|
||||||
|
medications: MedicationRow[];
|
||||||
|
doses: DoseRow[];
|
||||||
|
thresholdDays: number;
|
||||||
|
}): SharedMedicationOverviewItem[] {
|
||||||
|
const { medications: medicationRows, doses, thresholdDays } = options;
|
||||||
|
|
||||||
|
const dosesByMedication = new Map<number, DoseRow[]>();
|
||||||
|
for (const dose of doses) {
|
||||||
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const medicationId = Number.parseInt(match[1], 10);
|
||||||
|
if (Number.isNaN(medicationId)) continue;
|
||||||
|
|
||||||
|
const existing = dosesByMedication.get(medicationId) ?? [];
|
||||||
|
existing.push(dose);
|
||||||
|
dosesByMedication.set(medicationId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayDateOnly = getTodayInTimezone();
|
||||||
|
const todayDate = parseDateOnly(todayDateOnly);
|
||||||
|
|
||||||
|
return medicationRows.map((medication) => {
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
medication.intakesJson,
|
||||||
|
{
|
||||||
|
usageJson: medication.usageJson,
|
||||||
|
everyJson: medication.everyJson,
|
||||||
|
startJson: medication.startJson,
|
||||||
|
},
|
||||||
|
medication.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
const capacity = computeCapacity(medication);
|
||||||
|
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
|
||||||
|
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
|
||||||
|
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
|
||||||
|
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
|
||||||
|
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
|
||||||
|
const depletionDate =
|
||||||
|
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
|
||||||
|
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
||||||
|
return {
|
||||||
|
name: medication.name,
|
||||||
|
genericName: medication.genericName,
|
||||||
|
imageUrl: medication.imageUrl,
|
||||||
|
packageType: medication.packageType,
|
||||||
|
packCount: medication.packCount,
|
||||||
|
blistersPerPack: medication.blistersPerPack,
|
||||||
|
pillsPerBlister: medication.pillsPerBlister,
|
||||||
|
totalPills: medication.totalPills,
|
||||||
|
looseTablets: medication.looseTablets,
|
||||||
|
currentStock,
|
||||||
|
capacity,
|
||||||
|
daysLeft,
|
||||||
|
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
|
||||||
|
depletionDate,
|
||||||
|
priority,
|
||||||
|
expiryDate: toNullableDate(medication.expiryDate),
|
||||||
|
medicationStartDate: toNullableDate(medication.medicationStartDate),
|
||||||
|
prescriptionEnabled: medication.prescriptionEnabled ?? false,
|
||||||
|
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { doseTracking, medications } from "../db/schema.js";
|
||||||
|
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||||
|
import {
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
parseTakenByJson,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
|
type MedicationRow = typeof medications.$inferSelect;
|
||||||
|
type DoseRow = typeof doseTracking.$inferSelect;
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
function getDoseTakenAtMs(dose: DoseRow): number {
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
|
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMedicationCurrentStock(options: {
|
||||||
|
medication: MedicationRow;
|
||||||
|
doses: DoseRow[];
|
||||||
|
stockCalculationMode: "automatic" | "manual";
|
||||||
|
nowMs?: number;
|
||||||
|
}): number {
|
||||||
|
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
medication.intakesJson,
|
||||||
|
{
|
||||||
|
usageJson: medication.usageJson,
|
||||||
|
everyJson: medication.everyJson,
|
||||||
|
startJson: medication.startJson,
|
||||||
|
},
|
||||||
|
medication.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseStock = isAmountBasedPackageType(medication.packageType)
|
||||||
|
? medication.looseTablets + (medication.stockAdjustment ?? 0)
|
||||||
|
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
|
||||||
|
medication.looseTablets +
|
||||||
|
(medication.stockAdjustment ?? 0);
|
||||||
|
|
||||||
|
const relevantDoses = doses.filter((dose) => !dose.dismissed);
|
||||||
|
const stockCorrectionCutoff = medication.lastStockCorrectionAt
|
||||||
|
? new Date(medication.lastStockCorrectionAt).getTime()
|
||||||
|
: 0;
|
||||||
|
let consumed = 0;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
|
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
|
||||||
|
|
||||||
|
intakes.forEach((intake, intakeIndex) => {
|
||||||
|
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||||
|
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
||||||
|
if (Number.isNaN(intakeStart)) return;
|
||||||
|
|
||||||
|
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||||
|
let effectiveStart: number;
|
||||||
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
|
||||||
|
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
|
||||||
|
} else {
|
||||||
|
effectiveStart = intakeStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intake.takenBy) {
|
||||||
|
peopleForThisIntake = [intake.takenBy];
|
||||||
|
} else if (medicationTakenBy.length > 0) {
|
||||||
|
peopleForThisIntake = medicationTakenBy;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
if (effectiveStart <= nowMs) {
|
||||||
|
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
|
||||||
|
consumed += occurrences * usage * peopleForThisIntake.length;
|
||||||
|
|
||||||
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
|
lastAutoConsumedDateMs = new Date(
|
||||||
|
lastDoseTime.getFullYear(),
|
||||||
|
lastDoseTime.getMonth(),
|
||||||
|
lastDoseTime.getDate()
|
||||||
|
).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||||
|
|
||||||
|
for (const dose of relevantDoses) {
|
||||||
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
|
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doseDateOnlyMs > earlyCutoff) {
|
||||||
|
consumed += usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
intakes.forEach((intake, intakeIndex) => {
|
||||||
|
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||||
|
const intakeStart = parseLocalDateTime(intake.start);
|
||||||
|
const intakeStartDateOnly = new Date(
|
||||||
|
intakeStart.getFullYear(),
|
||||||
|
intakeStart.getMonth(),
|
||||||
|
intakeStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(intakeStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const dose of relevantDoses) {
|
||||||
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
|
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAtMs = getDoseTakenAtMs(dose);
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
|
||||||
|
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
|
consumed += usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.floor(baseStock - consumed));
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { and, eq, gte, lte } from "drizzle-orm";
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { doseTracking, medications } from "../db/schema.js";
|
import { doseTracking, medications, users } from "../db/schema.js";
|
||||||
import {
|
import {
|
||||||
getDateLocale,
|
getDateLocale,
|
||||||
getFooterHtml,
|
getFooterHtml,
|
||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
type UpcomingIntake,
|
type UpcomingIntake,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.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);
|
||||||
@@ -50,6 +52,190 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
|
const intakeDate = intake.intakeTime;
|
||||||
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
|
if (intake.takenBy) {
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
|
}
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSchedulerUserDisplayName(userId: number): Promise<string> {
|
||||||
|
const [userRow] = await db.select({ username: users.username }).from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
return userRow?.username?.trim() || `unknown-user-${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIntakeDescriptor(
|
||||||
|
definitionIndex: number,
|
||||||
|
medicationName: string,
|
||||||
|
medicationId: number,
|
||||||
|
intake: { every: number; usage: number; start: string; intakeRemindersEnabled: boolean; takenBy: string | null }
|
||||||
|
): string {
|
||||||
|
const takenByPart = intake.takenBy ? `, takenBy=${intake.takenBy}` : "";
|
||||||
|
return `Intake #${definitionIndex + 1} (index=${definitionIndex}, medication=${medicationName}, medicationId=${medicationId}, start=${intake.start}, every=${intake.every}d, usage=${intake.usage}, reminderEnabled=${intake.intakeRemindersEnabled}${takenByPart})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoMarkDueIntakesAsTaken(
|
||||||
|
settings: UserSettings & { userId: number },
|
||||||
|
rows: (typeof medications.$inferSelect)[],
|
||||||
|
locale: string,
|
||||||
|
tz: string,
|
||||||
|
logger: ServiceLogger
|
||||||
|
): Promise<number> {
|
||||||
|
if (settings.stockCalculationMode !== "automatic") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
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);
|
||||||
|
|
||||||
|
const existingToday = await db
|
||||||
|
.select({ doseId: doseTracking.doseId })
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, settings.userId),
|
||||||
|
gte(doseTracking.takenAt, todayStart),
|
||||||
|
lte(doseTracking.takenAt, todayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||||
|
const trackedDoses = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const med of rows) {
|
||||||
|
if (med.isObsolete) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
if (intakes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const medDisplayName = med.name || med.genericName || "";
|
||||||
|
let remainingStock = computeMedicationCurrentStock({
|
||||||
|
medication: med,
|
||||||
|
doses: trackedDoses,
|
||||||
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
nowMs: now.getTime(),
|
||||||
|
});
|
||||||
|
if (remainingStock <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const todaysIntakes = getTodaysIntakes(
|
||||||
|
medDisplayName,
|
||||||
|
intakes,
|
||||||
|
medicationTakenBy,
|
||||||
|
med.pillWeightMg,
|
||||||
|
locale,
|
||||||
|
tz,
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const intake of todaysIntakes) {
|
||||||
|
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doseId = buildDoseIdForIntake({
|
||||||
|
...intake,
|
||||||
|
medicationId: intake.medicationId,
|
||||||
|
blisterIndex: intake.blisterIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDoseIds.has(doseId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakeDefinition = intakes[intake.blisterIndex];
|
||||||
|
const usage = intakeDefinition
|
||||||
|
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
|
||||||
|
: 0;
|
||||||
|
if (remainingStock <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: settings.userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: intake.intakeTime,
|
||||||
|
markedBy: null,
|
||||||
|
takenSource: "automatic",
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
existingDoseIds.add(doseId);
|
||||||
|
trackedDoses.push({
|
||||||
|
id: 0,
|
||||||
|
userId: settings.userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: intake.intakeTime,
|
||||||
|
markedBy: null,
|
||||||
|
takenSource: "automatic",
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
remainingStock = Math.max(0, remainingStock - usage);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inserted > 0) {
|
||||||
|
logger.info(`[IntakeReminder] Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendIntakeReminderEmail(
|
async function sendIntakeReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
intakes: UpcomingIntake[],
|
intakes: UpcomingIntake[],
|
||||||
@@ -58,7 +244,7 @@ async function sendIntakeReminderEmail(
|
|||||||
repeatIntervalMinutes?: number,
|
repeatIntervalMinutes?: number,
|
||||||
currentCount?: number,
|
currentCount?: number,
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: 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
|
||||||
@@ -202,7 +388,7 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `💊 ${subject}`,
|
subject: `💊 ${subject}`,
|
||||||
@@ -210,7 +396,16 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
return { success: false, error: deliveryError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: mailResult.messageId,
|
||||||
|
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
@@ -228,107 +423,113 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
|
|||||||
return; // No users with settings
|
return; // No users with settings
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
logger.debug(`[IntakeReminder] Evaluating ${allUserSettings.length} intake profile(s) for auto-marking`);
|
||||||
|
|
||||||
for (const userSettings of allUserSettings) {
|
for (const userSettings of allUserSettings) {
|
||||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendIntakeRemindersForUser(
|
export async function checkAndSendIntakeRemindersForUser(
|
||||||
settings: UserSettings & { userId: number },
|
settings: UserSettings & { userId: number },
|
||||||
logger: ServiceLogger
|
logger: ServiceLogger
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const language = settings.language;
|
const language = settings.language;
|
||||||
const tr = getTranslations(language);
|
const tr = getTranslations(language);
|
||||||
|
const schedulerUserName = await resolveSchedulerUserDisplayName(settings.userId);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] Evaluating intake reminder profile for user '${schedulerUserName}'`);
|
||||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
|
||||||
);
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, settings.userId))
|
||||||
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const locale = getDateLocale(language);
|
||||||
|
const tz = getTimezone();
|
||||||
|
|
||||||
|
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||||
|
|
||||||
// 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.debug(
|
|
||||||
`[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.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
`[IntakeReminder] Notifications enabled for current scheduler context (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Build medication entries that have at least one reminder-enabled intake.
|
||||||
const rows = await db
|
// Intake-level reminders are the single source of truth.
|
||||||
.select()
|
const reminderEntries = rows
|
||||||
.from(medications)
|
.map((med) => {
|
||||||
.where(eq(medications.userId, settings.userId))
|
const intakes = parseIntakesJson(
|
||||||
.orderBy(medications.id);
|
med.intakesJson,
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
|
||||||
|
return { med, intakes, intakesWithReminders };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.intakesWithReminders.length > 0);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (reminderEntries.length === 0) {
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
logger.debug("[IntakeReminder] No medications have reminders enabled for current scheduler context");
|
||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] Found ${reminderEntries.length} medications with reminders`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
const locale = getDateLocale(language);
|
let scheduledIntakesTodayCount = 0;
|
||||||
const tz = getTimezone();
|
|
||||||
|
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const checkMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||||
|
const checkMinuteEnd = new Date(checkMinuteStart.getTime() + 60000);
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
todayEnd.setHours(23, 59, 59, 999);
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const med of medsWithReminders) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||||
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakesJson,
|
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
|
||||||
med.intakeRemindersEnabled ?? false
|
|
||||||
);
|
|
||||||
// Medication-level takenBy (for fallback/display purposes)
|
// Medication-level takenBy (for fallback/display purposes)
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const medDisplayName = med.name || med.genericName || "";
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
|
`[IntakeReminder] Processing medication '${medDisplayName}' (id=${med.id}) with ${intakes.length} intake definition(s)`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
|
||||||
const intakesWithReminders = intakes.filter((intake, idx) => {
|
|
||||||
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
|
|
||||||
if (!hasReminder) {
|
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
|
|
||||||
}
|
|
||||||
return hasReminder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||||
logger.debug(
|
const intakeDescriptor = formatIntakeDescriptor(actualIndex, medDisplayName, med.id, intake);
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
logger.debug(`[IntakeReminder] ${intakeDescriptor}`);
|
||||||
|
|
||||||
|
const todaysIntakesForThisDefinition = getTodaysIntakes(
|
||||||
|
medDisplayName,
|
||||||
|
[intake],
|
||||||
|
medicationTakenBy,
|
||||||
|
med.pillWeightMg,
|
||||||
|
locale,
|
||||||
|
tz,
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
);
|
);
|
||||||
|
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
|
||||||
|
|
||||||
// Always get upcoming intakes (15 min before) for first reminders
|
// Always get upcoming intakes (15 min before) for first reminders
|
||||||
const upcomingIntakes = getUpcomingIntakes(
|
const upcomingIntakes = getUpcomingIntakes(
|
||||||
med.name,
|
medDisplayName,
|
||||||
[intake],
|
[intake],
|
||||||
REMINDER_MINUTES_BEFORE,
|
REMINDER_MINUTES_BEFORE,
|
||||||
medicationTakenBy,
|
medicationTakenBy,
|
||||||
@@ -340,7 +541,10 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
med.doseUnit ?? "mg"
|
med.doseUnit ?? "mg"
|
||||||
);
|
);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
|
`[IntakeReminder] ${intakeDescriptor} -> ${upcomingIntakes.length} intake(s) currently due for advance reminder (default ${REMINDER_MINUTES_BEFORE} min before intake, with catch-up while intake is still in the future)`
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} scheduled intake(s) today (independent of reminder window)`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add upcoming intakes for first reminders
|
// Add upcoming intakes for first reminders
|
||||||
@@ -354,24 +558,14 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||||
if (settings.repeatRemindersEnabled) {
|
if (settings.repeatRemindersEnabled) {
|
||||||
const allTodaysIntakes = getTodaysIntakes(
|
|
||||||
med.name,
|
|
||||||
[intake],
|
|
||||||
medicationTakenBy,
|
|
||||||
med.pillWeightMg,
|
|
||||||
locale,
|
|
||||||
tz,
|
|
||||||
med.id,
|
|
||||||
med.doseUnit ?? "mg"
|
|
||||||
);
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
|
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} candidate intake(s) for repeat reminders`
|
||||||
);
|
);
|
||||||
const missedIntakes = allTodaysIntakes.filter(
|
const missedIntakes = todaysIntakesForThisDefinition.filter(
|
||||||
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
|
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
|
||||||
);
|
);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
|
`[IntakeReminder] ${intakeDescriptor} -> ${missedIntakes.length} missed intake(s) (past intake time)`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||||
@@ -389,10 +583,13 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
|
logger.debug(`[IntakeReminder] Total scheduled intakes for today: ${scheduledIntakesTodayCount}`);
|
||||||
|
logger.debug(`[IntakeReminder] Total reminder candidates in current check: ${allUpcoming.length}`);
|
||||||
|
|
||||||
if (allUpcoming.length === 0) {
|
if (allUpcoming.length === 0) {
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
|
logger.debug(
|
||||||
|
`[IntakeReminder] No reminder due in this check window (minute=${checkMinuteStart.toISOString()}..${checkMinuteEnd.toISOString()}, advanceLead=${REMINDER_MINUTES_BEFORE}m, plus catch-up while intake is still future)`
|
||||||
|
);
|
||||||
return; // No upcoming intakes for today
|
return; // No upcoming intakes for today
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +621,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
// Send a catch-up reminder (counts as first nagging reminder).
|
// Send a catch-up reminder (counts as first nagging reminder).
|
||||||
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
|
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
|
||||||
logger.info(
|
logger.info(
|
||||||
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
|
`[IntakeReminder] Catch-up reminder for recently missed intake (${Math.round(minutesSinceIntake)} min ago)`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Long ago — seed state without notification (user likely already noticed)
|
// Long ago — seed state without notification (user likely already noticed)
|
||||||
@@ -435,15 +632,13 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
advanceSent: false,
|
advanceSent: false,
|
||||||
};
|
};
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
|
`[IntakeReminder] Seeding state for old past intake (no notification — ${Math.round(minutesSinceIntake)} min ago)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Upcoming - this is advance reminder (no counter)
|
// Upcoming - this is advance reminder (no counter)
|
||||||
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
|
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
|
||||||
logger.debug(
|
logger.debug("[IntakeReminder] Advance reminder candidate added");
|
||||||
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
||||||
// Intake time passed - check if we need to send nagging reminder
|
// Intake time passed - check if we need to send nagging reminder
|
||||||
@@ -456,15 +651,11 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
if (currentNaggingCount >= maxReminders) {
|
if (currentNaggingCount >= maxReminders) {
|
||||||
// Max nagging reminders reached - stop
|
// Max nagging reminders reached - stop
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] Max nagging (${maxReminders}) reached for intake reminder key`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
|
|
||||||
);
|
|
||||||
} else if (timeSinceLastReminder >= intervalMs) {
|
} else if (timeSinceLastReminder >= intervalMs) {
|
||||||
const nextSendCount = currentNaggingCount + 1;
|
const nextSendCount = currentNaggingCount + 1;
|
||||||
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
|
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] Nagging reminder candidate added (${nextSendCount}/${maxReminders})`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
||||||
@@ -502,9 +693,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
const isTaken = takenDoseIds.has(doseId);
|
const isTaken = takenDoseIds.has(doseId);
|
||||||
if (isTaken) {
|
if (isTaken) {
|
||||||
logger.debug(
|
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
|
||||||
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return !isTaken;
|
return !isTaken;
|
||||||
} else {
|
} else {
|
||||||
@@ -512,21 +701,19 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
const isTaken = takenDoseIds.has(doseId);
|
const isTaken = takenDoseIds.has(doseId);
|
||||||
if (isTaken) {
|
if (isTaken) {
|
||||||
logger.debug(
|
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
|
||||||
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return !isTaken;
|
return !isTaken;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (remindersToSend.length === 0) {
|
if (remindersToSend.length === 0) {
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
|
logger.debug("[IntakeReminder] All doses taken, skipping reminders");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
|
logger.info(`[IntakeReminder] Sending reminder for ${remindersToSend.length} intakes...`);
|
||||||
|
|
||||||
// Determine if this is a repeat reminder:
|
// Determine if this is a repeat reminder:
|
||||||
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||||
@@ -558,9 +745,9 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
logger.info("[IntakeReminder] Email sent successfully");
|
||||||
} else {
|
} else {
|
||||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,9 +811,9 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
|
logger.info("[IntakeReminder] Push notification sent successfully");
|
||||||
} else {
|
} else {
|
||||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
|
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { medications, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
import {
|
import {
|
||||||
type Blister,
|
type Blister,
|
||||||
@@ -19,8 +25,11 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
parseBlisters,
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
|
parseTakenByJson,
|
||||||
type ReminderState,
|
type ReminderState,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
@@ -35,9 +44,89 @@ function escapeHtml(text: string): string {
|
|||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
|
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||||
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function ensureReminderLocksDir(): void {
|
||||||
|
if (!existsSync(reminderLocksDir)) {
|
||||||
|
mkdirSync(reminderLocksDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireReminderSendLock(lockKey: string): string | null {
|
||||||
|
ensureReminderLocksDir();
|
||||||
|
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
|
||||||
|
|
||||||
|
const tryCreateLock = (): boolean => {
|
||||||
|
try {
|
||||||
|
const fd = openSync(lockFilePath, "wx");
|
||||||
|
closeSync(fd);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(lockFilePath);
|
||||||
|
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; lock acquisition fails safely
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||||
|
if (!lockFilePath) return;
|
||||||
|
try {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
} catch {
|
||||||
|
// ignore release errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
@@ -119,10 +208,6 @@ export async function updateUserReminderSentTime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
|
||||||
return parseBlisters(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
@@ -131,6 +216,12 @@ type LowStockItem = {
|
|||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||||
|
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
return { lowDays, criticalDays };
|
||||||
|
}
|
||||||
|
|
||||||
type PrescriptionReminderItem = {
|
type PrescriptionReminderItem = {
|
||||||
name: string;
|
name: string;
|
||||||
remainingRefills: number;
|
remainingRefills: number;
|
||||||
@@ -142,7 +233,8 @@ async function getMedicationsNeedingReminder(
|
|||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
language: Language
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
): Promise<LowStockItem[]> {
|
): Promise<LowStockItem[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -150,25 +242,175 @@ async function getMedicationsNeedingReminder(
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const takenDoseRows = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
|
for (const dose of takenDoseRows) {
|
||||||
|
const parts = dose.doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const medId = parseInt(parts[0], 10);
|
||||||
|
if (Number.isNaN(medId)) continue;
|
||||||
|
|
||||||
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
|
}
|
||||||
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
let takenAtMs: number;
|
||||||
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
|
} else {
|
||||||
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
const lowStock: LowStockItem[] = [];
|
const lowStock: LowStockItem[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const packageType = normalizePackageType(row.packageType);
|
||||||
const totalPills =
|
// Tube stock reminders are intentionally disabled:
|
||||||
(row.packageType ?? "blister") === "bottle"
|
// topical usage in grams cannot be mapped reliably to schedule events.
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
if (isTubePackageType(packageType)) continue;
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
const intakes = parseIntakesJson(
|
||||||
|
row.intakesJson,
|
||||||
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
|
row.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
const blisters: Blister[] = intakes.map((i) => ({
|
||||||
|
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||||
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
|
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
|
|
||||||
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
|
|
||||||
|
let consumed = 0;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
|
let effectiveStart: number;
|
||||||
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
|
} else {
|
||||||
|
effectiveStart = blisterStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intakePerson) {
|
||||||
|
peopleForThisIntake = [intakePerson];
|
||||||
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeBasedConsumed = 0;
|
||||||
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|
||||||
|
if (effectiveStart <= now) {
|
||||||
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
|
|
||||||
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
|
lastAutoConsumedDateMs = new Date(
|
||||||
|
lastDoseTime.getFullYear(),
|
||||||
|
lastDoseTime.getMonth(),
|
||||||
|
lastDoseTime.getDate()
|
||||||
|
).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start);
|
||||||
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStart.getFullYear(),
|
||||||
|
blisterStart.getMonth(),
|
||||||
|
blisterStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
|
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
|
consumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||||
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||||
|
|
||||||
if (daysLeft === null) continue;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
const isCritical = daysLeft <= reminderDaysBefore;
|
const isLiquid = isLiquidContainerPackageType(packageType);
|
||||||
const isLow = daysLeft < lowStockDays;
|
const { lowDays, criticalDays } = isLiquid
|
||||||
|
? getLiquidReminderThresholds(reminderDaysBefore)
|
||||||
|
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
||||||
|
|
||||||
|
const isCritical = daysLeft <= criticalDays;
|
||||||
|
const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays;
|
||||||
|
|
||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
medsLeft: totalPills,
|
medsLeft: currentPills,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
isCritical,
|
isCritical,
|
||||||
@@ -200,6 +442,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||||
|
export async function getMedicationsNeedingReminderForTests(
|
||||||
|
userId: number,
|
||||||
|
reminderDaysBefore: number,
|
||||||
|
lowStockDays: number,
|
||||||
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
name: string;
|
||||||
|
medsLeft: number;
|
||||||
|
daysLeft: number | null;
|
||||||
|
depletionDate: string | null;
|
||||||
|
isCritical: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendReminderEmail(
|
async function sendReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
lowStock: LowStockItem[],
|
lowStock: LowStockItem[],
|
||||||
@@ -346,7 +607,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -354,6 +615,11 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
@@ -362,6 +628,15 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
||||||
|
// Track stock-scheduler daily execution separately from intake updates.
|
||||||
|
// This prevents intake reminders from suppressing stock catch-up after restarts.
|
||||||
|
const state = loadReminderState();
|
||||||
|
const today = getTodayInTimezone();
|
||||||
|
saveReminderState({
|
||||||
|
...state,
|
||||||
|
lastStockSchedulerCheckDate: today,
|
||||||
|
});
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
@@ -403,172 +678,212 @@ async function checkAndSendReminderForUser(
|
|||||||
settings.userId,
|
settings.userId,
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
language
|
language,
|
||||||
|
settings.stockCalculationMode
|
||||||
);
|
);
|
||||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||||
|
|
||||||
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
logger.info(
|
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
if (!stockSendLock) {
|
||||||
);
|
logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`);
|
||||||
|
|
||||||
let emailSuccess = false;
|
let emailSuccess = false;
|
||||||
let shoutrrrSuccess = false;
|
let shoutrrrSuccess = false;
|
||||||
|
|
||||||
if (stockEmailEnabled) {
|
if (stockEmailEnabled) {
|
||||||
const result = await sendReminderEmail(
|
const result = await sendReminderEmail(
|
||||||
settings.notificationEmail!,
|
settings.notificationEmail!,
|
||||||
allLowStock,
|
allLowStock,
|
||||||
language,
|
language,
|
||||||
settings.repeatDailyReminders
|
settings.repeatDailyReminders
|
||||||
);
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
logger.error(`[Reminder] Failed to send stock email: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stockPushEnabled) {
|
||||||
|
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||||
|
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||||
|
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||||
|
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||||
|
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||||
|
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||||
|
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||||
|
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||||
|
}
|
||||||
|
if (criticalMeds.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||||
|
criticalMeds.forEach((m) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lowStockMeds.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||||
|
lowStockMeds.forEach((m) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
|
shoutrrrSuccess = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "stock",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||||
|
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(stockSendLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stockPushEnabled) {
|
|
||||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
|
||||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
|
||||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
|
||||||
|
|
||||||
const titleParts: string[] = [];
|
|
||||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
|
||||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
|
||||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
|
||||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
|
||||||
if (emptyMeds.length > 0) {
|
|
||||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
|
||||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
|
||||||
}
|
|
||||||
if (criticalMeds.length > 0) {
|
|
||||||
if (messageParts.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
|
||||||
criticalMeds.forEach((m) =>
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (lowStockMeds.length > 0) {
|
|
||||||
if (messageParts.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
|
||||||
lowStockMeds.forEach((m) =>
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
|
||||||
shoutrrrSuccess = result.success;
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
|
||||||
const currentState = loadReminderState();
|
|
||||||
const singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "stock",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
|
||||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
logger.info(
|
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
if (!prescriptionSendLock) {
|
||||||
);
|
logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
|
||||||
|
// This blocks duplicate sends when two reminder checks overlap in time.
|
||||||
|
const lockedState = loadReminderState();
|
||||||
|
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
|
||||||
|
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
|
||||||
|
if (!shouldSend) {
|
||||||
|
logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
const preMarkedNotified =
|
||||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
!shouldSend || alreadyNotified
|
||||||
const lines = allPrescriptionLow.map((m) => {
|
? lockedState.notifiedMedications
|
||||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
: [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])];
|
||||||
if (m.remainingRefills <= 0) {
|
if (shouldSend && !alreadyNotified) {
|
||||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
saveReminderState({
|
||||||
name: m.name,
|
lastAutoEmailSent: lockedState.lastAutoEmailSent,
|
||||||
expirySuffix,
|
lastAutoEmailDate: lockedState.lastAutoEmailDate,
|
||||||
})}`;
|
lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate,
|
||||||
}
|
notifiedMedications: preMarkedNotified,
|
||||||
return `- ${t(tr.prescriptionReminder.line, {
|
nextScheduledCheck: lockedState.nextScheduledCheck,
|
||||||
name: m.name,
|
lastNotificationType: lockedState.lastNotificationType,
|
||||||
refills: m.remainingRefills,
|
lastNotificationChannel: lockedState.lastNotificationChannel,
|
||||||
expirySuffix,
|
});
|
||||||
})}`;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
let emailSuccess = false;
|
if (shouldSend) {
|
||||||
let shoutrrrSuccess = false;
|
logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`);
|
||||||
|
|
||||||
if (prescriptionEmailEnabled) {
|
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const lines = allPrescriptionLow.map((m) => {
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
if (m.remainingRefills <= 0) {
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
name: m.name,
|
||||||
|
expirySuffix,
|
||||||
if (smtpHost && smtpUser) {
|
})}`;
|
||||||
try {
|
}
|
||||||
const transporter = nodemailer.createTransport({
|
return `- ${t(tr.prescriptionReminder.line, {
|
||||||
host: smtpHost,
|
name: m.name,
|
||||||
port: smtpPort,
|
refills: m.remainingRefills,
|
||||||
secure: smtpSecure,
|
expirySuffix,
|
||||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
})}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const subject =
|
let emailSuccess = false;
|
||||||
allPrescriptionLow.length === 1
|
let shoutrrrSuccess = false;
|
||||||
? tr.prescriptionReminder.subjectSingle
|
|
||||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
|
||||||
|
|
||||||
const bodyText =
|
if (prescriptionEmailEnabled) {
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const emptyAlert =
|
const smtpUser = process.env.SMTP_USER;
|
||||||
emptyRx.length === 1
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const lowAlert =
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
lowRx.length === 1
|
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
|
||||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
|
||||||
|
|
||||||
const tableRows = allPrescriptionLow
|
if (smtpHost && smtpUser) {
|
||||||
.map((item) => {
|
try {
|
||||||
const isEmpty = item.remainingRefills <= 0;
|
const transporter = nodemailer.createTransport({
|
||||||
const safeName = escapeHtml(item.name);
|
host: smtpHost,
|
||||||
const safeRefills = Number(item.remainingRefills) || 0;
|
port: smtpPort,
|
||||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
secure: smtpSecure,
|
||||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
});
|
||||||
return `
|
|
||||||
|
const subject =
|
||||||
|
allPrescriptionLow.length === 1
|
||||||
|
? tr.prescriptionReminder.subjectSingle
|
||||||
|
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||||
|
|
||||||
|
const bodyText =
|
||||||
|
emptyRx.length > 0
|
||||||
|
? tr.prescriptionReminder.descriptionEmpty
|
||||||
|
: tr.prescriptionReminder.descriptionLow;
|
||||||
|
const emptyAlert =
|
||||||
|
emptyRx.length === 1
|
||||||
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
|
const tableRows = allPrescriptionLow
|
||||||
|
.map((item) => {
|
||||||
|
const isEmpty = item.remainingRefills <= 0;
|
||||||
|
const safeName = escapeHtml(item.name);
|
||||||
|
const safeRefills = Number(item.remainingRefills) || 0;
|
||||||
|
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||||
|
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||||
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</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>${safeRefills}</strong></td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
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;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
||||||
@@ -608,80 +923,105 @@ async function checkAndSendReminderForUser(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
emailSuccess = true;
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
} catch (error) {
|
if (deliveryError) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
throw new Error(deliveryError);
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
}
|
||||||
|
emailSuccess = true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prescriptionPushEnabled) {
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyRx.length > 0)
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||||
|
);
|
||||||
|
if (lowRx.length > 0)
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||||
|
);
|
||||||
|
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyRx.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||||
|
for (const m of emptyRx) {
|
||||||
|
messageParts.push(` • ${m.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lowRx.length > 0) {
|
||||||
|
if (emptyRx.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||||
|
for (const m of lowRx) {
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
|
shoutrrrSuccess = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "prescription",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||||
|
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||||
|
} else if (!alreadyNotified) {
|
||||||
|
// Roll back pre-mark when both channels failed so retries remain possible.
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: currentState.lastAutoEmailSent,
|
||||||
|
lastAutoEmailDate: currentState.lastAutoEmailDate,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: currentState.notifiedMedications.filter(
|
||||||
|
(key) => key !== userPrescriptionNotifiedKey
|
||||||
|
),
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: currentState.lastNotificationType,
|
||||||
|
lastNotificationChannel: currentState.lastNotificationChannel,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(prescriptionSendLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prescriptionPushEnabled) {
|
|
||||||
const titleParts: string[] = [];
|
|
||||||
if (emptyRx.length > 0)
|
|
||||||
titleParts.push(
|
|
||||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
|
||||||
);
|
|
||||||
if (lowRx.length > 0)
|
|
||||||
titleParts.push(
|
|
||||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
|
||||||
);
|
|
||||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
|
||||||
if (emptyRx.length > 0) {
|
|
||||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
|
||||||
for (const m of emptyRx) {
|
|
||||||
messageParts.push(` • ${m.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lowRx.length > 0) {
|
|
||||||
if (emptyRx.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
|
||||||
for (const m of lowRx) {
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
|
||||||
shoutrrrSuccess = result.success;
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
|
||||||
const currentState = loadReminderState();
|
|
||||||
const singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "prescription",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
|
||||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||||
|
let schedulerStarted = false;
|
||||||
|
|
||||||
function scheduleNextCheck(logger: ServiceLogger): void {
|
function scheduleNextCheck(logger: ServiceLogger): void {
|
||||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||||
@@ -706,6 +1046,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startReminderScheduler(logger: ServiceLogger): void {
|
export function startReminderScheduler(logger: ServiceLogger): void {
|
||||||
|
if (schedulerStarted) {
|
||||||
|
logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulerStarted = true;
|
||||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||||
|
|
||||||
// Check if we need to run immediately (missed today's check)
|
// Check if we need to run immediately (missed today's check)
|
||||||
@@ -713,9 +1058,10 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
const today = getTodayInTimezone();
|
const today = getTodayInTimezone();
|
||||||
const currentHour = getCurrentHourInTimezone();
|
const currentHour = getCurrentHourInTimezone();
|
||||||
|
|
||||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run one catch-up.
|
||||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
// This is intentionally a single current-state snapshot (no replay of missed days).
|
||||||
logger.info("[Reminder] Missed today's check, running now...");
|
if (currentHour >= REMINDER_HOUR && state.lastStockSchedulerCheckDate !== today) {
|
||||||
|
logger.info("[Reminder] Missed today's check, running one catch-up snapshot (no historical replay)...");
|
||||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,9 +1071,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runReminderSchedulerNow(logger: ServiceLogger): Promise<void> {
|
||||||
|
logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`);
|
||||||
|
await checkAndSendReminder(logger);
|
||||||
|
}
|
||||||
|
|
||||||
export function stopReminderScheduler(): void {
|
export function stopReminderScheduler(): void {
|
||||||
if (schedulerTimeout) {
|
if (schedulerTimeout) {
|
||||||
clearTimeout(schedulerTimeout);
|
clearTimeout(schedulerTimeout);
|
||||||
schedulerTimeout = null;
|
schedulerTimeout = null;
|
||||||
}
|
}
|
||||||
|
schedulerStarted = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sensible from "@fastify/sensible";
|
|||||||
import type { Client } from "@libsql/client";
|
import type { Client } from "@libsql/client";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||||
const { testClient, testDb } = vi.hoisted(() => {
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
@@ -28,7 +29,7 @@ vi.mock("../db/client.js", () => ({
|
|||||||
vi.mock("../plugins/env.js", () => ({
|
vi.mock("../plugins/env.js", () => ({
|
||||||
env: {
|
env: {
|
||||||
AUTH_ENABLED: true,
|
AUTH_ENABLED: true,
|
||||||
LOCAL_AUTH_ENABLED: true,
|
FORM_LOGIN_ENABLED: true,
|
||||||
REGISTRATION_ENABLED: true,
|
REGISTRATION_ENABLED: true,
|
||||||
OIDC_ENABLED: false,
|
OIDC_ENABLED: false,
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
@@ -97,7 +98,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await createSchema(testClient);
|
await createSchema(testClient);
|
||||||
|
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(cookie, { secret: "test-cookie-secret-12345" });
|
await app.register(cookie, { secret: "test-cookie-secret-12345" });
|
||||||
@@ -144,7 +145,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.authEnabled).toBe(true);
|
expect(data.authEnabled).toBe(true);
|
||||||
expect(data.registrationEnabled).toBe(true);
|
expect(data.registrationEnabled).toBe(true);
|
||||||
expect(data.localAuthEnabled).toBe(true);
|
expect(data.formLoginEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
expect(response.json().code).toBe("FST_ERR_VALIDATION");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject short username", async () => {
|
it("should reject short username", async () => {
|
||||||
@@ -241,10 +242,61 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().code).toBe("FST_ERR_VALIDATION");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should register with trimmed username when input has whitespace", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/register",
|
||||||
|
payload: {
|
||||||
|
username: " trimuser ",
|
||||||
|
password: "TestPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
expect(response.json().user.username).toBe("trimuser");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject whitespace-only username on registration", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/register",
|
||||||
|
payload: {
|
||||||
|
username: " ",
|
||||||
|
password: "TestPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject duplicate username even with surrounding whitespace", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/register",
|
||||||
|
payload: {
|
||||||
|
username: "spacedupe",
|
||||||
|
password: "TestPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/register",
|
||||||
|
payload: {
|
||||||
|
username: " spacedupe ",
|
||||||
|
password: "AnotherPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||||
|
});
|
||||||
|
|
||||||
it("should reject invalid username characters", async () => {
|
it("should reject invalid username characters", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -341,6 +393,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should login successfully when username has leading/trailing whitespace", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/login",
|
||||||
|
payload: {
|
||||||
|
username: " loginuser ",
|
||||||
|
password: "TestPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().ok).toBe(true);
|
||||||
|
expect(response.json().user.username).toBe("loginuser");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject whitespace-only username on login", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/login",
|
||||||
|
payload: {
|
||||||
|
username: " ",
|
||||||
|
password: "TestPassword123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
it("should support rememberMe option", async () => {
|
it("should support rememberMe option", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv } = 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,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: true,
|
||||||
|
REGISTRATION_ENABLED: true,
|
||||||
|
FORM_LOGIN_ENABLED: true,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: "*",
|
||||||
|
JWT_SECRET: "test-jwt-secret",
|
||||||
|
REFRESH_SECRET: "test-refresh-secret",
|
||||||
|
COOKIE_SECRET: "test-cookie-secret",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
OPENAPI_DOCS_ENABLED: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
|
const { doseRoutes } = await import("../routes/doses.js");
|
||||||
|
const { refillRoutes } = await import("../routes/refills.js");
|
||||||
|
const { shareRoutes } = await import("../routes/share.js");
|
||||||
|
const { reportRoutes } = await import("../routes/report.js");
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
const { hashApiKeyToken } = await import("../plugins/auth.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM api_keys");
|
||||||
|
await testClient.execute("DELETE FROM refresh_tokens");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username: string) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||||
|
args: [username],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
|
const token = app.jwt.sign({ sub: userId, username });
|
||||||
|
return `access_token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertApiKey(options: {
|
||||||
|
userId: number;
|
||||||
|
token: string;
|
||||||
|
scope?: "read" | "write";
|
||||||
|
isActive?: boolean;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
"Seeded Key",
|
||||||
|
hashApiKeyToken(options.token),
|
||||||
|
`${options.token.slice(0, 12)}...`,
|
||||||
|
options.scope ?? "write",
|
||||||
|
options.isActive === false ? 0 : 1,
|
||||||
|
expiresAtValue,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMedication(options: {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
takenBy?: string[];
|
||||||
|
packCount?: number;
|
||||||
|
looseTablets?: number;
|
||||||
|
start?: string;
|
||||||
|
}) {
|
||||||
|
const start = options.start ?? "2026-01-01T08:00:00.000Z";
|
||||||
|
const takenBy = options.takenBy ?? ["Daniel"];
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, generic_name, taken_by_json, medication_form, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
stock_adjustment, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
options.name,
|
||||||
|
`${options.name} Generic`,
|
||||||
|
JSON.stringify(takenBy),
|
||||||
|
"tablet",
|
||||||
|
"blister",
|
||||||
|
options.packCount ?? 1,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
options.looseTablets ?? 0,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([start]),
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start,
|
||||||
|
takenBy: takenBy[0] ?? null,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)",
|
||||||
|
args: [options.userId, options.doseId, options.dismissed ? 1 : 0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedRefill(options: {
|
||||||
|
userId: number;
|
||||||
|
medicationId: number;
|
||||||
|
packsAdded?: number;
|
||||||
|
loosePillsAdded?: number;
|
||||||
|
}) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription)
|
||||||
|
VALUES (?, ?, ?, ?, 0)`,
|
||||||
|
args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMedicationPayload(name: string) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
genericName: `${name} Generic`,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportPayload() {
|
||||||
|
return {
|
||||||
|
version: "1.3",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
includeSensitiveData: false,
|
||||||
|
medications: [],
|
||||||
|
doseHistory: [],
|
||||||
|
refillHistory: [],
|
||||||
|
settings: {
|
||||||
|
emailEnabled: false,
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
},
|
||||||
|
shareLinks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Real business route authz contracts", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
|
||||||
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
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(medicationRoutes);
|
||||||
|
await app.register(doseRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
await app.register(shareRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await clearTables();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects protected business endpoints without authentication", async () => {
|
||||||
|
const endpoints: Array<{
|
||||||
|
method: "GET" | "POST";
|
||||||
|
url: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}> = [
|
||||||
|
{ method: "GET", url: "/medications" },
|
||||||
|
{ method: "GET", url: "/doses/taken" },
|
||||||
|
{ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } },
|
||||||
|
{ method: "GET", url: "/export" },
|
||||||
|
{ method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } },
|
||||||
|
{ method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload });
|
||||||
|
expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401);
|
||||||
|
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes medication listing and export output to the authenticated user", async () => {
|
||||||
|
const ownerId = await createUser("owner-medications");
|
||||||
|
const otherId = await createUser("other-medications");
|
||||||
|
const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications");
|
||||||
|
|
||||||
|
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
|
||||||
|
await seedMedication({ userId: otherId, name: "Other User Med" });
|
||||||
|
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medications",
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(listResponse.body).toContain("Owner Only Med");
|
||||||
|
expect(listResponse.body).not.toContain("Other User Med");
|
||||||
|
|
||||||
|
const exportResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportResponse.statusCode).toBe(200);
|
||||||
|
expect(exportResponse.body).toContain("Owner Only Med");
|
||||||
|
expect(exportResponse.body).not.toContain("Other User Med");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when a user updates or deletes another user's medication", async () => {
|
||||||
|
const ownerId = await createUser("owner-update");
|
||||||
|
const otherId = await createUser("other-update");
|
||||||
|
const otherCookie = buildSessionCookie(app, otherId, "other-update");
|
||||||
|
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
|
||||||
|
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/medications/${medicationId}`,
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
payload: buildMedicationPayload("Updated By Stranger"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const deleteResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/medications/${medicationId}`,
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const dbState = await testClient.execute({
|
||||||
|
sql: "SELECT name FROM medications WHERE id = ?",
|
||||||
|
args: [medicationId],
|
||||||
|
});
|
||||||
|
expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes dose reads and writes to the authenticated user", async () => {
|
||||||
|
const ownerId = await createUser("owner-dose");
|
||||||
|
const otherId = await createUser("other-dose");
|
||||||
|
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
|
||||||
|
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
|
||||||
|
|
||||||
|
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
|
||||||
|
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
|
||||||
|
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(listResponse.body).toContain("101-0-1760000000000");
|
||||||
|
expect(listResponse.body).not.toContain("202-0-1760000000000");
|
||||||
|
|
||||||
|
const deleteResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: "/doses/taken/101-0-1760000000000",
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const ownerDose = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [ownerId, "101-0-1760000000000"],
|
||||||
|
});
|
||||||
|
expect(Number(ownerDose.rows[0].count)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces medication ownership on refill history and report generation", async () => {
|
||||||
|
const ownerId = await createUser("owner-refill");
|
||||||
|
const otherId = await createUser("other-refill");
|
||||||
|
const otherCookie = buildSessionCookie(app, otherId, "other-refill");
|
||||||
|
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
|
||||||
|
await seedRefill({ userId: ownerId, medicationId });
|
||||||
|
|
||||||
|
const refillListResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medicationId}/refills`,
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillListResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const refillMutationResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medicationId}/refill`,
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillMutationResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const reportResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
payload: { medicationIds: [medicationId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reportResponse.statusCode).toBe(403);
|
||||||
|
expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes share people to the authenticated user's medications", async () => {
|
||||||
|
const ownerId = await createUser("owner-share");
|
||||||
|
const otherId = await createUser("other-share");
|
||||||
|
const ownerCookie = buildSessionCookie(app, ownerId, "owner-share");
|
||||||
|
|
||||||
|
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
|
||||||
|
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share/people",
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ people: ["Daniel"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mutation routes for read-only API keys across business endpoints", async () => {
|
||||||
|
const userId = await createUser("readonly-business-key");
|
||||||
|
const medicationId = await seedMedication({ userId, name: "Readonly Med" });
|
||||||
|
const apiToken = "ma_readonly_business_routes_123456789";
|
||||||
|
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: buildMedicationPayload("Blocked Create"),
|
||||||
|
}),
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: { doseId: "1-0-1760000000000" },
|
||||||
|
}),
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medicationId}/refill`,
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
}),
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: { takenBy: "Daniel", scheduleDays: 7 },
|
||||||
|
}),
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: buildImportPayload(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => {
|
||||||
|
const userId = await createUser("readonly-export-user");
|
||||||
|
const otherId = await createUser("readonly-export-other");
|
||||||
|
await seedMedication({ userId, name: "Readable Owner Med" });
|
||||||
|
await seedMedication({ userId: otherId, name: "Unreadable Other Med" });
|
||||||
|
const apiToken = "ma_readonly_export_access_123456789";
|
||||||
|
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain("Readable Owner Med");
|
||||||
|
expect(response.body).not.toContain("Unreadable Other Med");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
|||||||
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||||
const getDbPaths = vi.fn().mockReturnValue({
|
const getDbPaths = vi.fn().mockReturnValue({
|
||||||
dataDir: "/tmp/medassist-data",
|
dataDir: "/tmp/medassist-data",
|
||||||
dbPath: "/tmp/medassist-data/medassist.db",
|
dbPath: "/tmp/medassist-data/medassist-ng.db",
|
||||||
url: "file:/tmp/medassist-data/medassist.db",
|
url: "file:/tmp/medassist-data/medassist-ng.db",
|
||||||
});
|
});
|
||||||
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||||
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||||
@@ -102,7 +102,7 @@ describe("db/client bootstrap", () => {
|
|||||||
await mod.migrationsReady;
|
await mod.migrationsReady;
|
||||||
|
|
||||||
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
|
||||||
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
+328
-386
@@ -1,487 +1,412 @@
|
|||||||
/**
|
import { dirname, resolve } from "node:path";
|
||||||
* Tests for /doses/taken API endpoints.
|
import { fileURLToPath } from "node:url";
|
||||||
* Tests marking doses as taken, listing taken doses, and unmarking.
|
import cookie from "@fastify/cookie";
|
||||||
*/
|
import jwt from "@fastify/jwt";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// =============================================================================
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
// Route Registration
|
const { createClient } = require("@libsql/client");
|
||||||
// Since we can't easily import routes that depend on the global db,
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
// we'll create simplified route handlers for testing the core logic.
|
const client = createClient({ url: ":memory:" });
|
||||||
// =============================================================================
|
const db = drizzle(client);
|
||||||
|
|
||||||
async function registerDoseRoutes(ctx: TestContext) {
|
return {
|
||||||
const { app, client } = ctx;
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: true,
|
||||||
|
REGISTRATION_ENABLED: true,
|
||||||
|
FORM_LOGIN_ENABLED: true,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: "*",
|
||||||
|
JWT_SECRET: "test-jwt-secret",
|
||||||
|
REFRESH_SECRET: "test-refresh-secret",
|
||||||
|
COOKIE_SECRET: "test-cookie-secret",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
OPENAPI_DOCS_ENABLED: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// GET /doses/taken - List all taken doses
|
vi.mock("../db/client.js", () => ({
|
||||||
app.get("/doses/taken", async (_request, _reply) => {
|
db: testDb,
|
||||||
// In test mode, use user ID 1 (will be created in tests)
|
migrationsReady: Promise.resolve(),
|
||||||
const userId = 1;
|
}));
|
||||||
|
|
||||||
const result = await client.execute({
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const { doseRoutes } = await import("../routes/doses.js");
|
||||||
doses: result.rows.map((d) => ({
|
|
||||||
doseId: d.dose_id,
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
takenAt: (d.taken_at as number) * 1000, // Convert to ms
|
const __dirname = dirname(__filename);
|
||||||
markedBy: d.marked_by,
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
})),
|
|
||||||
};
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM api_keys");
|
||||||
|
await testClient.execute("DELETE FROM refresh_tokens");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username: string) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||||
|
args: [username],
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /doses/taken - Mark a dose as taken
|
return Number(result.rows[0].id);
|
||||||
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) {
|
async function insertMedication(options: {
|
||||||
return reply.status(400).send({ error: "doseId is required" });
|
id: number;
|
||||||
}
|
userId: number;
|
||||||
|
takenBy?: string[];
|
||||||
// Check if already marked
|
packCount?: number;
|
||||||
const existing = await client.execute({
|
looseTablets?: number;
|
||||||
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
start?: string;
|
||||||
args: [userId, doseId],
|
}) {
|
||||||
});
|
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
||||||
|
await testClient.execute({
|
||||||
if (existing.rows.length > 0) {
|
sql: `INSERT INTO medications (
|
||||||
return { success: true, message: "Already marked" };
|
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||||
}
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||||
|
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||||
// Insert new record
|
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
|
||||||
await client.execute({
|
args: [
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
|
options.id,
|
||||||
args: [userId, doseId],
|
options.userId,
|
||||||
});
|
JSON.stringify(options.takenBy ?? []),
|
||||||
|
options.packCount ?? 1,
|
||||||
return { success: true };
|
options.looseTablets ?? 0,
|
||||||
});
|
intakeStart,
|
||||||
|
"[]",
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Check if this dose was also dismissed
|
|
||||||
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 && existing.rows[0].dismissed) {
|
|
||||||
// Already dismissed - keep the record as-is (don't delete)
|
|
||||||
// The dose stays dismissed, we just ignore the undo request
|
|
||||||
} else {
|
|
||||||
// Not dismissed - delete the record entirely
|
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
|
||||||
// Tests
|
await testClient.execute({
|
||||||
// =============================================================================
|
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
|
||||||
|
args: [userId, stockCalculationMode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _insertShareToken(userId: number, token: string, takenBy: string) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
||||||
|
args: [userId, token, takenBy],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
|
const token = app.jwt.sign({ sub: userId, username });
|
||||||
|
return `access_token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertDose(options: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
markedBy?: string | null;
|
||||||
|
dismissed?: boolean;
|
||||||
|
takenAt?: number | null;
|
||||||
|
takenSource?: "manual" | "automatic";
|
||||||
|
}) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
options.doseId,
|
||||||
|
options.markedBy ?? null,
|
||||||
|
options.dismissed ? 1 : 0,
|
||||||
|
options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0),
|
||||||
|
options.takenSource ?? "manual",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("Dose Tracking API", () => {
|
describe("Dose Tracking API", () => {
|
||||||
let ctx: TestContext;
|
let app: FastifyInstance;
|
||||||
let userId: number;
|
let userId: number;
|
||||||
|
let cookieHeader: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await buildTestApp();
|
await migrate(testDb, { migrationsFolder });
|
||||||
await registerDoseRoutes(ctx);
|
await runAlterMigrations(testClient);
|
||||||
await ctx.app.ready();
|
|
||||||
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
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(doseRoutes);
|
||||||
|
await app.ready();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await closeTestApp(ctx);
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clearTestData(ctx.client);
|
await clearTables();
|
||||||
// Create test user - will get ID 1 since table is cleared
|
userId = await createUser("dose-test-user");
|
||||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
cookieHeader = buildSessionCookie(app, userId, "dose-test-user");
|
||||||
// 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", () => {
|
describe("POST /doses/taken", () => {
|
||||||
it("should mark a dose as taken", async () => {
|
it("marks a dose as taken", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
const response = await ctx.app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
payload: { doseId },
|
payload: { doseId },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true });
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
// Verify in database
|
const result = await testClient.execute({
|
||||||
const result = await ctx.client.execute({
|
sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
|
||||||
args: [userId, doseId],
|
args: [userId, doseId],
|
||||||
});
|
});
|
||||||
expect(result.rows.length).toBe(1);
|
expect(result.rows).toEqual([
|
||||||
expect(result.rows[0].dose_id).toBe(doseId);
|
expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }),
|
||||||
expect(result.rows[0].marked_by).toBeNull();
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return idempotent response when dose already marked", async () => {
|
it("returns an idempotent response when the dose is already marked", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId });
|
||||||
|
|
||||||
// Mark once
|
const response = await app.inject({
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/doses/taken",
|
|
||||||
payload: { doseId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark again
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
payload: { doseId },
|
payload: { doseId },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true, message: "Already marked" });
|
expect(response.json()).toEqual({ success: true, message: "Already marked" });
|
||||||
|
|
||||||
// Should still only have one record
|
const countResult = await testClient.execute({
|
||||||
const result = await ctx.client.execute({
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
|
||||||
args: [userId, doseId],
|
args: [userId, doseId],
|
||||||
});
|
});
|
||||||
expect(result.rows[0].count).toBe(1);
|
expect(Number(countResult.rows[0].count)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject request without doseId", async () => {
|
it("rejects requests without a doseId", async () => {
|
||||||
const response = await ctx.app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
expect(response.json()).toEqual({ error: "Required" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject request with empty doseId", async () => {
|
it("accepts dose IDs with a person suffix and special characters", async () => {
|
||||||
const response = await ctx.app.inject({
|
const doseId = "5-0-1735344000000-Max Müller";
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
payload: { doseId: "" },
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseId },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
|
||||||
|
const getResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects taking a dose when the medication is out of stock", async () => {
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseId: "5-0-1735344000000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows taking a historical dose when stock existed at that occurrence", async () => {
|
||||||
|
await insertMedication({
|
||||||
|
id: 6,
|
||||||
|
userId,
|
||||||
|
packCount: 1,
|
||||||
|
looseTablets: 0,
|
||||||
|
start: "2025-01-01T08:00:00.000Z",
|
||||||
|
});
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
|
||||||
|
const historicalDoseId = "6-0-1736064000000";
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseId: historicalDoseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /doses/taken
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("GET /doses/taken", () => {
|
describe("GET /doses/taken", () => {
|
||||||
it("should return empty array when no doses taken", async () => {
|
it("returns an empty array when no doses were taken", async () => {
|
||||||
const response = await ctx.app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ doses: [] });
|
expect(response.json()).toEqual({ doses: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return list of taken doses", async () => {
|
it("returns only the authenticated user's taken doses with metadata", async () => {
|
||||||
const doseId1 = "1-0-1735344000000";
|
const otherUserId = await createUser("dose-other-user");
|
||||||
const doseId2 = "1-0-1735430400000";
|
await insertDose({
|
||||||
|
userId,
|
||||||
// Mark two doses
|
doseId: "1-0-1735344000000",
|
||||||
await ctx.app.inject({
|
markedBy: "Daniel",
|
||||||
method: "POST",
|
takenSource: "automatic",
|
||||||
url: "/doses/taken",
|
|
||||||
payload: { doseId: doseId1 },
|
|
||||||
});
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/doses/taken",
|
|
||||||
payload: { doseId: doseId2 },
|
|
||||||
});
|
});
|
||||||
|
await insertDose({ userId, doseId: "1-0-1735430400000" });
|
||||||
|
await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" });
|
||||||
|
|
||||||
const response = await ctx.app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/doses/taken",
|
url: "/doses/taken",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.doses).toHaveLength(2);
|
expect(data.doses).toHaveLength(2);
|
||||||
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([
|
||||||
// Each dose should have a takenAt timestamp
|
"1-0-1735344000000",
|
||||||
for (const dose of data.doses) {
|
"1-0-1735430400000",
|
||||||
expect(dose.takenAt).toBeTypeOf("number");
|
]);
|
||||||
expect(dose.takenAt).toBeGreaterThan(0);
|
expect(data.doses).toEqual(
|
||||||
expect(dose.markedBy).toBeNull();
|
expect.arrayContaining([
|
||||||
}
|
expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }),
|
||||||
});
|
expect.objectContaining({ markedBy: null, takenSource: "manual" }),
|
||||||
|
])
|
||||||
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", () => {
|
describe("DELETE /doses/taken/:doseId", () => {
|
||||||
it("should unmark a dose", async () => {
|
it("unmarks an existing dose", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId });
|
||||||
|
|
||||||
// Mark first
|
const response = await app.inject({
|
||||||
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",
|
method: "DELETE",
|
||||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true });
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
// Verify unmarked
|
const countResult = await testClient.execute({
|
||||||
result = await ctx.client.execute({
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
args: [userId, doseId],
|
||||||
args: [doseId],
|
|
||||||
});
|
});
|
||||||
expect(result.rows[0].count).toBe(0);
|
expect(Number(countResult.rows[0].count)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed even if dose was not marked", async () => {
|
it("keeps the record when the dose is dismissed", async () => {
|
||||||
const doseId = "nonexistent-dose-id";
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId, dismissed: true });
|
||||||
|
|
||||||
const response = await ctx.app.inject({
|
const response = await app.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true });
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should preserve dismissed status when unmarking a dose", async () => {
|
it("still succeeds when the dose does not exist", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const response = await app.inject({
|
||||||
|
|
||||||
// First dismiss the dose
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/doses/dismiss",
|
|
||||||
payload: { doseIds: [doseId] },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify it's dismissed
|
|
||||||
let result = await ctx.client.execute({
|
|
||||||
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
|
||||||
args: [doseId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].dismissed).toBe(1);
|
|
||||||
const originalTakenAt = result.rows[0].taken_at;
|
|
||||||
|
|
||||||
// Now try to unmark it (undo) - should keep the dismissed record
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
url: "/doses/taken/nonexistent-dose-id",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true });
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
// Verify the record still exists and is still dismissed
|
|
||||||
result = await ctx.client.execute({
|
|
||||||
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
|
||||||
args: [doseId],
|
|
||||||
});
|
|
||||||
expect(result.rows.length).toBe(1);
|
|
||||||
expect(result.rows[0].dismissed).toBe(1);
|
|
||||||
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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", () => {
|
describe("POST /doses/dismiss", () => {
|
||||||
it("should dismiss multiple doses", async () => {
|
it("dismisses multiple doses", async () => {
|
||||||
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
const response = await app.inject({
|
||||||
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/dismiss",
|
url: "/doses/dismiss",
|
||||||
payload: { doseIds },
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||||
|
|
||||||
// Verify in database
|
const result = await testClient.execute({
|
||||||
const result = await ctx.client.execute({
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1",
|
||||||
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
|
||||||
args: [userId],
|
args: [userId],
|
||||||
});
|
});
|
||||||
expect(result.rows.length).toBe(2);
|
expect(Number(result.rows[0].count)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not double-count already dismissed doses", async () => {
|
it("does not double-count already dismissed doses", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId, dismissed: true });
|
||||||
|
|
||||||
// Dismiss once
|
const response = await app.inject({
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/doses/dismiss",
|
|
||||||
payload: { doseIds: [doseId] },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dismiss again
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/dismiss",
|
url: "/doses/dismiss",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
payload: { doseIds: [doseId] },
|
payload: { doseIds: [doseId] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,54 +414,71 @@ describe("Dose Tracking API", () => {
|
|||||||
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject empty doseIds array", async () => {
|
it("converts a taken dose into a dismissed one", 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";
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId, dismissed: false });
|
||||||
|
|
||||||
// First mark as taken
|
const response = await app.inject({
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/doses/taken",
|
|
||||||
payload: { doseId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then dismiss it
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/doses/dismiss",
|
url: "/doses/dismiss",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
payload: { doseIds: [doseId] },
|
payload: { doseIds: [doseId] },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||||
|
|
||||||
// Verify it's now dismissed
|
const result = await testClient.execute({
|
||||||
const result = await ctx.client.execute({
|
sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
|
||||||
args: [userId, doseId],
|
args: [userId, doseId],
|
||||||
});
|
});
|
||||||
expect(result.rows[0].dismissed).toBe(1);
|
expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing or empty doseIds", async () => {
|
||||||
|
const emptyResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseIds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emptyResponse.statusCode).toBe(400);
|
||||||
|
expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" });
|
||||||
|
|
||||||
|
const missingResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(missingResponse.statusCode).toBe(400);
|
||||||
|
expect(missingResponse.json()).toEqual({ error: "Required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /doses/dismiss", () => {
|
||||||
|
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
||||||
|
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
||||||
|
await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, clearedCount: 2 });
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(rows.rows).toEqual([
|
||||||
|
expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
|
|||||||
import type { Client } from "@libsql/client";
|
import type { Client } from "@libsql/client";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||||
const { testClient, testDb } = vi.hoisted(() => {
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
@@ -82,7 +83,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -101,6 +107,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -138,6 +146,7 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
share_medication_overview integer NOT NULL DEFAULT 0,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
@@ -171,6 +180,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -239,7 +249,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
await createSchema(testClient);
|
await createSchema(testClient);
|
||||||
|
|
||||||
// Build app with real routes
|
// Build app with real routes
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
@@ -337,6 +347,37 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not include refill history entries from another user for the same medication", async () => {
|
||||||
|
const medId = await createMedication(testClient, userId, "Report Isolation Med", ["Daniel"]);
|
||||||
|
const otherUserId = await _createUser(testClient, "report-isolation-other-user");
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 1, 0, 0, 1735603200],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, otherUserId, 9, 99, 1, 1735689600],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data[medId].refills).toHaveLength(1);
|
||||||
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
|
packsAdded: 1,
|
||||||
|
loosePillsAdded: 0,
|
||||||
|
usedPrescription: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -495,6 +536,77 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(404);
|
expect(response.statusCode).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return shared medication overview for a valid token", async () => {
|
||||||
|
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||||
|
const token = "abcdef0123456789";
|
||||||
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}/overview`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["cache-control"]).toBe("no-store");
|
||||||
|
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.takenBy).toBe("Daniel");
|
||||||
|
expect(data.sharedBy).toBe("__anonymous__");
|
||||||
|
expect(Array.isArray(data.medications)).toBe(true);
|
||||||
|
expect(data.medications).toHaveLength(1);
|
||||||
|
expect(data.medications[0].name).toBe("Aspirin");
|
||||||
|
expect(data.medications[0].currentStock).toBeTypeOf("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for unknown overview token", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share/abcdef0123456789/overview",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json()).toEqual({ error: "not_found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 410 for expired overview token", async () => {
|
||||||
|
const token = "fedcba9876543210";
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)",
|
||||||
|
args: [userId, token, "Daniel", Math.floor(Date.now() / 1000) - 60],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}/overview`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(410);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.error).toBe("expired");
|
||||||
|
expect(data.expiredAt).toBeTypeOf("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always show stock fields in overview regardless of share_stock_status setting", async () => {
|
||||||
|
await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]);
|
||||||
|
const token = "0123456789abcdef";
|
||||||
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO user_settings (user_id, share_stock_status, low_stock_days) VALUES (?, 0, 30)",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}/overview`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const [medication] = response.json().medications;
|
||||||
|
expect(medication.currentStock).toBeTypeOf("number");
|
||||||
|
expect(medication.capacity).toBeTypeOf("number");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -826,7 +938,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json().error).toBe("Invalid language");
|
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create and update language via lightweight language endpoint", async () => {
|
it("should create and update language via lightweight language endpoint", async () => {
|
||||||
@@ -867,7 +979,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const json = response.json();
|
const json = response.json();
|
||||||
expect(json.status).toBe("ok");
|
expect(json.status).toBe("ok");
|
||||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1288,7 +1399,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const json = response.json();
|
const json = response.json();
|
||||||
expect(json.status).toBe("ok");
|
expect(json.status).toBe("ok");
|
||||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1923,6 +2033,47 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(hasLooseRefill).toBe(true);
|
expect(hasLooseRefill).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not return refill history entries from another user for the same medication", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Refill Isolation Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
const otherUserId = await _createUser(testClient, "refill-isolation-other-user");
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 2, 3, 0, 1735603200],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, otherUserId, 8, 88, 1, 1735689600],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const refills = response.json();
|
||||||
|
expect(refills).toHaveLength(1);
|
||||||
|
expect(refills[0]).toMatchObject({
|
||||||
|
packsAdded: 2,
|
||||||
|
loosePillsAdded: 3,
|
||||||
|
usedPrescription: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should return 404 for non-existent medication", async () => {
|
it("should return 404 for non-existent medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -2296,6 +2447,28 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
emailEnabled: true,
|
emailEnabled: true,
|
||||||
notificationEmail: "test@example.com",
|
notificationEmail: "test@example.com",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2336,7 +2509,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
maxNaggingReminders: 5,
|
maxNaggingReminders: 5,
|
||||||
language: "en",
|
language: "en",
|
||||||
stockCalculationMode: "automatic",
|
stockCalculationMode: "automatic",
|
||||||
shareStockStatus: true,
|
|
||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
@@ -2500,10 +2672,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Package Type (bottle vs blister) Tests
|
// Package Type (blister, bottle, tube, liquid_container) Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("Package type handling (bottle vs blister)", () => {
|
describe("Package type handling (blister, bottle, tube, liquid_container)", () => {
|
||||||
const bottleMedication = {
|
const bottleMedication = {
|
||||||
name: "Vitamin D Drops",
|
name: "Vitamin D Drops",
|
||||||
packageType: "bottle",
|
packageType: "bottle",
|
||||||
@@ -2524,6 +2696,33 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const liquidContainerMedication = {
|
||||||
|
name: "Cough Syrup",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
looseTablets: 180,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tubeMedication = {
|
||||||
|
name: "Topical Cream",
|
||||||
|
medicationForm: "topical",
|
||||||
|
packageType: "tube",
|
||||||
|
doseUnit: "units",
|
||||||
|
packCount: 2,
|
||||||
|
packageAmountValue: 40,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 80,
|
||||||
|
looseTablets: 80,
|
||||||
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2568,6 +2767,49 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.medications[0].totalPills).toBe(120);
|
expect(data.medications[0].totalPills).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create and return liquid_container type medication", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: liquidContainerMedication,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.packageType).toBe("liquid_container");
|
||||||
|
expect(data.medicationForm).toBe("liquid");
|
||||||
|
expect(data.doseUnit).toBe("ml");
|
||||||
|
expect(data.looseTablets).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: { ...liquidContainerMedication, takenBy: ["Daniel"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||||
|
});
|
||||||
|
expect(shareResponse.statusCode).toBe(200);
|
||||||
|
const { token } = shareResponse.json();
|
||||||
|
|
||||||
|
const scheduleResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduleResponse.statusCode).toBe(200);
|
||||||
|
const data = scheduleResponse.json();
|
||||||
|
expect(data.medications).toHaveLength(1);
|
||||||
|
expect(data.medications[0].packageType).toBe("liquid_container");
|
||||||
|
// Liquid container follows container semantics (stock from looseTablets only).
|
||||||
|
expect(data.medications[0].totalPills).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct totalPills for shared blister medication", async () => {
|
it("should calculate correct totalPills for shared blister medication", async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2637,6 +2879,72 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
|
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 180,
|
||||||
|
totalPills: 180,
|
||||||
|
looseTablets: 180,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.packsAdded).toBe(1);
|
||||||
|
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(180);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(360);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.totalPills).toBe(360);
|
||||||
|
expect(med.looseTablets).toBe(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep tube refill additive and preserve amount baseline", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: tubeMedication,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.packsAdded).toBe(1);
|
||||||
|
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(40);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(120);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.totalPills).toBe(120);
|
||||||
|
expect(med.looseTablets).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
|
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2743,5 +3051,18 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(medsResponse.json()).toHaveLength(1);
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
expect(medsResponse.json()[0].packageType).toBe("blister");
|
expect(medsResponse.json()[0].packageType).toBe("blister");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject liquid medication form with non-liquid package type", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packageType: "bottle",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /import
|
// POST /import
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
|
||||||
app.post("/import", async (request, reply) => {
|
app.post("/import", async (request, reply) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||||
const importData = request.body as any;
|
const importData = request.body as any;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
return {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("checkAndSendIntakeRemindersForUser", () => {
|
||||||
|
const mockedDb = vi.mocked(db);
|
||||||
|
let originalTz: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
|
||||||
|
originalTz = process.env.TZ;
|
||||||
|
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalTz === undefined) {
|
||||||
|
delete process.env.TZ;
|
||||||
|
} else {
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-marks due intakes in automatic mode even when all intake reminder channels are disabled", async () => {
|
||||||
|
const insertedRows: Array<Record<string, unknown>> = [];
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
const insertMock = vi.mocked(mockedDb.insert);
|
||||||
|
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ username: "auto-user" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 11,
|
||||||
|
name: "Vitamin D",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
intakesJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2026-01-05T08:00:00.000Z",
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [],
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [],
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
insertMock.mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
values: async (row: Record<string, unknown>) => {
|
||||||
|
insertedRows.push(row);
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 11,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: null,
|
||||||
|
shoutrrrIntakeReminders: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertedRows).toHaveLength(1);
|
||||||
|
expect(insertedRows[0]).toMatchObject({
|
||||||
|
userId: 11,
|
||||||
|
doseId: `7-0-${new Date(2026, 0, 5).getTime()}`,
|
||||||
|
markedBy: null,
|
||||||
|
takenSource: "automatic",
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-mark due intakes when current stock is empty", async () => {
|
||||||
|
const insertedRows: Array<Record<string, unknown>> = [];
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
const insertMock = vi.mocked(mockedDb.insert);
|
||||||
|
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ username: "auto-user" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 11,
|
||||||
|
name: "Vitamin D",
|
||||||
|
genericName: null,
|
||||||
|
takenByJson: null,
|
||||||
|
packageType: "blister",
|
||||||
|
medicationForm: "tablet",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
isObsolete: false,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
intakesJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2026-01-05T08:00:00.000Z",
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [],
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [],
|
||||||
|
}),
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
insertMock.mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
values: async (row: Record<string, unknown>) => {
|
||||||
|
insertedRows.push(row);
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 11,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: null,
|
||||||
|
shoutrrrIntakeReminders: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertedRows).toHaveLength(0);
|
||||||
|
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
|
|||||||
import type { Client } from "@libsql/client";
|
import type { Client } from "@libsql/client";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||||
const { testClient, testDb } = vi.hoisted(() => {
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
@@ -76,7 +77,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -95,6 +101,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -132,6 +140,7 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
share_medication_overview integer NOT NULL DEFAULT 0,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
@@ -165,6 +174,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -195,7 +205,7 @@ describe("Integration Tests", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await createSchema(testClient);
|
await createSchema(testClient);
|
||||||
|
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
@@ -245,6 +255,9 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: {
|
payload: {
|
||||||
name: "Test Med",
|
name: "Test Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -298,6 +311,9 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: {
|
payload: {
|
||||||
name: "Test Med",
|
name: "Test Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -336,6 +352,9 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: {
|
payload: {
|
||||||
name: "Test Med",
|
name: "Test Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [
|
blisters: [
|
||||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||||
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
||||||
@@ -397,6 +416,9 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: {
|
payload: {
|
||||||
name: "Weekly Med",
|
name: "Weekly Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
|
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -534,6 +556,9 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: {
|
payload: {
|
||||||
name: "Interval Med",
|
name: "Interval Med",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -588,6 +613,9 @@ describe("Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
name: "Aspirin",
|
name: "Aspirin",
|
||||||
takenBy: ["Daniel"],
|
takenBy: ["Daniel"],
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify from "fastify";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
type OidcMocks = {
|
type OidcMocks = {
|
||||||
discovery: ReturnType<typeof vi.fn>;
|
discovery: ReturnType<typeof vi.fn>;
|
||||||
@@ -54,7 +55,7 @@ async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
|||||||
|
|
||||||
const { oidcRoutes } = await import("../routes/oidc.js");
|
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||||
|
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
app.decorate("config", {
|
app.decorate("config", {
|
||||||
accessSecret: "test-jwt-secret-12345",
|
accessSecret: "test-jwt-secret-12345",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Client } from "@libsql/client";
|
import type { Client } from "@libsql/client";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Create test database and mocks before anything else (hoisted)
|
// Create test database and mocks before anything else (hoisted)
|
||||||
const {
|
const {
|
||||||
@@ -93,7 +94,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -112,6 +118,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -149,6 +157,7 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
share_medication_overview integer NOT NULL DEFAULT 0,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
@@ -207,7 +216,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [],
|
args: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.ready();
|
await app.ready();
|
||||||
|
|
||||||
@@ -284,7 +293,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -330,7 +339,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -434,7 +443,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -522,7 +531,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -697,7 +706,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -727,7 +736,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -763,7 +772,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -849,7 +858,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -982,7 +991,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1036,6 +1045,36 @@ describe("Planner Routes", () => {
|
|||||||
expect(title).not.toContain("Low");
|
expect(title).not.toContain("Low");
|
||||||
expect(message).toContain("Running critically low");
|
expect(message).toContain("Running critically low");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 400 when only tube medications are in active meds", async () => {
|
||||||
|
// Insert a tube medication (should be excluded from reminders)
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type)
|
||||||
|
VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`,
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
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", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/reminder/send-email",
|
||||||
|
payload: {
|
||||||
|
email: "test@example.com",
|
||||||
|
lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expects 400 because tube medications are excluded from stock reminders
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "No active medications to notify" });
|
||||||
|
expect(mockSendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /reminder/send-prescription", () => {
|
describe("POST /reminder/send-prescription", () => {
|
||||||
@@ -1082,7 +1121,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { runAlterMigrations } from "../db/db-utils.js";
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||||
const { createClient } = require("@libsql/client");
|
const { createClient } = require("@libsql/client");
|
||||||
@@ -45,7 +46,9 @@ vi.mock("nodemailer", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
|
const { settingsRoutes, sendShoutrrrNotification, loadUserSettings, getAllUserSettings } = await import(
|
||||||
|
"../routes/settings.js"
|
||||||
|
);
|
||||||
const { exportRoutes } = await import("../routes/export.js");
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
const { reportRoutes } = await import("../routes/report.js");
|
const { reportRoutes } = await import("../routes/report.js");
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await migrate(testDb, { migrationsFolder });
|
await migrate(testDb, { migrationsFolder });
|
||||||
await runAlterMigrations(testClient);
|
await runAlterMigrations(testClient);
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(reportRoutes);
|
await app.register(reportRoutes);
|
||||||
@@ -137,11 +140,76 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const body = response.json();
|
const body = response.json();
|
||||||
expect(body.language).toBe("en");
|
expect(body.language).toBe("en");
|
||||||
expect(body.shareStockStatus).toBe(true);
|
|
||||||
expect(body.upcomingTodayOnly).toBe(false);
|
expect(body.upcomingTodayOnly).toBe(false);
|
||||||
expect(body.shareScheduleTodayOnly).toBe(false);
|
expect(body.shareScheduleTodayOnly).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /settings returns a non-empty serialized payload with SMTP fields", async () => {
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_PORT = "2525";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_FROM = "MedAssist <mailer@example.com>";
|
||||||
|
process.env.SMTP_PASS = "secret";
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: true,
|
||||||
|
notificationEmail: "person@example.com",
|
||||||
|
reminderDaysBefore: 5,
|
||||||
|
repeatDailyReminders: true,
|
||||||
|
lowStockDays: 14,
|
||||||
|
normalStockDays: 45,
|
||||||
|
highStockDays: 90,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 20,
|
||||||
|
maxNaggingReminders: 4,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
upcomingTodayOnly: true,
|
||||||
|
shareScheduleTodayOnly: true,
|
||||||
|
swapDashboardMainSections: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).not.toBe("{}");
|
||||||
|
|
||||||
|
const body = response.json();
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
emailEnabled: true,
|
||||||
|
notificationEmail: "person@example.com",
|
||||||
|
reminderDaysBefore: 5,
|
||||||
|
repeatDailyReminders: true,
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 20,
|
||||||
|
maxNaggingReminders: 4,
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
upcomingTodayOnly: true,
|
||||||
|
shareScheduleTodayOnly: true,
|
||||||
|
swapDashboardMainSections: true,
|
||||||
|
smtpHost: "smtp.example.com",
|
||||||
|
smtpPort: 2525,
|
||||||
|
smtpUser: "mailer@example.com",
|
||||||
|
smtpFrom: "MedAssist <mailer@example.com>",
|
||||||
|
hasSmtpPassword: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -168,7 +236,6 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
maxNaggingReminders: 5,
|
maxNaggingReminders: 5,
|
||||||
language: "en",
|
language: "en",
|
||||||
stockCalculationMode: "automatic",
|
stockCalculationMode: "automatic",
|
||||||
shareStockStatus: true,
|
|
||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
@@ -190,7 +257,30 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
payload: { language: "fr" },
|
payload: { language: "fr" },
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json().error).toBe("Invalid language");
|
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /settings/language creates and updates the stored language", async () => {
|
||||||
|
let response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "de" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "en" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const stored = await testClient.execute({
|
||||||
|
sql: "SELECT language FROM user_settings WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(stored.rows[0].language).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
||||||
@@ -207,7 +297,12 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
process.env.SMTP_HOST = "smtp.example.com";
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
process.env.SMTP_USER = "mailer@example.com";
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
process.env.SMTP_TOKEN = "secret";
|
process.env.SMTP_TOKEN = "secret";
|
||||||
nodemailerSendMail.mockResolvedValue(undefined);
|
nodemailerSendMail.mockResolvedValue({
|
||||||
|
accepted: ["person@example.com"],
|
||||||
|
rejected: [],
|
||||||
|
response: "250 2.0.0 OK",
|
||||||
|
messageId: "test-message-id",
|
||||||
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -219,6 +314,22 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-email maps generic transport failures to HTTP 500", async () => {
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_PASS = "secret";
|
||||||
|
nodemailerSendMail.mockRejectedValue(new Error("socket hang up"));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(500);
|
||||||
|
expect(response.json()).toMatchObject({ code: "TEST_EMAIL_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -228,6 +339,30 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-shoutrrr returns 500 when notification delivery fails", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-shoutrrr",
|
||||||
|
payload: { url: "ftp://invalid.example.com/topic" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(500);
|
||||||
|
expect(response.json().error).toMatch(/Only HTTP\/HTTPS protocols are allowed|Unsupported URL format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-shoutrrr",
|
||||||
|
payload: { url: "ntfy://ntfy.sh/medassist" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
||||||
|
});
|
||||||
|
|
||||||
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||||
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -261,6 +396,166 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification returns HTTP response errors for ntfy-style endpoints", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("rate limited") });
|
||||||
|
|
||||||
|
const result = await sendShoutrrrNotification("https://ntfy.sh/medassist", "Title", "Body");
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "HTTP 429: rate limited" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification rejects invalid Discord webhook identifiers", async () => {
|
||||||
|
const result = await sendShoutrrrNotification("discord://bad-token@not-a-number", "Title", "Body");
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "Invalid Discord webhook ID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification validates Pushover URL credentials", async () => {
|
||||||
|
const result = await sendShoutrrrNotification("pushover://missing-token", "Title", "Body");
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "Invalid Pushover URL format" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification requires Telegram chats and validates tokens", async () => {
|
||||||
|
let result = await sendShoutrrrNotification("telegram://123:abc@telegram", "Title", "Body");
|
||||||
|
expect(result).toEqual({ success: false, error: "Telegram URL requires chats parameter" });
|
||||||
|
|
||||||
|
result = await sendShoutrrrNotification("telegram://invalid@telegram?chats=123", "Title", "Body");
|
||||||
|
expect(result).toEqual({ success: false, error: "Invalid Telegram token format" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification converts Gotify URLs and supports disabletls", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const result = await sendShoutrrrNotification(
|
||||||
|
"gotify://push.example.com/basepath/token123?disabletls=yes&priority=8",
|
||||||
|
"Title",
|
||||||
|
"Body"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0];
|
||||||
|
expect(targetUrl).toBe("http://push.example.com/basepath/message?token=token123");
|
||||||
|
expect(requestInit.body).toBe("Body\n\n(priority=8)");
|
||||||
|
expect(requestInit.headers).toMatchObject({ Tags: "pill" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadUserSettings creates defaults for users without settings", async () => {
|
||||||
|
const settings = await loadUserSettings(1);
|
||||||
|
|
||||||
|
expect(settings).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 1,
|
||||||
|
emailEnabled: false,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadUserSettings maps persisted settings", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (
|
||||||
|
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||||
|
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||||
|
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||||
|
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||||
|
stock_calculation_mode, share_stock_status, skip_reminders_for_taken_doses,
|
||||||
|
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||||
|
upcoming_today_only, share_schedule_today_only, swap_dashboard_main_sections
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"person@example.com",
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
90,
|
||||||
|
"de",
|
||||||
|
"manual",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
30,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await loadUserSettings(1);
|
||||||
|
|
||||||
|
expect(settings).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
notificationEmail: "person@example.com",
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAllUserSettings returns mapped entries for each persisted user", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||||
|
args: [2, "second-user", "local"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (
|
||||||
|
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||||
|
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||||
|
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||||
|
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||||
|
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
|
||||||
|
swap_dashboard_main_sections
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [1, 0, null, 1, 1, 1, 1, "ntfy://ntfy.sh/topic", 1, 1, 1, 7, 1, 30, 60, 120, "en", "manual", 1, 1, 0, 1],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (
|
||||||
|
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||||
|
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||||
|
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||||
|
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||||
|
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
|
||||||
|
swap_dashboard_main_sections
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [2, 1, "second@example.com", 0, 1, 1, 0, null, 1, 1, 1, 10, 0, 20, 50, 100, "de", "automatic", 1, 0, 0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSettings = await getAllUserSettings();
|
||||||
|
|
||||||
|
expect(allSettings).toHaveLength(2);
|
||||||
|
expect(allSettings).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ userId: 1, stockCalculationMode: "manual", upcomingTodayOnly: true }),
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 2,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
||||||
await seedMedication("Owned Med");
|
await seedMedication("Owned Med");
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import cors from "@fastify/cors";
|
|||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Import from utils to avoid index.ts import side effects (server start)
|
// Import from utils to avoid index.ts import side effects (server start)
|
||||||
import {
|
import {
|
||||||
@@ -197,6 +198,7 @@ describe("Server Bootstrap", () => {
|
|||||||
logger: {
|
logger: {
|
||||||
level: "silent", // Disable logging for tests
|
level: "silent", // Disable logging for tests
|
||||||
},
|
},
|
||||||
|
ajv: documentationSchemaAjv,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(app).toBeDefined();
|
expect(app).toBeDefined();
|
||||||
@@ -206,7 +208,7 @@ describe("Server Bootstrap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should register sensible plugin", async () => {
|
it("should register sensible plugin", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
||||||
// Sensible adds error helpers
|
// Sensible adds error helpers
|
||||||
@@ -219,7 +221,7 @@ describe("Server Bootstrap", () => {
|
|||||||
it("should register cors plugin with multiple origins", async () => {
|
it("should register cors plugin with multiple origins", async () => {
|
||||||
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
||||||
|
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(cors, { origin: origins, credentials: true });
|
await app.register(cors, { origin: origins, credentials: true });
|
||||||
|
|
||||||
// Add a test route
|
// Add a test route
|
||||||
@@ -243,7 +245,7 @@ describe("Server Bootstrap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should register cookie plugin", async () => {
|
it("should register cookie plugin", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
|
|
||||||
// Add a test route that sets a cookie
|
// Add a test route that sets a cookie
|
||||||
@@ -267,7 +269,7 @@ describe("Server Bootstrap", () => {
|
|||||||
|
|
||||||
describe("Config Decorator", () => {
|
describe("Config Decorator", () => {
|
||||||
it("should create config with auth settings", async () => {
|
it("should create config with auth settings", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
const accessTtlMinutes = 15;
|
const accessTtlMinutes = 15;
|
||||||
const refreshTtlDays = 7;
|
const refreshTtlDays = 7;
|
||||||
@@ -369,7 +371,7 @@ describe("Server Bootstrap", () => {
|
|||||||
|
|
||||||
describe("Route Registration", () => {
|
describe("Route Registration", () => {
|
||||||
it("should register multiple route plugins", async () => {
|
it("should register multiple route plugins", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
// Mock route plugins
|
// Mock route plugins
|
||||||
const healthRoutes = async (app: FastifyInstance) => {
|
const healthRoutes = async (app: FastifyInstance) => {
|
||||||
@@ -402,7 +404,7 @@ describe("Server Bootstrap", () => {
|
|||||||
|
|
||||||
describe("Server Startup", () => {
|
describe("Server Startup", () => {
|
||||||
it("should listen on specified port", async () => {
|
it("should listen on specified port", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
app.get("/test", async () => ({ ok: true }));
|
app.get("/test", async () => ({ ok: true }));
|
||||||
|
|
||||||
@@ -415,7 +417,7 @@ describe("Server Bootstrap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle listen errors gracefully", async () => {
|
it("should handle listen errors gracefully", async () => {
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
// Try to listen on an invalid port
|
// Try to listen on an invalid port
|
||||||
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
|
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
|
personTakesMedication,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// Helper to convert Blister to Intake for tests
|
// Helper to convert Blister to Intake for tests
|
||||||
@@ -151,6 +152,16 @@ describe("Scheduler Utils - Timezone Functions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Scheduler Utils - Sharing", () => {
|
||||||
|
it("treats the all-share sentinel as matching intake-specific assignees", () => {
|
||||||
|
const intakes = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, "Max")];
|
||||||
|
|
||||||
|
expect(personTakesMedication("all", [], intakes)).toBe(true);
|
||||||
|
expect(personTakesMedication("Max", [], intakes)).toBe(true);
|
||||||
|
expect(personTakesMedication("Anna", [], intakes)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Scheduler Utils - Blister Parsing", () => {
|
describe("Scheduler Utils - Blister Parsing", () => {
|
||||||
describe("parseBlisters", () => {
|
describe("parseBlisters", () => {
|
||||||
it("should parse valid blister JSON arrays", () => {
|
it("should parse valid blister JSON arrays", () => {
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv, nodemailerSendMail } = 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,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: true,
|
||||||
|
REGISTRATION_ENABLED: true,
|
||||||
|
FORM_LOGIN_ENABLED: true,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: "*",
|
||||||
|
JWT_SECRET: "test-jwt-secret",
|
||||||
|
REFRESH_SECRET: "test-refresh-secret",
|
||||||
|
COOKIE_SECRET: "test-cookie-secret",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
OPENAPI_DOCS_ENABLED: false,
|
||||||
|
},
|
||||||
|
nodemailerSendMail: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
default: {
|
||||||
|
createTransport: () => ({
|
||||||
|
sendMail: nodemailerSendMail,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { settingsRoutes } = await import("../routes/settings.js");
|
||||||
|
const { apiKeyRoutes } = await import("../routes/api-keys.js");
|
||||||
|
const { hashApiKeyToken } = await import("../plugins/auth.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM api_keys");
|
||||||
|
await testClient.execute("DELETE FROM refresh_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username: string) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||||
|
args: [username],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
|
const token = app.jwt.sign({ sub: userId, username });
|
||||||
|
return `access_token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertApiKey(options: {
|
||||||
|
userId: number;
|
||||||
|
token: string;
|
||||||
|
scope?: "read" | "write";
|
||||||
|
isActive?: boolean;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
"Seeded Key",
|
||||||
|
hashApiKeyToken(options.token),
|
||||||
|
`${options.token.slice(0, 12)}...`,
|
||||||
|
options.scope ?? "write",
|
||||||
|
options.isActive === false ? 0 : 1,
|
||||||
|
expiresAtValue,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Settings and API key security contracts", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
|
||||||
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
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(settingsRoutes);
|
||||||
|
await app.register(apiKeyRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await clearTables();
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
delete process.env.SMTP_USER;
|
||||||
|
delete process.env.SMTP_TOKEN;
|
||||||
|
delete process.env.SMTP_PASS;
|
||||||
|
delete process.env.SMTP_FROM;
|
||||||
|
delete process.env.SMTP_PORT;
|
||||||
|
delete process.env.SMTP_SECURE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects GET /settings without authentication when auth is enabled", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns settings defaults for an authenticated session cookie", async () => {
|
||||||
|
const userId = await createUser("settings-session-user");
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
emailEnabled: false,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows GET /settings with a read-only API key", async () => {
|
||||||
|
const userId = await createUser("settings-read-user");
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_PORT = "2525";
|
||||||
|
|
||||||
|
const apiToken = "ma_read_only_valid_token_123456789";
|
||||||
|
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
smtpHost: "smtp.example.com",
|
||||||
|
smtpPort: 2525,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects PUT /settings with a read-only API key", async () => {
|
||||||
|
const userId = await createUser("settings-read-mutation-user");
|
||||||
|
const apiToken = "ma_read_only_mutation_token_123456789";
|
||||||
|
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
payload: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: "",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid API key bearer tokens for GET /settings", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
headers: { authorization: "Bearer definitely-not-a-medassist-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
expect(response.json()).toMatchObject({ code: "INVALID_API_KEY" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired API keys for GET /settings", async () => {
|
||||||
|
const userId = await createUser("settings-expired-key-user");
|
||||||
|
const apiToken = "ma_expired_token_for_settings_123456789";
|
||||||
|
await insertApiKey({
|
||||||
|
userId,
|
||||||
|
token: apiToken,
|
||||||
|
scope: "read",
|
||||||
|
expiresAt: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
headers: { authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
expect(response.json()).toMatchObject({ code: "API_KEY_EXPIRED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
|
||||||
|
const userId = await createUser("api-key-session-user");
|
||||||
|
const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user");
|
||||||
|
|
||||||
|
const firstCreate = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/api-keys",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { name: "Primary key", scope: "write", expiresInDays: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstCreate.statusCode).toBe(201);
|
||||||
|
const firstBody = firstCreate.json();
|
||||||
|
expect(firstBody.token).toMatch(/^ma_/);
|
||||||
|
|
||||||
|
const secondCreate = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/api-keys",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { name: "Rotated key", scope: "write", expiresInDays: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondCreate.statusCode).toBe(201);
|
||||||
|
const secondBody = secondCreate.json();
|
||||||
|
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth/api-keys",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(listResponse.body).not.toContain(firstBody.token);
|
||||||
|
expect(listResponse.body).not.toContain(secondBody.token);
|
||||||
|
expect(listResponse.body).not.toContain("keyHash");
|
||||||
|
expect(listResponse.json().keys).toHaveLength(2);
|
||||||
|
|
||||||
|
const dbState = await testClient.execute({
|
||||||
|
sql: "SELECT name, is_active FROM api_keys WHERE user_id = ? ORDER BY id ASC",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(dbState.rows).toEqual([
|
||||||
|
expect.objectContaining({ name: "Primary key", is_active: 0 }),
|
||||||
|
expect.objectContaining({ name: "Rotated key", is_active: 1 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects API key rotation when authenticated with a read-only API key", async () => {
|
||||||
|
const userId = await createUser("api-key-readonly-rotate-user");
|
||||||
|
const readOnlyToken = "ma_readonly_rotation_denied_123456789";
|
||||||
|
await insertApiKey({ userId, token: readOnlyToken, scope: "read" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/api-keys",
|
||||||
|
headers: { authorization: `Bearer ${readOnlyToken}` },
|
||||||
|
payload: { name: "Blocked rotation", scope: "write", expiresInDays: 30 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when deleting an API key owned by a different user", async () => {
|
||||||
|
const ownerUserId = await createUser("api-key-owner");
|
||||||
|
const otherUserId = await createUser("api-key-other-user");
|
||||||
|
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
|
||||||
|
|
||||||
|
const keyId = await insertApiKey({
|
||||||
|
userId: ownerUserId,
|
||||||
|
token: "ma_write_owner_token_123456789",
|
||||||
|
scope: "write",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/auth/api-keys/${keyId}`,
|
||||||
|
headers: { cookie: otherCookieHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json()).toMatchObject({ code: "API_KEY_NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps SMTP recipient rejection to HTTP 400 instead of a generic 500", async () => {
|
||||||
|
const userId = await createUser("settings-email-recipient-user");
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_PASS = "secret";
|
||||||
|
nodemailerSendMail.mockResolvedValue({
|
||||||
|
accepted: [],
|
||||||
|
rejected: ["missing@example.com"],
|
||||||
|
response: "550 5.1.1 recipient address rejected",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") },
|
||||||
|
payload: { email: "missing@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({ code: "EMAIL_RECIPIENT_REJECTED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps missing SMTP acceptance to HTTP 502 for test email", async () => {
|
||||||
|
const userId = await createUser("settings-email-unconfirmed-user");
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_PASS = "secret";
|
||||||
|
nodemailerSendMail.mockResolvedValue({
|
||||||
|
accepted: [],
|
||||||
|
rejected: [],
|
||||||
|
response: "250 queued without explicit acceptance",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(502);
|
||||||
|
expect(response.json()).toMatchObject({ code: "SMTP_DELIVERY_UNCONFIRMED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,7 +51,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
expiryWarningDays: 90,
|
expiryWarningDays: 90,
|
||||||
language: "en",
|
language: "en",
|
||||||
stockCalculationMode: "automatic",
|
stockCalculationMode: "automatic",
|
||||||
shareStockStatus: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +76,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
expiryWarningDays: s.expiry_warning_days,
|
expiryWarningDays: s.expiry_warning_days,
|
||||||
language: s.language,
|
language: s.language,
|
||||||
stockCalculationMode: s.stock_calculation_mode,
|
stockCalculationMode: s.stock_calculation_mode,
|
||||||
shareStockStatus: Boolean(s.share_stock_status ?? 1),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +102,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
expiryWarningDays?: number;
|
expiryWarningDays?: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
stockCalculationMode?: "automatic" | "manual";
|
stockCalculationMode?: "automatic" | "manual";
|
||||||
shareStockStatus?: boolean;
|
|
||||||
};
|
};
|
||||||
}>("/settings", async (request, reply) => {
|
}>("/settings", async (request, reply) => {
|
||||||
const userId = 1;
|
const userId = 1;
|
||||||
@@ -177,7 +174,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
body.expiryWarningDays ?? 90,
|
body.expiryWarningDays ?? 90,
|
||||||
body.language || "en",
|
body.language || "en",
|
||||||
body.stockCalculationMode || "automatic",
|
body.stockCalculationMode || "automatic",
|
||||||
body.shareStockStatus !== false ? 1 : 0,
|
1,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -228,7 +225,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
body.expiryWarningDays ?? 90,
|
body.expiryWarningDays ?? 90,
|
||||||
body.language || "en",
|
body.language || "en",
|
||||||
body.stockCalculationMode || "automatic",
|
body.stockCalculationMode || "automatic",
|
||||||
body.shareStockStatus !== false ? 1 : 0,
|
1,
|
||||||
userId,
|
userId,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -550,62 +547,6 @@ describe("Settings API", () => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Share Stock Status
|
// Share Stock Status
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("Share Stock Status", () => {
|
|
||||||
it("should default to true (show stock on shared links)", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/settings",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json().shareStockStatus).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable share stock status", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "PUT",
|
|
||||||
url: "/settings",
|
|
||||||
payload: { shareStockStatus: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const getResponse = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/settings",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getResponse.json().shareStockStatus).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should re-enable share stock status", async () => {
|
|
||||||
// Disable first
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "PUT",
|
|
||||||
url: "/settings",
|
|
||||||
payload: { shareStockStatus: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-enable
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "PUT",
|
|
||||||
url: "/settings",
|
|
||||||
payload: { shareStockStatus: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const getResponse = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/settings",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getResponse.json().shareStockStatus).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Repeat Reminders & Skip Reminders Settings
|
// Repeat Reminders & Skip Reminders Settings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+46
-12
@@ -13,6 +13,7 @@ import { type Client, createClient } from "@libsql/client";
|
|||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Get migrations folder path
|
// Get migrations folder path
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -44,7 +45,7 @@ export async function buildTestApp(): Promise<TestContext> {
|
|||||||
await runTestMigrations(client);
|
await runTestMigrations(client);
|
||||||
|
|
||||||
// Create Fastify app with minimal plugins
|
// Create Fastify app with minimal plugins
|
||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
@@ -217,13 +218,20 @@ export interface UpdateUserSettingsOptions {
|
|||||||
stockCalculationMode?: "automatic" | "manual";
|
stockCalculationMode?: "automatic" | "manual";
|
||||||
lowStockDays?: number;
|
lowStockDays?: number;
|
||||||
shareStockStatus?: boolean;
|
shareStockStatus?: boolean;
|
||||||
|
shareMedicationOverview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update user settings
|
* Create or update user settings
|
||||||
*/
|
*/
|
||||||
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
|
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
|
||||||
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
|
const {
|
||||||
|
userId,
|
||||||
|
stockCalculationMode = "automatic",
|
||||||
|
lowStockDays = 30,
|
||||||
|
shareStockStatus,
|
||||||
|
shareMedicationOverview,
|
||||||
|
} = options;
|
||||||
|
|
||||||
// Check if settings exist
|
// Check if settings exist
|
||||||
const existing = await client.execute({
|
const existing = await client.execute({
|
||||||
@@ -232,20 +240,46 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
|
const updateArgs = [stockCalculationMode, lowStockDays] as Array<string | number>;
|
||||||
|
let updateSql = "UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?";
|
||||||
|
|
||||||
|
if (shareStockStatus !== undefined) {
|
||||||
|
updateSql += ", share_stock_status = ?";
|
||||||
|
updateArgs.push(shareStockStatus ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMedicationOverview !== undefined) {
|
||||||
|
updateSql += ", share_medication_overview = ?";
|
||||||
|
updateArgs.push(shareMedicationOverview ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSql += " WHERE user_id = ?";
|
||||||
|
updateArgs.push(userId);
|
||||||
|
|
||||||
await client.execute({
|
await client.execute({
|
||||||
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
|
sql: updateSql,
|
||||||
args:
|
args: updateArgs,
|
||||||
shareStockStatus !== undefined
|
|
||||||
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
|
|
||||||
: [stockCalculationMode, lowStockDays, userId],
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const insertColumns = ["user_id", "stock_calculation_mode", "low_stock_days"];
|
||||||
|
const insertPlaceholders = ["?", "?", "?"];
|
||||||
|
const insertArgs = [userId, stockCalculationMode, lowStockDays] as Array<string | number>;
|
||||||
|
|
||||||
|
if (shareStockStatus !== undefined) {
|
||||||
|
insertColumns.push("share_stock_status");
|
||||||
|
insertPlaceholders.push("?");
|
||||||
|
insertArgs.push(shareStockStatus ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareMedicationOverview !== undefined) {
|
||||||
|
insertColumns.push("share_medication_overview");
|
||||||
|
insertPlaceholders.push("?");
|
||||||
|
insertArgs.push(shareMedicationOverview ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
await client.execute({
|
await client.execute({
|
||||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
|
sql: `INSERT INTO user_settings (${insertColumns.join(", ")}) VALUES (${insertPlaceholders.join(", ")})`,
|
||||||
args:
|
args: insertArgs,
|
||||||
shareStockStatus !== undefined
|
|
||||||
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
|
|
||||||
: [userId, stockCalculationMode, lowStockDays],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,14 +142,6 @@ async function registerShareRoutes(ctx: TestContext) {
|
|||||||
|
|
||||||
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
|
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
|
||||||
|
|
||||||
// Get shareStockStatus setting
|
|
||||||
const shareStockResult = await client.execute({
|
|
||||||
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
|
|
||||||
args: [share.user_id],
|
|
||||||
});
|
|
||||||
const shareStockStatus =
|
|
||||||
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
takenBy: share.taken_by,
|
takenBy: share.taken_by,
|
||||||
sharedBy: share.owner_username,
|
sharedBy: share.owner_username,
|
||||||
@@ -158,7 +150,6 @@ async function registerShareRoutes(ctx: TestContext) {
|
|||||||
stockThresholds: {
|
stockThresholds: {
|
||||||
lowStockDays,
|
lowStockDays,
|
||||||
},
|
},
|
||||||
shareStockStatus,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,41 +422,6 @@ describe("Share Link API", () => {
|
|||||||
expect(med.blisters).toHaveLength(1);
|
expect(med.blisters).toHaveLength(1);
|
||||||
expect(med.blisters[0].usage).toBe(1);
|
expect(med.blisters[0].usage).toBe(1);
|
||||||
expect(med.blisters[0].every).toBe(1);
|
expect(med.blisters[0].every).toBe(1);
|
||||||
|
|
||||||
// shareStockStatus should default to true
|
|
||||||
expect(data.shareStockStatus).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should respect shareStockStatus setting when disabled", async () => {
|
|
||||||
// Create medication
|
|
||||||
await createTestMedication(ctx.client, {
|
|
||||||
userId,
|
|
||||||
name: "TestMed",
|
|
||||||
takenBy: ["Daniel"],
|
|
||||||
packCount: 1,
|
|
||||||
blistersPerPack: 1,
|
|
||||||
pillsPerBlister: 10,
|
|
||||||
looseTablets: 0,
|
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set shareStockStatus to false
|
|
||||||
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
expect(response.json().shareStockStatus).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 404 for invalid token", async () => {
|
it("should return 404 for invalid token", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv } = 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,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../plugins/auth.js", () => ({
|
||||||
|
requireAuth: async () => {},
|
||||||
|
getAnonymousUserId: async () => 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
|
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAnonymousUser() {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||||
|
args: [1, "anon", "anonymous"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStockMode(mode: "automatic" | "manual") {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||||
|
VALUES (?, ?, 7, 365, 'en')`,
|
||||||
|
args: [1, mode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMedication(options: {
|
||||||
|
name: string;
|
||||||
|
packCount?: number;
|
||||||
|
blistersPerPack?: number;
|
||||||
|
pillsPerBlister?: number;
|
||||||
|
looseTablets?: number;
|
||||||
|
stockAdjustment?: number;
|
||||||
|
lastStockCorrectionAt?: number | null;
|
||||||
|
isObsolete?: boolean;
|
||||||
|
takenBy?: string[];
|
||||||
|
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
packCount = 1,
|
||||||
|
blistersPerPack = 1,
|
||||||
|
pillsPerBlister = 10,
|
||||||
|
looseTablets = 0,
|
||||||
|
stockAdjustment = 0,
|
||||||
|
lastStockCorrectionAt = null,
|
||||||
|
isObsolete = false,
|
||||||
|
takenBy = [],
|
||||||
|
intakes,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||||
|
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||||
|
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||||
|
const intakesJson = JSON.stringify(
|
||||||
|
intakes.map((i) => ({
|
||||||
|
usage: i.usage,
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
takenBy: i.takenBy ?? null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, taken_by_json, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
stock_adjustment, last_stock_correction_at,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
is_obsolete, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
JSON.stringify(takenBy),
|
||||||
|
packCount,
|
||||||
|
blistersPerPack,
|
||||||
|
pillsPerBlister,
|
||||||
|
looseTablets,
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt,
|
||||||
|
usageJson,
|
||||||
|
everyJson,
|
||||||
|
startJson,
|
||||||
|
intakesJson,
|
||||||
|
isObsolete ? 1 : 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markDoseTaken(options: {
|
||||||
|
medicationId: number;
|
||||||
|
blisterIdx: number;
|
||||||
|
doseDateOnlyMs: number;
|
||||||
|
takenAtMs: number;
|
||||||
|
personSuffix?: string;
|
||||||
|
}) {
|
||||||
|
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||||
|
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||||
|
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||||
|
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate, endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const rows = response.json();
|
||||||
|
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnlyMs(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
await app.register(medicationRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
await seedAnonymousUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps automatic mode current stock in sync", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Auto Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(10);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Correction";
|
||||||
|
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
lastStockCorrectionAt: correctionMs,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||||
|
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||||
|
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan5DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan6DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts early taken dose in automatic mode without drift", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Early Taken";
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
tomorrow.setHours(20, 0, 0, 0);
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: tomorrowDateOnly,
|
||||||
|
takenAtMs: now.getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rangeStart = new Date(now);
|
||||||
|
rangeStart.setDate(now.getDate() - 1);
|
||||||
|
const rangeEnd = new Date(now);
|
||||||
|
rangeEnd.setDate(now.getDate() + 7);
|
||||||
|
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(9);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Mixed TakenBy";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
takenBy: ["Alice", "Bob"],
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
expect(usageRow.currentPills).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
await createMedication({
|
||||||
|
name: "Obsolete Med",
|
||||||
|
isObsolete: true,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||||
|
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLiquidReminderThresholds", () => {
|
||||||
|
// Import the function for testing (test-only export)
|
||||||
|
// The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number }
|
||||||
|
// Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2)
|
||||||
|
|
||||||
|
it("derives critical as ceil(baseline / 2) for typical baseline", () => {
|
||||||
|
// For baseline=7 days: low=7, critical=ceil(7/2)=4
|
||||||
|
const baseline = 7;
|
||||||
|
// Manually apply the formula to verify
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(7);
|
||||||
|
expect(expectedCritical).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives critical correctly at boundary: baseline=1", () => {
|
||||||
|
// For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...))
|
||||||
|
const baseline = 1;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(1);
|
||||||
|
expect(expectedCritical).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for even baseline (baseline=14)", () => {
|
||||||
|
// For baseline=14: low=14, critical=ceil(14/2)=7
|
||||||
|
const baseline = 14;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(14);
|
||||||
|
expect(expectedCritical).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for odd baseline (baseline=15)", () => {
|
||||||
|
// For baseline=15: low=15, critical=ceil(15/2)=8
|
||||||
|
const baseline = 15;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(15);
|
||||||
|
expect(expectedCritical).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+8
-1
@@ -5,7 +5,12 @@ import "@fastify/jwt";
|
|||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
}
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
method: "session" | "api_key";
|
||||||
|
scope: "read" | "write";
|
||||||
|
apiKeyId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "fastify" {
|
declare module "fastify" {
|
||||||
@@ -22,6 +27,8 @@ declare module "fastify" {
|
|||||||
|
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
user?: AuthUser | null;
|
user?: AuthUser | null;
|
||||||
|
authContext?: AuthContext;
|
||||||
|
correlationId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Plugin } from "ajv";
|
||||||
|
|
||||||
|
export const registerDocumentationSchemaKeywords: Plugin<unknown> = (ajv) => {
|
||||||
|
ajv.addKeyword({ keyword: "example", valid: true });
|
||||||
|
return ajv;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentationSchemaAjv = {
|
||||||
|
plugins: [registerDocumentationSchemaKeywords],
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { extname, resolve } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
|
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function getThumbFilename(imageFilename: string): string {
|
||||||
|
const ext = extname(imageFilename);
|
||||||
|
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
|
||||||
|
return `${base}-thumb.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
|
||||||
|
const fullPath = resolve(imagesDir, imageFilename);
|
||||||
|
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||||
|
|
||||||
|
const thumbFilename = getThumbFilename(imageFilename);
|
||||||
|
if (thumbFilename !== imageFilename) {
|
||||||
|
const thumbPath = resolve(imagesDir, thumbFilename);
|
||||||
|
if (existsSync(thumbPath)) unlinkSync(thumbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
totalSize += buffer.length;
|
||||||
|
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
throw new Error("IMAGE_TOO_LARGE");
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeOptimizedImageSet(
|
||||||
|
imagesDir: string,
|
||||||
|
filePrefix: string,
|
||||||
|
uploadBuffer: Buffer,
|
||||||
|
options?: {
|
||||||
|
maxEdgePx?: number;
|
||||||
|
thumbSizePx?: number;
|
||||||
|
fullQuality?: number;
|
||||||
|
thumbQuality?: number;
|
||||||
|
}
|
||||||
|
): Promise<{ filename: string; thumbFilename: string }> {
|
||||||
|
const maxEdgePx = options?.maxEdgePx ?? 1600;
|
||||||
|
const thumbSizePx = options?.thumbSizePx ?? 96;
|
||||||
|
const fullQuality = options?.fullQuality ?? 82;
|
||||||
|
const thumbQuality = options?.thumbQuality ?? 76;
|
||||||
|
|
||||||
|
const filename = `${filePrefix}-${Date.now()}.webp`;
|
||||||
|
const thumbFilename = getThumbFilename(filename);
|
||||||
|
|
||||||
|
const filepath = resolve(imagesDir, filename);
|
||||||
|
const thumbFilepath = resolve(imagesDir, thumbFilename);
|
||||||
|
|
||||||
|
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
|
||||||
|
.webp({ quality: fullQuality })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
|
||||||
|
.webp({ quality: thumbQuality })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await writeFile(filepath, optimizedBuffer);
|
||||||
|
await writeFile(thumbFilepath, thumbBuffer);
|
||||||
|
|
||||||
|
return { filename, thumbFilename };
|
||||||
|
}
|
||||||
@@ -23,18 +23,22 @@ function shouldLog(level: string): boolean {
|
|||||||
return LOG_LEVELS[level] >= getLevel();
|
return LOG_LEVELS[level] >= getLevel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ts(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export const log = {
|
export const log = {
|
||||||
debug(msg: string): void {
|
debug(msg: string): void {
|
||||||
if (shouldLog("debug")) console.log(msg);
|
if (shouldLog("debug")) console.log(`[${ts()}] [DEBUG] ${msg}`);
|
||||||
},
|
},
|
||||||
info(msg: string): void {
|
info(msg: string): void {
|
||||||
if (shouldLog("info")) console.log(msg);
|
if (shouldLog("info")) console.log(`[${ts()}] [INFO] ${msg}`);
|
||||||
},
|
},
|
||||||
warn(msg: string): void {
|
warn(msg: string): void {
|
||||||
if (shouldLog("warn")) console.warn(msg);
|
if (shouldLog("warn")) console.warn(`[${ts()}] [WARN] ${msg}`);
|
||||||
},
|
},
|
||||||
error(msg: string): void {
|
error(msg: string): void {
|
||||||
if (shouldLog("error")) console.error(msg);
|
if (shouldLog("error")) console.error(`[${ts()}] [ERROR] ${msg}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { FastifyInstance, RouteOptions } from "fastify";
|
||||||
|
|
||||||
|
type SecurityEntry = Readonly<Record<string, readonly string[]>>;
|
||||||
|
|
||||||
|
const defaultProtectedSecurity: readonly SecurityEntry[] = [{ bearerAuth: [] }, { cookieAuth: [] }];
|
||||||
|
|
||||||
|
export const genericErrorSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const validationErrorSchema = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const idParamsSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", pattern: "^\\d+$" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const tokenParamsSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["token"],
|
||||||
|
properties: {
|
||||||
|
token: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const successResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const messageResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpenApiRouteStandardsOptions = {
|
||||||
|
tag: string;
|
||||||
|
protectedByDefault: boolean;
|
||||||
|
protectedPaths?: RegExp[];
|
||||||
|
publicPaths?: RegExp[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function asMethods(method: RouteOptions["method"]): string[] {
|
||||||
|
if (Array.isArray(method)) return method.map((m) => String(m).toUpperCase());
|
||||||
|
return [String(method).toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatches(path: string, patterns: RegExp[] | undefined): boolean {
|
||||||
|
if (!patterns || patterns.length === 0) return false;
|
||||||
|
return patterns.some((pattern) => pattern.test(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultSummary(methods: string[], path: string): string {
|
||||||
|
const methodText = methods.join("/");
|
||||||
|
return `${methodText} ${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultDescription(requiresAuth: boolean): string {
|
||||||
|
return requiresAuth
|
||||||
|
? "Protected endpoint. Requires Bearer token (API key or JWT) or session cookie."
|
||||||
|
: "Public endpoint.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOpenApiRouteStandards(app: FastifyInstance, options: OpenApiRouteStandardsOptions): void {
|
||||||
|
app.addHook("onRoute", (routeOptions) => {
|
||||||
|
const methods = asMethods(routeOptions.method);
|
||||||
|
const path = routeOptions.url;
|
||||||
|
|
||||||
|
const isExplicitlyPublic = pathMatches(path, options.publicPaths);
|
||||||
|
const isExplicitlyProtected = pathMatches(path, options.protectedPaths);
|
||||||
|
let requiresAuth = options.protectedByDefault;
|
||||||
|
if (isExplicitlyPublic) {
|
||||||
|
requiresAuth = false;
|
||||||
|
} else if (isExplicitlyProtected) {
|
||||||
|
requiresAuth = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
routeOptions.schema = routeOptions.schema ?? {};
|
||||||
|
routeOptions.schema.tags = routeOptions.schema.tags ?? [options.tag];
|
||||||
|
routeOptions.schema.summary = routeOptions.schema.summary ?? buildDefaultSummary(methods, path);
|
||||||
|
routeOptions.schema.description = routeOptions.schema.description ?? buildDefaultDescription(requiresAuth);
|
||||||
|
|
||||||
|
if (requiresAuth) {
|
||||||
|
routeOptions.schema.security = routeOptions.schema.security ?? defaultProtectedSecurity;
|
||||||
|
routeOptions.schema.response = {
|
||||||
|
...(routeOptions.schema.response ?? {}),
|
||||||
|
401: (routeOptions.schema.response as Record<number | string, unknown> | undefined)?.[401] ?? {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
code: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (routeOptions.schema.security === undefined) {
|
||||||
|
routeOptions.schema.security = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||||
|
|
||||||
|
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||||
|
|
||||||
|
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
|
||||||
|
|
||||||
|
export function normalizePackageType(packageType?: string | null): PackageType {
|
||||||
|
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
|
||||||
|
return packageType as PackageType;
|
||||||
|
}
|
||||||
|
return "blister";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTubePackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "tube";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
if (normalized === "tube") return "units";
|
||||||
|
if (normalized === "liquid_container") return "ml";
|
||||||
|
return "pills";
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||||
|
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
|
||||||
|
|
||||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||||
export type Blister = { usage: number; every: number; start: string };
|
export type Blister = { usage: number; every: number; start: string };
|
||||||
@@ -13,10 +14,39 @@ export type Intake = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
|
||||||
|
value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize intake usage for stock math.
|
||||||
|
*
|
||||||
|
* Stock semantics:
|
||||||
|
* - tube: no automatic depletion (unknown per-application amount)
|
||||||
|
* - liquid_container/liquid forms: convert tsp/tbsp to ml
|
||||||
|
* - others: usage as-is
|
||||||
|
*/
|
||||||
|
export function normalizeIntakeUsageForStock(
|
||||||
|
intake: Pick<Intake, "usage" | "intakeUnit">,
|
||||||
|
medicationForm?: string | null,
|
||||||
|
packageType?: string | null
|
||||||
|
): number {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
|
if (isTubePackageType(packageType)) return 0;
|
||||||
|
|
||||||
|
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
|
||||||
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Timezone utilities
|
// Timezone utilities
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -122,7 +152,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
|||||||
/** Calculate milliseconds until next check at the given reminder hour */
|
/** Calculate milliseconds until next check at the given reminder hour */
|
||||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||||
const next = getNextScheduledTime(reminderHour, tz);
|
const next = getNextScheduledTime(reminderHour, tz);
|
||||||
return next.getTime() - Date.now();
|
const msUntilNext = next.getTime() - Date.now();
|
||||||
|
if (msUntilNext <= 0) {
|
||||||
|
return msUntilNext + 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
return msUntilNext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -195,6 +229,7 @@ export function parseIntakesJson(
|
|||||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||||
every: typeof intake.every === "number" ? intake.every : 1,
|
every: typeof intake.every === "number" ? intake.every : 1,
|
||||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||||
|
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
|
||||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||||
intakeRemindersEnabled:
|
intakeRemindersEnabled:
|
||||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||||
@@ -212,6 +247,7 @@ export function parseIntakesJson(
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // Legacy format has no per-intake takenBy
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -256,6 +292,7 @@ export function getAllTakenByForMedication(medicationTakenBy: string[], intakes:
|
|||||||
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
|
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
|
||||||
*/
|
*/
|
||||||
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
|
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
|
||||||
|
if (person === "all") return medicationTakenBy.length > 0 || intakes.some((intake) => intake.takenBy !== null);
|
||||||
if (medicationTakenBy.includes(person)) return true;
|
if (medicationTakenBy.includes(person)) return true;
|
||||||
return intakes.some((intake) => intake.takenBy === person);
|
return intakes.some((intake) => intake.takenBy === person);
|
||||||
}
|
}
|
||||||
@@ -483,6 +520,7 @@ export function getUpcomingIntakes(
|
|||||||
export type ReminderState = {
|
export type ReminderState = {
|
||||||
lastAutoEmailSent: string | null;
|
lastAutoEmailSent: string | null;
|
||||||
lastAutoEmailDate: string | null;
|
lastAutoEmailDate: string | null;
|
||||||
|
lastStockSchedulerCheckDate: string | null;
|
||||||
notifiedMedications: string[];
|
notifiedMedications: string[];
|
||||||
nextScheduledCheck: string | null;
|
nextScheduledCheck: string | null;
|
||||||
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
||||||
@@ -505,6 +543,7 @@ export function createDefaultReminderState(): ReminderState {
|
|||||||
return {
|
return {
|
||||||
lastAutoEmailSent: null,
|
lastAutoEmailSent: null,
|
||||||
lastAutoEmailDate: null,
|
lastAutoEmailDate: null,
|
||||||
|
lastStockSchedulerCheckDate: null,
|
||||||
notifiedMedications: [],
|
notifiedMedications: [],
|
||||||
nextScheduledCheck: null,
|
nextScheduledCheck: null,
|
||||||
lastNotificationType: null,
|
lastNotificationType: null,
|
||||||
@@ -524,6 +563,7 @@ export function parseReminderState(json: string): ReminderState {
|
|||||||
return {
|
return {
|
||||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||||
|
lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
|
||||||
notifiedMedications: saved.notifiedMedications ?? [],
|
notifiedMedications: saved.notifiedMedications ?? [],
|
||||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||||
lastNotificationType: saved.lastNotificationType ?? null,
|
lastNotificationType: saved.lastNotificationType ?? null,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: medassist-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend-dev:
|
backend-dev:
|
||||||
image: node:22-slim
|
image: node:22-slim
|
||||||
@@ -10,6 +12,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- RATE_LIMIT_MAX=1000
|
- RATE_LIMIT_MAX=1000
|
||||||
ports:
|
ports:
|
||||||
@@ -33,6 +36,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
- BACKEND_URL=http://backend-dev:3000
|
- BACKEND_URL=http://backend-dev:3000
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: medassist-ng
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/danielvolz/medassist-ng-backend:latest
|
image: ghcr.io/danielvolz/medassist-ng-backend:latest
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Default User Settings
|
||||||
|
|
||||||
|
This document lists all environment variables used as defaults for new users.
|
||||||
|
|
||||||
|
Scope and behavior:
|
||||||
|
|
||||||
|
- These values are applied only when a user's settings are created for the first time.
|
||||||
|
- After that, values stored in the database are used and take precedence.
|
||||||
|
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
||||||
|
|
||||||
|
## Email Defaults
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_EMAIL_ENABLED` | `false` | Enable email notifications by default. |
|
||||||
|
| `DEFAULT_NOTIFICATION_EMAIL` | empty | Default notification email address. |
|
||||||
|
| `DEFAULT_EMAIL_STOCK_REMINDERS` | `true` | Send stock reminders via email. |
|
||||||
|
| `DEFAULT_EMAIL_INTAKE_REMINDERS` | `true` | Send intake reminders via email. |
|
||||||
|
| `DEFAULT_EMAIL_PRESCRIPTION_REMINDERS` | `true` | Send prescription reminders via email. |
|
||||||
|
|
||||||
|
## Push Defaults (Shoutrrr)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default. |
|
||||||
|
| `DEFAULT_SHOUTRRR_URL` | empty | Default Shoutrrr URL. |
|
||||||
|
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock reminders via push. |
|
||||||
|
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push. |
|
||||||
|
| `DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS` | `true` | Send prescription reminders via push. |
|
||||||
|
|
||||||
|
## Reminder and Stock Defaults
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_REPEAT_DAILY_REMINDERS` | `false` | Repeat stock reminders daily. |
|
||||||
|
| `DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES` | `false` | Skip reminders for doses already marked as taken. |
|
||||||
|
| `DEFAULT_REPEAT_REMINDERS_ENABLED` | `false` | Enable repeat reminders (nagging) for missed doses. |
|
||||||
|
| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | `30` | Repeat interval for nagging reminders. |
|
||||||
|
| `DEFAULT_MAX_NAGGING_REMINDERS` | `5` | Maximum number of repeat reminders per dose. |
|
||||||
|
| `DEFAULT_LOW_STOCK_DAYS` | `30` | Low stock threshold in days. |
|
||||||
|
| `DEFAULT_NORMAL_STOCK_DAYS` | `90` | Normal stock threshold in days. |
|
||||||
|
| `DEFAULT_HIGH_STOCK_DAYS` | `180` | High stock threshold in days. |
|
||||||
|
|
||||||
|
## UI Defaults
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
||||||
|
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||||
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# GitHub Project Setup
|
|
||||||
|
|
||||||
This repository includes a GitHub Actions workflow that automatically adds new issues to a GitHub Project for tracking feature requests and bugs.
|
|
||||||
|
|
||||||
## Setup Steps
|
|
||||||
|
|
||||||
### 1. Create a GitHub Project
|
|
||||||
|
|
||||||
1. Go to your GitHub profile → **Projects** → **New project**
|
|
||||||
2. Choose the **Board** template (recommended for feature tracking)
|
|
||||||
3. Name it e.g. **MedAssist-ng Roadmap**
|
|
||||||
4. Configure the default columns:
|
|
||||||
- **Triage** – New issues land here
|
|
||||||
- **Backlog** – Accepted but not yet started
|
|
||||||
- **In Progress** – Currently being worked on
|
|
||||||
- **Done** – Completed
|
|
||||||
|
|
||||||
### 2. Create a Personal Access Token (PAT)
|
|
||||||
|
|
||||||
The workflow needs a token with project permissions. The built-in `GITHUB_TOKEN` does not support GitHub Projects.
|
|
||||||
|
|
||||||
1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Fine-grained tokens**
|
|
||||||
2. Click **Generate new token**
|
|
||||||
3. Set:
|
|
||||||
- **Token name**: `add-to-project`
|
|
||||||
- **Expiration**: Choose an appropriate duration
|
|
||||||
- **Repository access**: Select **Only select repositories** → `DanielVolz/medassist-ng`
|
|
||||||
- **Permissions**:
|
|
||||||
- Repository permissions: **Issues** → Read
|
|
||||||
- Organization permissions (if applicable): **Projects** → Read and write
|
|
||||||
- For **user-owned projects**, you need a **classic** token with the `project` scope instead
|
|
||||||
4. Copy the generated token
|
|
||||||
|
|
||||||
### 3. Add Repository Secrets and Variables
|
|
||||||
|
|
||||||
1. Go to the repository → **Settings** → **Secrets and variables** → **Actions**
|
|
||||||
2. Add a **secret**:
|
|
||||||
- Name: `ADD_TO_PROJECT_PAT`
|
|
||||||
- Value: The PAT from step 2
|
|
||||||
3. Add a **variable** (under the **Variables** tab):
|
|
||||||
- Name: `PROJECT_URL`
|
|
||||||
- Value: The full URL of your GitHub Project (e.g. `https://github.com/users/DanielVolz/projects/1`)
|
|
||||||
|
|
||||||
### 4. Verify
|
|
||||||
|
|
||||||
1. Create a test issue using the **✨ Feature Request** template
|
|
||||||
2. Check the **Actions** tab to see the workflow run
|
|
||||||
3. Verify the issue appears in your GitHub Project under **Triage**
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
The workflow (`.github/workflows/add-to-project.yml`) triggers when:
|
|
||||||
- A new issue is **opened**
|
|
||||||
- A label is **added** to an existing issue
|
|
||||||
|
|
||||||
Issues with any of these labels are automatically added to the project:
|
|
||||||
- `enhancement` – Feature requests
|
|
||||||
- `bug` – Bug reports
|
|
||||||
- `triage` – New issues needing review
|
|
||||||
|
|
||||||
Both the feature request and bug report issue templates automatically apply the `triage` label, so all new issues from templates are captured.
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
### Adding more labels
|
|
||||||
|
|
||||||
Edit `.github/workflows/add-to-project.yml` and add labels to the `labeled` field:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
labeled: enhancement, bug, triage, documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restricting to feature requests only
|
|
||||||
|
|
||||||
Change the `labeled` field to only include `enhancement`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
labeled: enhancement
|
|
||||||
label-operator: OR
|
|
||||||
```
|
|
||||||
+74
-30
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { expect, test as setup } from "@playwright/test";
|
import { expect, test as setup } from "@playwright/test";
|
||||||
import { TEST_USER } from "./fixtures";
|
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||||
|
|
||||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
|
|||||||
* 4. Log in via the UI.
|
* 4. Log in via the UI.
|
||||||
*/
|
*/
|
||||||
setup("authenticate", async ({ page }) => {
|
setup("authenticate", async ({ page }) => {
|
||||||
|
await applyVideoSafetyMode(page);
|
||||||
|
|
||||||
// Create .auth directory if it doesn't exist
|
// Create .auth directory if it doesn't exist
|
||||||
const authDir = path.dirname(authFile);
|
const authDir = path.dirname(authFile);
|
||||||
if (!fs.existsSync(authDir)) {
|
if (!fs.existsSync(authDir)) {
|
||||||
@@ -68,40 +70,82 @@ setup("authenticate", async ({ page }) => {
|
|||||||
// Wait for auth container
|
// Wait for auth container
|
||||||
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// ---- 3. Ensure the test user exists ----
|
// ---- 3. Query auth state to determine login method ----
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
await page.request
|
let formLoginEnabled = true;
|
||||||
.post(`${baseURL}/api/auth/register`, {
|
let oidcEnabled = false;
|
||||||
data: { username: TEST_USER.username, password: TEST_USER.password },
|
try {
|
||||||
})
|
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
|
||||||
.catch(() => {});
|
if (stateRes.ok()) {
|
||||||
|
const state = await stateRes.json();
|
||||||
// ---- 4. Log in via UI ----
|
formLoginEnabled = state.formLoginEnabled !== false;
|
||||||
const usernameField = page.locator("#username");
|
oidcEnabled = state.oidcEnabled === true;
|
||||||
const passwordField = page.locator("#password");
|
|
||||||
|
|
||||||
// Make sure we're on the login form (not register)
|
|
||||||
const isOnRegister = await page
|
|
||||||
.locator(".auth-subtitle")
|
|
||||||
.filter({ hasText: /Create Account/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (isOnRegister) {
|
|
||||||
const switchBtn = page.locator("button.auth-link-btn");
|
|
||||||
if (await switchBtn.isVisible().catch(() => false)) {
|
|
||||||
await switchBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: assume form login is available
|
||||||
}
|
}
|
||||||
|
|
||||||
await usernameField.clear();
|
// ---- 4. Ensure the test user exists (only if form login is available) ----
|
||||||
await usernameField.fill(TEST_USER.username);
|
if (formLoginEnabled) {
|
||||||
await passwordField.clear();
|
await page.request
|
||||||
await passwordField.fill(TEST_USER.password);
|
.post(`${baseURL}/api/auth/register`, {
|
||||||
|
data: { username: TEST_USER.username, password: TEST_USER.password },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Click the submit button (not the SSO button)
|
// ---- 5. Log in via the appropriate method ----
|
||||||
await page.locator('button.auth-submit[type="submit"]').click();
|
if (formLoginEnabled) {
|
||||||
|
// Form login path: username/password
|
||||||
|
const usernameField = page.locator("#username");
|
||||||
|
const passwordField = page.locator("#password");
|
||||||
|
|
||||||
|
// Make sure we're on the login form (not register)
|
||||||
|
const isOnRegister = await page
|
||||||
|
.locator(".auth-subtitle")
|
||||||
|
.filter({ hasText: /Create Account/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (isOnRegister) {
|
||||||
|
const switchBtn = page.locator("button.auth-link-btn");
|
||||||
|
if (await switchBtn.isVisible().catch(() => false)) {
|
||||||
|
await switchBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await usernameField.clear();
|
||||||
|
await usernameField.fill(TEST_USER.username);
|
||||||
|
await passwordField.clear();
|
||||||
|
await passwordField.fill(TEST_USER.password);
|
||||||
|
|
||||||
|
// Click the submit button (not the SSO button)
|
||||||
|
await page.locator('button.auth-submit[type="submit"]').click();
|
||||||
|
} else if (oidcEnabled) {
|
||||||
|
// SSO-only path: click the SSO button and let the OIDC provider handle login.
|
||||||
|
// This requires the OIDC provider to be configured with test credentials
|
||||||
|
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
|
||||||
|
// or to auto-approve the test user.
|
||||||
|
await page.locator("button.sso-btn").click();
|
||||||
|
|
||||||
|
// Wait for OIDC redirect and callback — the provider may show its own login form
|
||||||
|
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
|
||||||
|
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
|
||||||
|
if (oidcUsername && oidcPassword) {
|
||||||
|
// Fill OIDC provider login form (generic selectors — override if needed)
|
||||||
|
await page.waitForURL(/.*/, { timeout: 15000 });
|
||||||
|
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
|
||||||
|
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
|
||||||
|
await oidcUserField.fill(oidcUsername);
|
||||||
|
await oidcPassField.fill(oidcPassword);
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("No login method available: form login and OIDC are both disabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for successful auth — app header should appear
|
// Wait for successful auth — app header should appear
|
||||||
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
async function isAuthEnabled(page: Page): Promise<boolean> {
|
interface AuthStateResponse {
|
||||||
|
authEnabled: boolean;
|
||||||
|
formLoginEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcProviderName: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
|
||||||
try {
|
try {
|
||||||
const response = await page.request.get("/api/auth/state");
|
const response = await page.request.get("/api/auth/state");
|
||||||
if (!response.ok()) return true;
|
if (!response.ok()) return null;
|
||||||
const state = await response.json();
|
return (await response.json()) as AuthStateResponse;
|
||||||
return state?.authEnabled !== false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isAuthEnabled(page: Page): Promise<boolean> {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
return state?.authEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication E2E Tests
|
* Authentication E2E Tests
|
||||||
*
|
*
|
||||||
@@ -110,4 +122,48 @@ test.describe("Authentication", () => {
|
|||||||
const newText = await subtitle.textContent();
|
const newText = await subtitle.textContent();
|
||||||
expect(newText).not.toBe(initialText);
|
expect(newText).not.toBe(initialText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should show SSO button when OIDC is enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const ssoButton = page.locator("button.sso-btn");
|
||||||
|
await expect(ssoButton).toBeVisible();
|
||||||
|
await expect(ssoButton).toContainText(state.oidcProviderName || "SSO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should hide form login when formLoginEnabled is false", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Username/password fields should not be visible
|
||||||
|
await expect(page.locator("#username")).not.toBeVisible();
|
||||||
|
await expect(page.locator("#password")).not.toBeVisible();
|
||||||
|
|
||||||
|
// SSO button should be the only login method
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show both login methods when OIDC and form login are enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled");
|
||||||
|
test.skip(!state?.formLoginEnabled, "Form login is not enabled");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Both login methods visible
|
||||||
|
await expect(page.locator("#username")).toBeVisible();
|
||||||
|
await expect(page.locator("#password")).toBeVisible();
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show medication overview table with medications", async ({ page }) => {
|
test("should show medication overview table with medications", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show status chips in overview table", async ({ page }) => {
|
test("should show status chips in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Each medication row should have a status chip
|
// Each medication row should have a status chip
|
||||||
@@ -88,7 +88,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show stock information in overview", async ({ page }) => {
|
test("should show stock information in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
||||||
@@ -117,6 +117,9 @@ test.describe("Dashboard with medications", () => {
|
|||||||
|
|
||||||
test("should show day summary with dose progress", async ({ page }) => {
|
test("should show day summary with dose progress", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(overviewTable.getByText(MED_1)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||||
@@ -202,7 +205,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should open medication detail modal from overview table", async ({ page }) => {
|
test("should open medication detail modal from overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
||||||
|
|||||||
+168
-28
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce visual flashing in recorded videos by forcing a dark first paint and
|
||||||
|
* disabling most animations/transitions in test mode.
|
||||||
|
*/
|
||||||
|
export async function applyVideoSafetyMode(page: Page): Promise<void> {
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "pw-video-safety-style";
|
||||||
|
style.textContent = `
|
||||||
|
html, body {
|
||||||
|
background: #111111 !important;
|
||||||
|
color-scheme: dark !important;
|
||||||
|
}
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.documentElement.appendChild(style);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended test fixture that automatically mocks /auth/me on every page
|
* Extended test fixture that automatically mocks /auth/me on every page
|
||||||
* using user data from the JWT in the stored auth file.
|
* using user data from the JWT in the stored auth file.
|
||||||
@@ -70,8 +93,9 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||||
* since it tests the unauthenticated flow.
|
* since it tests the unauthenticated flow.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{}>({
|
export const test = base.extend<object>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
|
await applyVideoSafetyMode(page);
|
||||||
await setupAuthMeMock(page);
|
await setupAuthMeMock(page);
|
||||||
await use(page);
|
await use(page);
|
||||||
},
|
},
|
||||||
@@ -79,25 +103,43 @@ export const test = base.extend<{}>({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the app to be fully loaded past any loading/initializing screens.
|
* Wait for the app to be fully loaded past any loading/initializing screens.
|
||||||
* Includes a single retry with page reload to handle transient auth failures
|
* Retries up to 2 times with page reload to handle transient auth or
|
||||||
* (e.g. brief race between context setup and cookie application).
|
* rate-limit failures.
|
||||||
*/
|
*/
|
||||||
export async function waitForAppReady(page: Page): Promise<void> {
|
export async function waitForAppReady(page: Page): Promise<void> {
|
||||||
const hero = page.locator("header.hero");
|
const hero = page.locator("header.hero");
|
||||||
try {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
try {
|
||||||
} catch {
|
await expect(hero).toBeVisible({ timeout: 15000 });
|
||||||
// Auth might have failed transiently — reload and retry once
|
return;
|
||||||
await page.reload();
|
} catch {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
if (attempt === 2) throw new Error("App failed to become ready after 3 attempts");
|
||||||
|
// Check for rate-limit error displayed in UI
|
||||||
|
const rateLimited = await page
|
||||||
|
.locator("text=rate limit, text=429, text=too many")
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
if (rateLimited) {
|
||||||
|
// Wait longer before retrying if rate-limited
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a page and wait for it to be ready.
|
* Navigate to a page and wait for it to be ready.
|
||||||
|
* Handles transient navigation failures with a single retry.
|
||||||
*/
|
*/
|
||||||
export async function navigateTo(page: Page, path: string): Promise<void> {
|
export async function navigateTo(page: Page, path: string): Promise<void> {
|
||||||
await page.goto(path);
|
const response = await page.goto(path);
|
||||||
|
if (response && response.status() === 429) {
|
||||||
|
// Rate-limited — wait and retry once
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await page.goto(path);
|
||||||
|
}
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
}
|
}
|
||||||
@@ -135,7 +177,9 @@ export { expect };
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
function getAuthCookie(): string | null {
|
let cachedAuthCookie: string | null = null;
|
||||||
|
|
||||||
|
function readAuthCookieFromFile(): string | null {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
||||||
@@ -144,6 +188,49 @@ function getAuthCookie(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeaders: string[], name: string): string | null {
|
||||||
|
for (const header of setCookieHeaders) {
|
||||||
|
const [pair] = header.split(";");
|
||||||
|
if (!pair) continue;
|
||||||
|
const [cookieName, ...valueParts] = pair.split("=");
|
||||||
|
if (cookieName?.trim() !== name) continue;
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthCookieViaLogin(): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: TEST_USER.username,
|
||||||
|
password: TEST_USER.password,
|
||||||
|
rememberMe: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const getSetCookie = (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
const setCookieHeaders = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
||||||
|
const fallback = res.headers.get("set-cookie");
|
||||||
|
if (fallback) setCookieHeaders.push(fallback);
|
||||||
|
|
||||||
|
const accessToken = extractCookieValue(setCookieHeaders, "access_token");
|
||||||
|
if (accessToken) {
|
||||||
|
cachedAuthCookie = accessToken;
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthCookie(): string | null {
|
||||||
|
if (cachedAuthCookie) return cachedAuthCookie;
|
||||||
|
cachedAuthCookie = readAuthCookieFromFile();
|
||||||
|
return cachedAuthCookie;
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed medication response (subset of fields we care about) */
|
/** Typed medication response (subset of fields we care about) */
|
||||||
export interface TestMedication {
|
export interface TestMedication {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -172,12 +259,14 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string[];
|
takenBy?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
|
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
intakeRemindersEnabled?: boolean;
|
intakeRemindersEnabled?: boolean;
|
||||||
intakes?: {
|
intakes?: {
|
||||||
usage: number;
|
usage: number;
|
||||||
@@ -187,16 +276,30 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string | null;
|
takenBy?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}): Promise<TestMedication> {
|
}): Promise<TestMedication> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
const isBottle = data.packageType === "bottle";
|
const packageType = data.packageType ?? "blister";
|
||||||
|
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||||
|
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||||
|
if (packageType === "tube") {
|
||||||
|
defaultMedicationForm = "topical";
|
||||||
|
} else if (packageType === "liquid_container") {
|
||||||
|
defaultMedicationForm = "liquid";
|
||||||
|
}
|
||||||
|
const medicationForm = data.medicationForm ?? defaultMedicationForm;
|
||||||
|
const packageAmountValue =
|
||||||
|
data.packageAmountValue ??
|
||||||
|
(packageType === "tube" || packageType === "liquid_container" ? Math.max(1, data.totalPills ?? 30) : 0);
|
||||||
const body = {
|
const body = {
|
||||||
packageType: isBottle ? "bottle" : "blister",
|
packageType,
|
||||||
packCount: isBottle ? 1 : (data.packCount ?? 1),
|
medicationForm,
|
||||||
blistersPerPack: isBottle ? 1 : (data.blistersPerPack ?? 1),
|
packCount: packageType === "tube" ? 1 : (data.packCount ?? 1),
|
||||||
pillsPerBlister: isBottle ? 1 : (data.pillsPerBlister ?? 10),
|
blistersPerPack: isAmountBased ? 1 : (data.blistersPerPack ?? 1),
|
||||||
// For bottles: looseTablets IS the current stock. Default to totalPills if not specified.
|
pillsPerBlister: isAmountBased ? 1 : (data.pillsPerBlister ?? 10),
|
||||||
looseTablets: isBottle ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
// Amount-based packages use looseTablets as current stock.
|
||||||
totalPills: isBottle ? (data.totalPills ?? null) : null,
|
looseTablets: isAmountBased ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
||||||
|
totalPills: isAmountBased ? (data.totalPills ?? null) : null,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit: packageType === "tube" ? "g" : "ml",
|
||||||
intakes: [
|
intakes: [
|
||||||
{
|
{
|
||||||
usage: 1,
|
usage: 1,
|
||||||
@@ -219,6 +322,10 @@ export async function createMedicationViaAPI(data: {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
@@ -235,13 +342,25 @@ export async function createMedicationViaAPI(data: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a medication via the backend API.
|
* Delete a medication via the backend API.
|
||||||
|
* Includes retry for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
await fetch(`${API_BASE}/api/medications/${id}`, {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
method: "DELETE",
|
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
method: "DELETE",
|
||||||
});
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,11 +368,15 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
|||||||
* Includes retry logic for rate-limited responses.
|
* Includes retry logic for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/medications`, {
|
const res = await fetch(`${API_BASE}/api/medications`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
@@ -266,6 +389,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (delRes.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (delRes.status === 429) {
|
if (delRes.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
continue;
|
continue;
|
||||||
@@ -282,7 +409,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
* Requires a medication with takenBy to exist first.
|
* Requires a medication with takenBy to exist first.
|
||||||
*/
|
*/
|
||||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/share`, {
|
const res = await fetch(`${API_BASE}/api/share`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -292,10 +419,23 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
body: JSON.stringify({ takenBy, scheduleDays }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (res.status === 400) {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text.includes('"code":"NO_MEDICATIONS"') && attempt < 4) {
|
||||||
|
// Freshly seeded E2E medication data can lag briefly behind the share lookup.
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to create share token: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(`Failed to create share token: ${res.status} ${text}`);
|
throw new Error(`Failed to create share token: ${res.status} ${text}`);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
|||||||
opts: {
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
genericName?: string;
|
genericName?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
packs?: string;
|
packs?: string;
|
||||||
blistersPerPack?: string;
|
blistersPerPack?: string;
|
||||||
pillsPerBlister?: string;
|
pillsPerBlister?: string;
|
||||||
@@ -56,6 +56,18 @@ async function fillAndSaveMedication(
|
|||||||
if (opts.totalCapacity)
|
if (opts.totalCapacity)
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||||
|
} else if (opts.packageType === "tube") {
|
||||||
|
await packageTypeSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
|
} else if (opts.packageType === "liquid_container") {
|
||||||
|
await packageTypeSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Package amount|form\.packageAmount)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await packageTypeSelect.selectOption("blister");
|
await packageTypeSelect.selectOption("blister");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
@@ -83,7 +95,11 @@ async function fillAndSaveMedication(
|
|||||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = form.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
await row
|
||||||
|
.getByLabel(
|
||||||
|
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
||||||
|
)
|
||||||
|
.fill(intakes[i].usage);
|
||||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +210,26 @@ test.describe("Medication CRUD", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create a tube medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Tube Cream",
|
||||||
|
packageType: "tube",
|
||||||
|
totalCapacity: "50",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create a liquid-container medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Liquid Syrup",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
totalCapacity: "120",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should create medication with notes and expiry date", async ({ page }) => {
|
test("should create medication with notes and expiry date", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i);
|
||||||
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
|
|
||||||
await usageField.fill("2");
|
await usageField.fill("2");
|
||||||
@@ -247,7 +247,7 @@ test.describe("Medication Editing", () => {
|
|||||||
// Verify the changes persisted
|
// Verify the changes persisted
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
const savedRow = page.locator(".blister-row").first();
|
const savedRow = page.locator(".blister-row").first();
|
||||||
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||||
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Fill the new intake row
|
// Fill the new intake row
|
||||||
const secondRow = page.locator(".blister-row").nth(1);
|
const secondRow = page.locator(".blister-row").nth(1);
|
||||||
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5");
|
||||||
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Add Intake Med");
|
await saveEditAndVerify(page, "Add Intake Med");
|
||||||
@@ -329,7 +329,7 @@ test.describe("Medication Editing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should change package type between blister and bottle", async ({ page }) => {
|
test("should change package type across all supported profiles", async ({ page }) => {
|
||||||
createdMeds.push(
|
createdMeds.push(
|
||||||
await createMedicationViaAPI({
|
await createMedicationViaAPI({
|
||||||
name: "PackType Change Med",
|
name: "PackType Change Med",
|
||||||
@@ -357,15 +357,24 @@ test.describe("Medication Editing", () => {
|
|||||||
await packageSelect.selectOption("bottle");
|
await packageSelect.selectOption("bottle");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Fill bottle-specific fields
|
// Switch to tube
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
await packageSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
|
// Switch to liquid container and persist this final state
|
||||||
|
await packageSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
// Verify it's still a bottle after reload
|
// Verify final package type persisted
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
await expect(page.locator("select.package-type-select")).toHaveValue("bottle");
|
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medication Lifecycle Integration Tests
|
||||||
|
*
|
||||||
|
* End-to-end workflows that verify changes propagate across pages:
|
||||||
|
* create → verify on medications → check in planner → check in schedule → edit → delete
|
||||||
|
*/
|
||||||
|
test.describe("Medication lifecycle", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 90000 });
|
||||||
|
|
||||||
|
const MED_NAME = "Lifecycle TestMed";
|
||||||
|
const MED_EDITED = "Lifecycle Edited";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create medication via API and verify it appears on all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Step 1: Create medication
|
||||||
|
const created = await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Step 2: Verify on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Step 3: Verify in planner
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible();
|
||||||
|
|
||||||
|
// Step 4: Verify in schedule
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit medication name via UI and verify update propagates", async ({ page }) => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create a fresh medication for this test
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Open edit view from medication row actions
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: MED_NAME });
|
||||||
|
await expect(medRow.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await medRow.first().locator("button.info").click();
|
||||||
|
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the name
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first();
|
||||||
|
await nameInput.fill(MED_EDITED);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const submitButton = form.locator('button[type="submit"]').first();
|
||||||
|
await expect(submitButton).toBeEnabled({ timeout: 5000 });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close or save to complete
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Verify edited name appears on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
// Old name should no longer appear
|
||||||
|
await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete medication via API and verify it disappears from all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create and then delete
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 5,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify it exists first
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Delete via API
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
// Verify gone from medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify planner shows no results for this med
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
// Either no table or table without the medication name
|
||||||
|
const table = page.locator(".table");
|
||||||
|
const tableVisible = await table.isVisible().catch(() => false);
|
||||||
|
if (tableVisible) {
|
||||||
|
await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medication with multiple intakes shows all schedule entries", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const todayEvening = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(20, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: "MultiIntake Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false },
|
||||||
|
{ usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify schedule shows this medication
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The medication should appear at least twice (morning + evening)
|
||||||
|
const medEntries = page.getByText("MultiIntake Med");
|
||||||
|
expect(await medEntries.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,25 +87,17 @@ test.describe("Medications Page", () => {
|
|||||||
expect(hasPacks || hasTotal).toBeTruthy();
|
expect(hasPacks || hasTotal).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
test("should expose all supported package type options", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
const form = visibleMedForm(page);
|
const form = visibleMedForm(page);
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
const packageSelect = form.locator("select.package-type-select");
|
||||||
|
await expect(packageSelect).toBeVisible();
|
||||||
|
|
||||||
// Find the package type radio buttons or selector
|
const optionValues = await packageSelect
|
||||||
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
.locator("option")
|
||||||
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
|
|
||||||
if (await blisterOption.isVisible().catch(() => false)) {
|
expect(optionValues).toEqual(expect.arrayContaining(["blister", "bottle", "tube", "liquid_container"]));
|
||||||
// Switch to bottle
|
|
||||||
await bottleOption.click();
|
|
||||||
// Bottle-specific fields should appear
|
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Switch back to blister
|
|
||||||
await blisterOption.click();
|
|
||||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have intake schedule with add button", async ({ page }) => {
|
test("should have intake schedule with add button", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance Tests
|
||||||
|
*
|
||||||
|
* Verify the schedule timeline and planner render within acceptable
|
||||||
|
* time limits when many medications exist.
|
||||||
|
*/
|
||||||
|
test.describe("Performance with many medications", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 120000 });
|
||||||
|
|
||||||
|
const MED_COUNT = 20;
|
||||||
|
const MED_PREFIX = "PerfTest Med";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create medications sequentially (API rate limits prevent parallel)
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: `${MED_PREFIX} ${i}`,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
|
||||||
|
// Wait for schedule entries to render
|
||||||
|
const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card");
|
||||||
|
await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify all medications appear
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goal: render under 10 seconds
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medications page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
// Wait for medication cards to render
|
||||||
|
const medEntries = page.locator(".medication-card, .card, .table-row");
|
||||||
|
await expect(medEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify count — all 20 should be visible
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planner calculates within 15 seconds with 20 medications", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const calcTime = Date.now() - start;
|
||||||
|
|
||||||
|
// All medications should appear in the results
|
||||||
|
const rows = page.locator(".table .table-row");
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT);
|
||||||
|
|
||||||
|
// Goal: calculate and render under 15 seconds
|
||||||
|
expect(calcTime).toBeLessThan(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ test.describe("Planner with medications", () => {
|
|||||||
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show usage data in results rows", async ({ page }) => {
|
test("should show correct usage values in results rows", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|
||||||
@@ -116,10 +116,15 @@ test.describe("Planner with medications", () => {
|
|||||||
const rows = resultsTable.locator(".table-row");
|
const rows = resultsTable.locator(".table-row");
|
||||||
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
const firstRowText = await rows.first().textContent();
|
// Each medication has usage=1, every=1 → plannerUsage should reflect the period
|
||||||
expect(firstRowText).toBeTruthy();
|
// Verify the usage column contains a numeric <strong> value and "pill(s)"
|
||||||
// Check for "pill" (matches both "pill" and "pills")
|
for (const row of await rows.all()) {
|
||||||
expect(firstRowText!.toLowerCase()).toContain("pill");
|
const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column
|
||||||
|
const usageStrong = usageCell.locator("strong");
|
||||||
|
await expect(usageStrong).toBeVisible();
|
||||||
|
const usageText = await usageStrong.textContent();
|
||||||
|
expect(Number(usageText)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
||||||
@@ -139,9 +144,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Low-stock med (3 pills) should have a danger chip over 90 days
|
// Low-stock med (3 pills, usage 1/day, 90 days) should have danger status
|
||||||
const dangerChips = resultsTable.locator(".status-chip.danger");
|
const dangerChips = resultsTable.locator(".status-chip.danger");
|
||||||
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the low-stock med row and verify its usage value ~90 pills
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed
|
||||||
|
expect(Number(lowUsage)).toBeLessThanOrEqual(95);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
||||||
@@ -161,9 +173,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// With 60 pills and 7-day range, high-stock should be "Enough"
|
// High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough"
|
||||||
const successChips = resultsTable.locator(".status-chip.success");
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
expect(await successChips.count()).toBeGreaterThanOrEqual(1);
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStatus = highStockRow.locator(".status-chip.success");
|
||||||
|
await expect(highStatus).toBeVisible();
|
||||||
|
|
||||||
|
// Verify usage is ~7 pills for the 7-day range
|
||||||
|
const highUsage = await highStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(highUsage)).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(Number(highUsage)).toBeLessThanOrEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show table header with correct columns", async ({ page }) => {
|
test("should show table header with correct columns", async ({ page }) => {
|
||||||
@@ -180,6 +199,28 @@ test.describe("Planner with medications", () => {
|
|||||||
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should display available stock for each medication", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await calculatePlanner(page);
|
||||||
|
|
||||||
|
const resultsTable = page.locator(".table");
|
||||||
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// High-stock med should show a blister + loose-pill stock breakdown
|
||||||
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStockText = await highStockRow.textContent();
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i);
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
|
||||||
|
// Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowStockText = await lowStockRow.textContent();
|
||||||
|
// Should show 3 loose pills
|
||||||
|
expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("should reset form and clear results", async ({ page }) => {
|
test("should reset form and clear results", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|||||||
@@ -195,8 +195,13 @@ test.describe("Schedule with medications", () => {
|
|||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await Promise.all([
|
||||||
await page.waitForLoadState("networkidle");
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/doses/taken") && response.request().method() === "POST",
|
||||||
|
{ timeout: 10000 }
|
||||||
|
),
|
||||||
|
takeBtn.click(),
|
||||||
|
]);
|
||||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 });
|
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,15 +229,4 @@ test.describe("Schedule with medications", () => {
|
|||||||
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show medication names in timeline rows", async ({ page }) => {
|
|
||||||
await navigateTo(page, "/dashboard");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const medNames = todayBlock.locator(".med-name");
|
|
||||||
expect(await medNames.count()).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,17 +150,20 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Overview table has class .table.table-7
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
const overviewTable = page.locator(".table.table-7");
|
|
||||||
await expect(overviewTable).toBeVisible();
|
await expect(overviewTable).toBeVisible();
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display share button in schedules section", async ({ page }) => {
|
test("should display share button in schedules section", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
|
||||||
|
|
||||||
const shareBtn = page.locator("button.share-btn");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
|
const shareVisible = await shareBtn
|
||||||
|
.waitFor({ state: "visible", timeout: 10000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
test.skip(!shareVisible, "Share button is unavailable in this environment");
|
||||||
|
|
||||||
await expect(shareBtn).toBeVisible();
|
await expect(shareBtn).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { authFile, navigateTo, test } from "./fixtures";
|
import { authFile, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
const emailHeadingPattern = /Email|E-Mail/i;
|
||||||
|
const smtpUnavailablePattern = /stay unavailable until SMTP is configured|bleiben deaktiviert, bis SMTP/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings Page E2E Tests
|
* Settings Page E2E Tests
|
||||||
*
|
*
|
||||||
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
|
|||||||
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
|
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should keep email controls disabled when settings request is forbidden", async ({ page }) => {
|
||||||
|
await page.route("**/api/settings", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "Forbidden", code: "FORBIDDEN" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
const emailSection = page
|
||||||
|
.locator(".setting-section")
|
||||||
|
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
|
||||||
|
.first();
|
||||||
|
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
|
||||||
|
|
||||||
|
await expect(emailToggle).toBeDisabled();
|
||||||
|
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should keep the email toggle enabled when the settings API returns smtp configuration", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
const settingsResponse = await page.evaluate(async () => {
|
||||||
|
const response = await fetch("/api/settings", { credentials: "include" });
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(!settingsResponse.ok, `Settings request failed with status ${settingsResponse.status}`);
|
||||||
|
test.skip(!settingsResponse.body?.smtpHost, "SMTP is not configured in this environment");
|
||||||
|
|
||||||
|
const emailSection = page
|
||||||
|
.locator(".setting-section")
|
||||||
|
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
|
||||||
|
.first();
|
||||||
|
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
|
||||||
|
|
||||||
|
await expect(emailToggle).toBeEnabled();
|
||||||
|
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("should show stock settings section with threshold inputs", async ({ page }) => {
|
test("should show stock settings section with threshold inputs", async ({ page }) => {
|
||||||
await navigateTo(page, "/settings");
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
@@ -104,6 +159,28 @@ test.describe("Settings Page", () => {
|
|||||||
await expect(exportButton).toBeVisible();
|
await expect(exportButton).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should generate a new API key from the settings page", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
const generateButton = page.getByRole("button", { name: /Generate key|Key erzeugen/i });
|
||||||
|
test.skip(
|
||||||
|
!(await generateButton.isVisible().catch(() => false)),
|
||||||
|
"API key action is unavailable in this environment"
|
||||||
|
);
|
||||||
|
|
||||||
|
await generateButton.click();
|
||||||
|
|
||||||
|
const tokenInput = page.locator(".api-key-token-input");
|
||||||
|
const tokenVisible = await tokenInput
|
||||||
|
.waitFor({ state: "visible", timeout: 5000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
test.skip(!tokenVisible, "API key token UI is unavailable in this environment");
|
||||||
|
|
||||||
|
await expect(tokenInput).toBeVisible();
|
||||||
|
await expect(tokenInput).toHaveValue(/^ma_/);
|
||||||
|
});
|
||||||
|
|
||||||
test("should show export/import section", async ({ page }) => {
|
test("should show export/import section", async ({ page }) => {
|
||||||
await navigateTo(page, "/settings");
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's medication should show "Alice" badge
|
// Alice's medication should show "Alice" badge
|
||||||
@@ -96,6 +96,10 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
test("should open share dialog with person list", async ({ page }) => {
|
test("should open share dialog with person list", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(overviewTable.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(overviewTable.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click the share button
|
// Click the share button
|
||||||
const shareBtn = page.locator("button.share-btn");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
@@ -136,7 +140,7 @@ test.describe("Share Schedule", () => {
|
|||||||
await generateBtn.click();
|
await generateBtn.click();
|
||||||
|
|
||||||
// Wait for link to be generated
|
// Wait for link to be generated
|
||||||
const shareLinkInput = modal.locator("input.share-link-input");
|
const shareLinkInput = modal.locator("input.share-link-input").first();
|
||||||
await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
|
await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The share link should contain /share/
|
// The share link should contain /share/
|
||||||
@@ -144,7 +148,7 @@ test.describe("Share Schedule", () => {
|
|||||||
expect(linkValue).toContain("/share/");
|
expect(linkValue).toContain("/share/");
|
||||||
|
|
||||||
// Copy button should be visible
|
// Copy button should be visible
|
||||||
await expect(modal.locator("button.btn-copy")).toBeVisible();
|
await expect(modal.locator("button.btn-copy").first()).toBeVisible();
|
||||||
|
|
||||||
// Close
|
// Close
|
||||||
await page.locator("button.modal-close").click();
|
await page.locator("button.modal-close").click();
|
||||||
@@ -178,18 +182,19 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
await page.goto(`/share/${shareToken.token}`);
|
await page.goto(`/share/${shareToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
// Wait for page content to load
|
const sharedSchedule = page.locator(".shared-schedule-container");
|
||||||
await page.waitForTimeout(2000);
|
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The page should show Alice's medication name
|
// The page should show Alice's medication name
|
||||||
const content = page.getByText(MED_ALICE);
|
const content = sharedSchedule.getByText(MED_ALICE);
|
||||||
try {
|
try {
|
||||||
await expect(content).toBeVisible({ timeout: 10000 });
|
await expect(content).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Reload and retry — sometimes the initial load misses
|
// Reload and retry — sometimes the initial load misses
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
await expect(content).toBeVisible({ timeout: 10000 });
|
await expect(content).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -226,34 +231,39 @@ test.describe("Share Schedule", () => {
|
|||||||
// Visit Alice's share — should show Alice's med
|
// Visit Alice's share — should show Alice's med
|
||||||
await page.goto(`/share/${aliceToken.token}`);
|
await page.goto(`/share/${aliceToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.waitForTimeout(2000);
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
const sharedSchedule = page.locator(".shared-schedule-container");
|
||||||
|
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visit Bob's share — should show Bob's med
|
// Visit Bob's share — should show Bob's med
|
||||||
await page.goto(`/share/${bobToken.token}`);
|
await page.goto(`/share/${bobToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.waitForTimeout(2000);
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's med has notes — should show the 📝 icon
|
// Alice's med has notes — should show the 📝 icon
|
||||||
@@ -265,7 +275,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes in medication detail modal", async ({ page }) => {
|
test("should show notes in medication detail modal", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on Alice's med to open detail modal
|
// Click on Alice's med to open detail modal
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user