Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 827d1adc35 | |||
| f8e4b0faaf | |||
| c5c75f65e4 | |||
| 26e9b39f47 | |||
| 7362683c2b | |||
| 1a7b82d728 | |||
| 92f6995b1a | |||
| 6daafcc0dd | |||
| 795aa59acb | |||
| 1514483cf1 | |||
| 554e2ba5ae | |||
| 4ae845d412 | |||
| 59519ae214 | |||
| 328f732066 | |||
| ec478f7601 | |||
| c25076e83b | |||
| b4dc1074e8 | |||
| aa2313427a | |||
| 6a31019fdc | |||
| ae5d6cc3e8 | |||
| 5060d135ba | |||
| 4019716b9b | |||
| 7df17ef705 | |||
| b3c46ea179 | |||
| 255746d9f5 | |||
| d99bc3d99e | |||
| f265d090c6 | |||
| 8473ed8387 | |||
| c38964cd70 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c | |||
| 7554a79898 | |||
| 70f2392a71 | |||
| ba789f9794 | |||
| 277fc3e686 | |||
| b838f0e8ea | |||
| 0b888cf00a | |||
| dbc722a898 | |||
| 15a44d4f55 | |||
| 4de138015d | |||
| 3bb8b93a4c | |||
| 3af8a5a704 | |||
| f301f24182 | |||
| 6dc1e68392 | |||
| e4b1630922 | |||
| c7be73786b | |||
| cdfb19bde2 | |||
| f7da65e7a1 | |||
| 27e42c0935 | |||
| 67ad693b31 | |||
| ab3facc47a | |||
| ce02b4211a | |||
| 40bd7ba3b7 | |||
| 826d85937c | |||
| 6d98a049bc | |||
| 435ca5f1d6 | |||
| ecf9cfb539 | |||
| dafa5abab4 | |||
| cc5141c997 | |||
| 22725fa566 | |||
| a5fe76545e | |||
| 527f4251e5 | |||
| 5064de3bff | |||
| 40d6f33676 | |||
| 0dab318b66 | |||
| 932524125e | |||
| c291c88f2b | |||
| e42e4f5639 | |||
| b70fc88921 | |||
| 95aec8350a | |||
| 401228699f | |||
| 0d2b21199e | |||
| d5b3c5c21f | |||
| 002f16c505 | |||
| aa050f7dc5 | |||
| 0795bfe589 | |||
| 25483c12f0 | |||
| 2a340855fb | |||
| 52fec1a4e5 | |||
| 1cb4a44cef | |||
| 51b09dc563 | |||
| dbbd9d5ed8 | |||
| 15f1e33aa4 | |||
| 5161949578 | |||
| d721bab01a | |||
| eec1653ff4 | |||
| 6bba006e64 | |||
| 59ffb55dfd | |||
| ad48ab6ba7 | |||
| f4a5f5112a | |||
| 98062358be | |||
| 4132ba486d | |||
| 0faad5d28b | |||
| 218b9056fa | |||
| a7bd353f75 | |||
| bd2bfe6972 | |||
| 8a9b44ef31 | |||
| 026091c5ca | |||
| 08f75e44ff | |||
| 5e3a10a93c | |||
| 7f2ef09df5 | |||
| f46043970f | |||
| b58c4fe5bb | |||
| 73a235dd83 | |||
| ce184a6c56 | |||
| 675cb88f3e | |||
| 4b8fa10b39 | |||
| c39b5c9501 | |||
| a1c7e0e62c |
+18
-22
@@ -11,35 +11,31 @@ PGID=1000
|
|||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=warn
|
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Server default timezone for scheduled reminders.
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Users can override this in Settings -> Timezone.
|
||||||
# and frontend browser console (via build-time injection)
|
TZ=Europe/Berlin
|
||||||
#
|
|
||||||
# Behavior per level:
|
# Public base URL used for notification action links.
|
||||||
# debug — all app logs + all HTTP request logs (including polling endpoints)
|
# Required for intake reminder action buttons.
|
||||||
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
|
# PUBLIC_APP_URL=https://medassist.example.com
|
||||||
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
|
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
||||||
# warn — only warnings and errors
|
|
||||||
# error — only errors
|
# Log level: debug, info, warn, error, silent
|
||||||
# silent — no logs
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# 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
|
# API documentation UI + OpenAPI JSON
|
||||||
# Default behavior: enabled outside production, disabled in production
|
# Docs are served on /docs and /docs/json.
|
||||||
# When enabled, docs are available on /docs and /docs/json.
|
# Default behavior: enabled outside production, disabled in production.
|
||||||
# Recommended:
|
# Recommended:
|
||||||
# development/staging: OPENAPI_DOCS_ENABLED=true
|
# development, staging: OPENAPI_DOCS_ENABLED=true
|
||||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
# production: leave unset or set OPENAPI_DOCS_ENABLED=false
|
||||||
# OPENAPI_DOCS_ENABLED=true
|
# OPENAPI_DOCS_ENABLED=true
|
||||||
|
|
||||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
|
||||||
TZ=Europe/Berlin
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Authentication (optional - disabled by default for easy setup)
|
# Authentication (optional - disabled by default for easy setup)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -124,7 +120,7 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
||||||
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
|
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
|
||||||
|
|
||||||
# Push notifications (ntfy/gotify via Shoutrrr)
|
# Push notifications (Shoutrrr URL)
|
||||||
# DEFAULT_SHOUTRRR_ENABLED=false
|
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||||
# DEFAULT_SHOUTRRR_URL=
|
# DEFAULT_SHOUTRRR_URL=
|
||||||
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||||
@@ -148,6 +144,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_MEDICATION_OVERVIEW=false # Show medication overview on shared schedule links
|
||||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
|||||||
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
||||||
|
- **`git stash` is temporary only**: use it only as a short-lived safety mechanism during an active transition. Never use stash as the final way to make a workspace appear clean, and never leave user changes hidden in stash at task completion unless the user explicitly asked for that exact outcome.
|
||||||
|
- **"Local `main` must be clean" means zero leftover local changes**: when the user asks for a clean local `main`, finish with no uncommitted tracked changes, no leftover untracked files from the completed task, and no hidden task residue parked in stash as a substitute for cleanup.
|
||||||
- **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).
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
|||||||
- 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`.
|
- 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.
|
- 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.
|
||||||
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
|
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
|
||||||
|
- If `git stash` was used temporarily during the flow, either restore and resolve it or intentionally discard it before finishing. Do not end the task with a stash that merely hides leftover scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,7 +190,8 @@ When code changes (features or bug fixes) are complete:
|
|||||||
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 merge the PR via GitHub MCP using squash merge and branch deletion.
|
3. Once CI is green, **ask the user for merge confirmation**, then merge the PR via GitHub MCP using squash merge and branch deletion.
|
||||||
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.
|
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.
|
||||||
5. Switch back to main and pull:
|
5. If the requested end state is a clean local `main`, verify that `git status` is empty and that no task-related stash entry remains as hidden residue.
|
||||||
|
6. Switch back to main and pull:
|
||||||
```bash
|
```bash
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull origin main
|
git pull origin main
|
||||||
@@ -241,29 +245,10 @@ Apply these rules strictly:
|
|||||||
|
|
||||||
## Task 3: Execute Release
|
## Task 3: Execute Release
|
||||||
|
|
||||||
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
|
Use the manual release flow. The repository no longer uses a public release helper script.
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/release.sh <patch|minor|major|x.y.z>
|
|
||||||
```
|
|
||||||
|
|
||||||
The script performs these steps in order:
|
|
||||||
1. Checks out and updates `main`
|
|
||||||
2. Creates release branch `chore/release-X.Y.Z`
|
|
||||||
3. Bumps version in `backend/package.json` and `frontend/package.json`
|
|
||||||
4. Commits, pushes, and creates a PR
|
|
||||||
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
|
|
||||||
6. Merges the PR (squash + delete branch)
|
|
||||||
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.
|
**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.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
|
|
||||||
|
|
||||||
### Version Files (MANDATORY)
|
### Version Files (MANDATORY)
|
||||||
|
|
||||||
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
|
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
|
||||||
@@ -275,7 +260,7 @@ The version number is displayed in the **About modal** (Settings → About) as a
|
|||||||
- The About modal will show the old version
|
- The About modal will show the old version
|
||||||
- The version link will point to a non-existent GitHub release page
|
- The version link will point to a non-existent GitHub release page
|
||||||
|
|
||||||
### Manual Release (if script is not available)
|
### Manual Release
|
||||||
|
|
||||||
1. Create release branch:
|
1. Create release branch:
|
||||||
```bash
|
```bash
|
||||||
@@ -519,8 +504,7 @@ Ready for release?
|
|||||||
7. Check current version (git tag + package.json)
|
7. Check current version (git tag + package.json)
|
||||||
8. Analyze changes → determine SemVer level
|
8. Analyze changes → determine SemVer level
|
||||||
9. If minor/major: check README.md for needed updates (Task 5)
|
9. If minor/major: check README.md for needed updates (Task 5)
|
||||||
10. Run ./scripts/release.sh <patch|minor|major>
|
10. Run the manual release flow: branch → version bump → PR → CI → merge → tag
|
||||||
(or manually: branch → version bump → PR → CI → merge → tag)
|
|
||||||
↓
|
↓
|
||||||
11. Write release notes (mandatory for minor/major)
|
11. Write release notes (mandatory for minor/major)
|
||||||
12. Publish GitHub release
|
12. Publish GitHub release
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT
|
## VERY IMPORTANT - Prioritized Constraints
|
||||||
|
|
||||||
|
**First: Update Memory and Reports**
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- If `doku/memory_notes.md` is missing, create it immediately.
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- If `doku/report.md` is missing, create it immediately.
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
**Second: Follow Governance Rules**
|
||||||
|
- Consult `AGENTS.md` for governance, workflow, and skill rules when that file exists in the workspace.
|
||||||
|
|
||||||
|
When `AGENTS.md` exists in the workspace, use it as the single source of truth for governance, workflow, and skill rules.
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|
||||||
1. Read `AGENTS.md` first.
|
1. Read `AGENTS.md` first when it exists in the workspace.
|
||||||
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
2. If `AGENTS.md` exists, identify triggered skills from it and read each referenced `SKILL.md` before making changes.
|
||||||
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||||
|
4. When work moves into a different thematic area, create or switch to a dedicated local branch or worktree before editing code, and reuse the same branch/worktree for follow-up work inside that same theme.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Add issue to project
|
name: Add issue to project
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@v1.0.2
|
- uses: actions/add-to-project@v2.0.0
|
||||||
with:
|
with:
|
||||||
project-url: ${{ vars.PROJECT_URL }}
|
project-url: ${{ vars.PROJECT_URL }}
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Read Dependabot metadata
|
- name: Read Dependabot metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: dependabot/fetch-metadata@v2
|
uses: dependabot/fetch-metadata@v3
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
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@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.current_tag.outputs.value }}
|
tag_name: ${{ steps.current_tag.outputs.value }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||||
steps:
|
steps:
|
||||||
- name: Move project item to Done
|
- name: Move project item to Done
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Sync fields
|
- name: Sync fields
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Build weekly summary
|
- name: Build weekly summary
|
||||||
id: summary
|
id: summary
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
core.setOutput('body', body);
|
core.setOutput('body', body);
|
||||||
|
|
||||||
- name: Publish report issue
|
- name: Publish report issue
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
+36
-8
@@ -67,12 +67,12 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
/.vscode/settings.json
|
||||||
|
|
||||||
# Keep shared VS Code settings
|
# Keep shared VS Code workspace files, but ignore personal editor settings.
|
||||||
# .vscode/ is NOT ignored - settings.json is useful for the team
|
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Misc
|
# Local-only workspace artifacts (never upstream)
|
||||||
# ===================
|
# ===================
|
||||||
*.local
|
*.local
|
||||||
.cache/
|
.cache/
|
||||||
@@ -82,9 +82,37 @@ Thumbs.db
|
|||||||
.claude/
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku/
|
/doku/
|
||||||
doku/memory_notes.md
|
/plan/
|
||||||
doku/report.md
|
/.planning/
|
||||||
plan/
|
|
||||||
.copilot-tracking/
|
.copilot-tracking/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
.agents/
|
||||||
|
skills-lock.json
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Local Spec Kit workspace state
|
||||||
|
# ===================
|
||||||
|
.specify/
|
||||||
|
specs/
|
||||||
|
docs/SPEC_KIT.md
|
||||||
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
|
.github/agents/speckit.*.agent.md
|
||||||
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/accessibility/
|
||||||
|
.github/skills/frontend-design/
|
||||||
|
.github/skills/nodejs-backend-patterns/
|
||||||
|
.github/skills/nodejs-best-practices/
|
||||||
|
.github/skills/seo/
|
||||||
|
.playwright-mcp
|
||||||
|
|
||||||
|
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||||
|
.github/agents/copilot-instructions.md
|
||||||
|
.github/agents/gsd-*.agent.md
|
||||||
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
|
.github/agents/speckit.*.agent.md
|
||||||
|
.github/get-shit-done/
|
||||||
|
.github/gsd-file-manifest.json
|
||||||
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/gsd-*/
|
||||||
|
ops/medtest/
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- refreshed: 2026-04-30 -->
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend SPA (React) │
|
||||||
|
├──────────────────┬──────────────────┬───────────────────────┤
|
||||||
|
│ App Shell/Routes │ Shared State │ Feature Pages │
|
||||||
|
│ `frontend/src/ │ `frontend/src/ │ `frontend/src/pages/` │
|
||||||
|
│ App.tsx` │ context/` │ │
|
||||||
|
└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API (Fastify) │
|
||||||
|
│ `backend/src/index.ts` + `backend/src/routes/` │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ SQLite Persistence + Migration Layer │
|
||||||
|
│ `backend/src/db/schema.ts` + `backend/src/db/client.ts` │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
| Component | Responsibility | File |
|
||||||
|
|-----------|----------------|------|
|
||||||
|
| Frontend bootstrap | Mount providers/router and start app tree | `frontend/src/main.tsx` |
|
||||||
|
| App router/shell | Public share routes, authenticated shell routes, global modal composition | `frontend/src/App.tsx` |
|
||||||
|
| Frontend orchestration | Compose domain hooks and expose app-level state/actions | `frontend/src/context/AppContext.tsx` |
|
||||||
|
| API proxy boundary | Rewrite `/api/*` requests to backend root routes | `frontend/vite.config.ts` |
|
||||||
|
| Backend composition root | Register plugins/routes, await migrations, start schedulers | `backend/src/index.ts` |
|
||||||
|
| Route handlers | HTTP contracts, validation, auth hooks, response shaping | `backend/src/routes/*.ts` |
|
||||||
|
| Domain services | Shared domain logic and scheduler behavior | `backend/src/services/*.ts` |
|
||||||
|
| Persistence | Table definitions + compatibility migration/runtime initialization | `backend/src/db/schema.ts`, `backend/src/db/client.ts` |
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
**Overall:** Layered modular monolith (single frontend SPA + single backend process)
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Frontend uses React Router + context/hook composition (`frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`).
|
||||||
|
- Backend uses route modules with shared service modules (`backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||||
|
- Data persistence is centralized in Drizzle schema + startup migrations (`backend/src/db/schema.ts`, `backend/src/db/client.ts`).
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**Frontend Presentation + Orchestration:**
|
||||||
|
- Purpose: Render UI, route navigation, manage client state, invoke API.
|
||||||
|
- Location: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/`, `frontend/src/context/`, `frontend/src/hooks/`.
|
||||||
|
- Contains: pages, modals, app shell, hook-based API callers.
|
||||||
|
- Depends on: backend `/api/*`, i18n, shared frontend utils/types.
|
||||||
|
- Used by: browser clients.
|
||||||
|
|
||||||
|
**Backend HTTP/API Layer:**
|
||||||
|
- Purpose: Expose REST endpoints, authenticate/authorize requests, validate input, map to service/db logic.
|
||||||
|
- Location: `backend/src/index.ts`, `backend/src/routes/`, `backend/src/plugins/`.
|
||||||
|
- Contains: Fastify app setup, route registration, auth middleware.
|
||||||
|
- Depends on: services, db client/schema, env plugin.
|
||||||
|
- Used by: frontend SPA and API consumers.
|
||||||
|
|
||||||
|
**Domain Services Layer:**
|
||||||
|
- Purpose: Reusable business logic for scheduling, notifications, stock math, parsing.
|
||||||
|
- Location: `backend/src/services/`, `backend/src/utils/`.
|
||||||
|
- Contains: reminder scheduler, notification builders/delivery, medication helpers.
|
||||||
|
- Depends on: db models and utilities.
|
||||||
|
- Used by: routes and startup process.
|
||||||
|
|
||||||
|
**Persistence Layer:**
|
||||||
|
- Purpose: Define DB schema and keep existing SQLite instances compatible.
|
||||||
|
- Location: `backend/src/db/schema.ts`, `backend/src/db/client.ts`, `backend/drizzle/`.
|
||||||
|
- Contains: tables, migration execution, backward-compatible alter migrations.
|
||||||
|
- Depends on: Drizzle + libsql client.
|
||||||
|
- Used by: routes/services.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Primary Request Path
|
||||||
|
|
||||||
|
1. Frontend page triggers API call via `/api/*` fetch (`frontend/src/pages/PlannerPage.tsx:307`).
|
||||||
|
2. Vite proxy rewrites `/api` prefix to backend route root (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||||
|
3. Fastify route handles request under `/planner/send-email` with auth + validation (`backend/src/routes/planner.ts:141`, `backend/src/routes/planner.ts:158`).
|
||||||
|
4. Route loads user settings and dispatches channel delivery helpers (`backend/src/routes/planner.ts:221`, `backend/src/routes/planner.ts:432`, `backend/src/routes/planner.ts:829`).
|
||||||
|
|
||||||
|
### Public Share Flow
|
||||||
|
|
||||||
|
1. Frontend routes public token URL to shared schedule view (`frontend/src/App.tsx:35`).
|
||||||
|
2. Shared schedule component fetches token payload from `/api/share/:token` (`frontend/src/components/SharedSchedule.tsx:311`).
|
||||||
|
3. Backend public share route reads token/settings and returns filtered medication schedule (`backend/src/routes/share.ts:125`, `backend/src/routes/share.ts:156`).
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- Frontend: context-centric state aggregation (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||||
|
- Backend: DB-backed state with runtime scheduler state persisted through notification state utilities (`backend/src/services/reminder-scheduler.ts:42`).
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**Auth Context + Guards:**
|
||||||
|
- Purpose: unify session/API-key auth across protected routes.
|
||||||
|
- Examples: `backend/src/plugins/auth.ts`, `backend/src/routes/settings.ts`.
|
||||||
|
- Pattern: route-level `preHandler` guard plus request decoration (`backend/src/routes/settings.ts:138`, `backend/src/plugins/auth.ts:236`).
|
||||||
|
|
||||||
|
**Notification Delivery Contract:**
|
||||||
|
- Purpose: keep route-triggered and scheduler-triggered notifications consistent.
|
||||||
|
- Examples: `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/notifications/delivery.ts`.
|
||||||
|
- Pattern: shared builders/delivery/state helpers imported into both paths (`backend/src/routes/planner.ts:23`, `backend/src/services/reminder-scheduler.ts:39`).
|
||||||
|
|
||||||
|
**Frontend App Context Aggregator:**
|
||||||
|
- Purpose: centralize shared medication/settings/dose/share/refill state for page/modal consumers.
|
||||||
|
- Examples: `frontend/src/context/AppContext.tsx`, `frontend/src/context/ShareContext.tsx`.
|
||||||
|
- Pattern: compose domain hooks, expose typed value via provider (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Frontend bootstrap:**
|
||||||
|
- Location: `frontend/src/main.tsx`
|
||||||
|
- Triggers: browser loads `index.html`.
|
||||||
|
- Responsibilities: initialize theme/provider stack and router (`frontend/src/main.tsx:12`, `frontend/src/main.tsx:15`).
|
||||||
|
|
||||||
|
**Backend process entry:**
|
||||||
|
- Location: `backend/src/index.ts`
|
||||||
|
- Triggers: `npm run dev`/`npm start` in backend package.
|
||||||
|
- Responsibilities: await migrations, register routes, start HTTP listener and schedulers (`backend/src/index.ts:231`, `backend/src/index.ts:305`, `backend/src/index.ts:309`, `backend/src/index.ts:334`).
|
||||||
|
|
||||||
|
## Architectural Constraints
|
||||||
|
|
||||||
|
- **Threading:** Single Node.js event loop process with in-process schedulers started at runtime (`backend/src/index.ts:309`, `backend/src/index.ts:323`).
|
||||||
|
- **Global state:** Module/global singletons exist in auth and context layers (`backend/src/plugins/auth.ts:15`, `frontend/src/context/AppContext.tsx:222`).
|
||||||
|
- **Circular imports:** Not detected from sampled route/service/db/frontend orchestration files.
|
||||||
|
- **API boundary:** Frontend network calls must use `/api/*` so proxy rewrite applies (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
### Duplicated Backend App Wiring
|
||||||
|
|
||||||
|
**What happens:** Route/plugin registration appears in both `createApp(...)` and top-level startup path.
|
||||||
|
**Why it's wrong:** Two bootstrap paths increase divergence risk when new routes/plugins are added in one path but not the other.
|
||||||
|
**Do this instead:** Keep a single shared app-construction function used by both test/runtime startup paths (`backend/src/index.ts:133`, `backend/src/index.ts:207`, `backend/src/index.ts:289`).
|
||||||
|
|
||||||
|
### Oversized Frontend Orchestration Context
|
||||||
|
|
||||||
|
**What happens:** `AppContext` aggregates many unrelated concerns (medications, settings, doses, sharing, import/export, modal history) in one large provider.
|
||||||
|
**Why it's wrong:** High coupling and broad rerender surface make safe changes harder and increase regression risk.
|
||||||
|
**Do this instead:** Preserve existing provider contract, but move new domain concerns into focused hooks/providers and re-export through composition only when needed (`frontend/src/context/AppContext.tsx`, file size ~1035 lines).
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Fail fast at route boundary with explicit status codes and schema validation, then log context-rich errors.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Route validation + immediate 400 responses for invalid input (`backend/src/routes/medications.ts:76`, `backend/src/routes/medications.ts:584`).
|
||||||
|
- Planner routes return explicit channel/config errors (`backend/src/routes/planner.ts:204`, `backend/src/routes/planner.ts:509`).
|
||||||
|
- Frontend captures network errors and maps them to normalized error codes for UI handling (`frontend/src/hooks/useMedications.ts:80`).
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Logging:** Fastify logger options configured centrally with environment-aware formatting (`backend/src/index.ts:66`, `backend/src/index.ts:161`).
|
||||||
|
**Validation:** Zod validation for medication payloads and explicit OpenAPI schema contracts in routes (`backend/src/routes/medications.ts:76`, `backend/src/routes/planner.ts:157`).
|
||||||
|
**Authentication:** Route-level auth hooks and dual API-key/session handling (`backend/src/routes/planner.ts:141`, `backend/src/plugins/auth.ts:113`, `backend/src/plugins/auth.ts:236`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Codebase Concerns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
**Backend startup duplication and config drift:**
|
||||||
|
- Issue: `backend/src/index.ts` contains two parallel server setup paths (the exported `createApp(...)` flow and the top-level runtime bootstrap). Plugin/route registration and rate-limit defaults are duplicated in both branches.
|
||||||
|
- Files: `backend/src/index.ts`
|
||||||
|
- Impact: Configuration behavior can diverge between test/programmatic app construction and production startup (for example, `createApp` uses fixed `rateLimit` max `300`, while runtime startup uses `process.env.RATE_LIMIT_MAX` fallback `100`).
|
||||||
|
- Fix approach: Extract one canonical app-construction function and let both runtime startup and tests consume it; remove duplicated registration blocks.
|
||||||
|
|
||||||
|
**Notification architecture leakage and duplicated composition logic:**
|
||||||
|
- Issue: Notification delivery service code imports a route-layer helper (`sendShoutrrrNotification`) from settings routes, and large HTML/text reminder composition blocks are duplicated across manual and automatic reminder paths.
|
||||||
|
- Files: `backend/src/services/notifications/delivery.ts`, `backend/src/routes/settings.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Impact: Layer boundary violations increase coupling, and duplicated notification formatting logic makes behavior regressions likely when changing message content or channel behavior.
|
||||||
|
- Fix approach: Move `sendShoutrrrNotification` to a service-layer module, make routes call service APIs only, and centralize email/push payload builders for planner + scheduler flows.
|
||||||
|
|
||||||
|
**Migration artifact ambiguity in drizzle numbering:**
|
||||||
|
- Issue: There are two migration files with `0008_` prefix, but the journal tracks only one `0008` tag and then jumps to `0009`.
|
||||||
|
- Files: `backend/drizzle/0008_add_obsolete_medications.sql`, `backend/drizzle/0008_add_prescription_tracking.sql`, `backend/drizzle/meta/_journal.json`
|
||||||
|
- Impact: Developer confusion and higher risk of migration-order mistakes during future schema changes.
|
||||||
|
- Fix approach: Align migration file names and journal tags so each migration number is unique and journal order is obvious.
|
||||||
|
|
||||||
|
**Monolithic UI/editor and route modules with broad lint suppressions:**
|
||||||
|
- Issue: Core interaction files are very large and rely on file-level `biome-ignore-all` suppressions for multiple rule categories.
|
||||||
|
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/SharedSchedule.tsx`, `frontend/src/components/MedDetailModal.tsx`, `backend/src/routes/medications.ts`
|
||||||
|
- Impact: Refactors become high-risk; local regressions are harder to isolate; suppressed rule categories hide legitimate quality issues in future edits.
|
||||||
|
- Fix approach: Split by domain slices (state orchestration vs rendering vs helper transforms), then replace file-level suppressions with narrow, local exceptions only where justified.
|
||||||
|
|
||||||
|
## Known Bugs
|
||||||
|
|
||||||
|
**Environment-dependent behavior mismatch between test app factory and runtime app:**
|
||||||
|
- Symptoms: Programmatic app creation and runtime startup can apply different operational defaults (rate limiting and selected config pathways).
|
||||||
|
- Files: `backend/src/index.ts`
|
||||||
|
- Trigger: Using `createApp(...)` in tests/integration contexts while production startup uses the top-level runtime branch.
|
||||||
|
- Workaround: Explicitly pass runtime-equivalent options into `createApp(...)` in tests until startup construction is unified.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**Server-side outbound notification surface is broad and sensitive to parser correctness:**
|
||||||
|
- Risk: The app performs server-side HTTP requests to user-configurable notification URLs, including multiple protocol handlers (`pushover://`, `telegram://`, `gotify://`, generic webhook URLs).
|
||||||
|
- Files: `backend/src/routes/settings.ts`
|
||||||
|
- Current mitigation: URL sanitation/validation and hostname checks are present (`sanitizeNotificationUrl`, `validateNotificationHostname` usage in route logic).
|
||||||
|
- Recommendations: Add focused security regression tests for sanitizer bypasses and callback URL edge cases, and keep all outbound request execution in a dedicated service layer.
|
||||||
|
|
||||||
|
**Auth-off bootstrap path creates implicit default user state:**
|
||||||
|
- Risk: In auth-disabled mode, startup creates/relies on a default user path automatically.
|
||||||
|
- Files: `backend/src/db/client.ts`
|
||||||
|
- Current mitigation: Controlled by `AUTH_ENABLED` environment setting.
|
||||||
|
- Recommendations: Add startup log warnings when running without auth outside development and enforce explicit environment confirmation in deployment templates.
|
||||||
|
|
||||||
|
## Performance Bottlenecks
|
||||||
|
|
||||||
|
**Reminder scheduling uses repeated full scans over users and medication/dose datasets:**
|
||||||
|
- Problem: Reminder checks iterate all user settings and compute stock/prescription reminders with repeated in-memory loops over medication and dose collections.
|
||||||
|
- Files: `backend/src/services/reminder-scheduler.ts`, `backend/src/utils/scheduler-utils.ts`
|
||||||
|
- Cause: Polling/check strategy prioritizes correctness and compatibility over incremental indexing.
|
||||||
|
- Improvement path: Introduce incremental candidate selection (changed-medication windows, per-user next-check indices) and reduce repeated whole-set scans.
|
||||||
|
|
||||||
|
**Intake reminder scheduler polls every minute and may scale linearly with active schedules:**
|
||||||
|
- Problem: Intake reminder check loop runs continuously at 60s interval and processes all due reminders/users each tick.
|
||||||
|
- Files: `backend/src/services/intake-reminder-scheduler.ts`
|
||||||
|
- Cause: Fixed-interval scheduler (`CHECK_INTERVAL_MS = 60 * 1000`) with loop-driven due-item selection.
|
||||||
|
- Improvement path: Move toward next-due-time scheduling or bucketing strategy; keep minute polling as fallback only.
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
**Reminder state persistence and lock handling mix sync file IO with best-effort catches:**
|
||||||
|
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Why fragile: Reminder state writes are synchronous file writes and some read paths swallow errors (`catch {}`), while lock/state files are local filesystem coordination primitives.
|
||||||
|
- Safe modification: Keep file format backward-compatible, add explicit error telemetry, and add tests for concurrent/failed write scenarios before changing scheduler state logic.
|
||||||
|
- Test coverage: No direct tests detected for `notifications/delivery.ts` and only limited direct state-function assertions.
|
||||||
|
|
||||||
|
**Desktop/mobile medication edit parity depends on two large independent UI paths:**
|
||||||
|
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/medications/MedicationEditCoordinator.tsx`
|
||||||
|
- Why fragile: The same editing domain is implemented in separate surfaces, each with dense UI logic and custom interaction handling.
|
||||||
|
- Safe modification: Apply shared form-section components first, then update desktop and mobile in the same change; validate both paths with targeted tests.
|
||||||
|
- Test coverage: Coverage exists (`MedicationEditCoordinator`, `MobileEditModal`, `MedicationDialogs` tests), but parity regressions remain a recurring risk due to file size/complexity.
|
||||||
|
|
||||||
|
## Scaling Limits
|
||||||
|
|
||||||
|
**Current reminder architecture is single-node/local-state oriented:**
|
||||||
|
- Current capacity: Scheduler state and lock coordination are local files under data directory (`reminder-state.json`, `scheduler-locks/*`).
|
||||||
|
- Limit: Horizontal multi-instance scaling can duplicate work or require externalized coordination.
|
||||||
|
- Scaling path: Move reminder state/locks to DB or distributed lock backend and make scheduler execution leader-aware.
|
||||||
|
|
||||||
|
**SQLite file-backed persistence constrains concurrent write scaling:**
|
||||||
|
- Current capacity: Single SQLite file with local filesystem path resolution.
|
||||||
|
- Limit: Higher write concurrency and distributed deployments will hit filesystem/database locking and throughput limits.
|
||||||
|
- Scaling path: Keep SQLite for local/small deployments; define migration path to managed DB for larger multi-user workloads.
|
||||||
|
|
||||||
|
## Dependencies at Risk
|
||||||
|
|
||||||
|
**Route-to-service coupling in notification stack:**
|
||||||
|
- Risk: Service-layer delivery module depends on route-layer helper import.
|
||||||
|
- Impact: Refactors of route modules can break unrelated notification infrastructure and complicate testing boundaries.
|
||||||
|
- Migration plan: Move shared notification send helpers into `backend/src/services/notifications/*` and keep route modules thin.
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
**Risk-driven scheduler stress/integration test suite for state-lock edge cases:**
|
||||||
|
- Problem: Complex scheduler/state code paths rely on file coordination and mixed channel delivery outcomes, but dedicated stress/chaos-style verification is limited.
|
||||||
|
- Blocks: High-confidence scaling and reliability changes in reminder subsystems.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**Notification delivery abstraction lacks direct unit tests:**
|
||||||
|
- What's not tested: Direct behavior of SMTP transport creation/result validation and push delivery helpers in the dedicated delivery module.
|
||||||
|
- Files: `backend/src/services/notifications/delivery.ts`
|
||||||
|
- Risk: Regressions in recipient validation, SMTP response handling, or provider fallback can ship unnoticed.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
**Reminder state persistence/locking has limited direct verification:**
|
||||||
|
- What's not tested: Corrupted file recovery, concurrent state writes, and lock stale-file behavior under failure modes.
|
||||||
|
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Risk: Duplicate sends or missed sends after crashes/restarts are difficult to detect early.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Concerns audit: 2026-04-30*
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Frontend React components and pages use PascalCase file names (for example `frontend/src/components/MobileEditModal.tsx`, `frontend/src/pages/MedicationsPage.tsx`).
|
||||||
|
- Hooks use `useX` camelCase naming in files and symbols (for example `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useScheduleController.ts`).
|
||||||
|
- Backend routes/services use kebab-case file names with domain suffixes (for example `backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||||
|
- Test files use `*.test.ts` or `*.test.tsx` in dedicated test folders (for example `backend/src/test/planner.test.ts`, `frontend/src/test/components/MobileEditModal.test.tsx`).
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- Use camelCase names for functions and methods (for example `parseIntakesWithUnits` in `backend/src/services/medications-service.ts`, `loadMeds` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
- Use verb-first names for side-effect operations (`loadMeds`, `deleteMed`, `uploadMedImage` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- Use camelCase for local variables and state (`refillHistoryExpanded`, `scheduleDays`, `showFutureDays` in `frontend/src/context/AppContext.tsx`).
|
||||||
|
- Constant maps and singleton keys use UPPER_SNAKE_CASE (`LOG_LEVELS` in `backend/src/utils/logger.ts`, `APP_CONTEXT_SINGLETON_KEY` in `frontend/src/context/AppContext.tsx`).
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- Type aliases and interfaces use PascalCase (`AppContextValue` in `frontend/src/context/AppContext.tsx`, `TestContext` in `backend/src/test/setup.ts`).
|
||||||
|
- Return-shape interfaces use `UseXReturn` convention for hooks (`UseMedicationsReturn` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- Tool used: Biome (`biome.json`, scripts in `frontend/package.json`, `backend/package.json`, `package.json`).
|
||||||
|
- Key settings from `biome.json`:
|
||||||
|
- `indentStyle: tab`
|
||||||
|
- `indentWidth: 2`
|
||||||
|
- `lineWidth: 120`
|
||||||
|
- JavaScript quote style is double quotes, semicolons enabled, trailing commas `es5`.
|
||||||
|
|
||||||
|
**Linting:**
|
||||||
|
- Tool used: Biome linter (`biome.json`).
|
||||||
|
- Key rules enforced/relevant:
|
||||||
|
- `style.useConst: error`
|
||||||
|
- `style.noNestedTernary: warn`
|
||||||
|
- `correctness.noUnusedVariables: warn`
|
||||||
|
- `suspicious.noExplicitAny: warn`
|
||||||
|
- Project governance in `AGENTS.md` reinforces readable code, early returns, and no nested ternaries.
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
**Order:**
|
||||||
|
1. Node built-ins first in backend modules (for example `node:path` in `backend/src/routes/medications.ts`, `node:crypto` in `backend/src/index.ts`).
|
||||||
|
2. External packages second (`fastify`, `zod`, `drizzle-orm` in backend; `react`, `@testing-library/*` in frontend).
|
||||||
|
3. Internal modules last with relative paths (`../db/client.js`, `../../types`).
|
||||||
|
|
||||||
|
**Path Aliases:**
|
||||||
|
- Not detected in TypeScript configs (`frontend/tsconfig.json`, `backend/tsconfig.json` do not define `paths`).
|
||||||
|
- Relative imports are the standard.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Backend validates request data with Zod schemas and `.refine(...)` constraints before route logic (`backend/src/routes/medications.ts`).
|
||||||
|
- Backend route tests assert explicit status codes and body shape (`backend/src/test/routes-real.test.ts`, `backend/src/test/planner.test.ts`).
|
||||||
|
- Frontend hooks often normalize recoverable API errors into UI-safe states (`frontend/src/hooks/useMedications.ts` converts network failures into `NETWORK_ERROR`).
|
||||||
|
- Some frontend fetch flows still use tolerant fallbacks (`catch(() => setMeds([]))` in `frontend/src/hooks/useMedications.ts`), so future changes should prefer explicit user-facing error channels per `AGENTS.md` fail-clear guidance.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- Backend startup logger wrapper over console with level filtering in `backend/src/utils/logger.ts`.
|
||||||
|
- Runtime HTTP logging via Fastify logger options in `backend/src/index.ts` (`buildLoggerOptions`, request correlation IDs).
|
||||||
|
- Frontend logging utility mirrors backend level semantics (`frontend/src/utils/logger.ts`).
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Central log-level maps (`LOG_LEVELS`) and `shouldLog` gating are standard in both frontend and backend logger modules.
|
||||||
|
- Correlation ID propagation is enforced at request boundaries (`backend/src/index.ts` onRequest hook setting `x-correlation-id`).
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
**When to Comment:**
|
||||||
|
- Comments are used for rationale and test setup intent, not line-by-line narration.
|
||||||
|
- Typical examples:
|
||||||
|
- Migration/setup intent in `backend/src/test/setup.ts`
|
||||||
|
- E2E stability rationale in `frontend/e2e/fixtures/index.ts`
|
||||||
|
- Timeout/determinism notes in `frontend/vitest.config.ts` and `frontend/playwright.base.config.ts`
|
||||||
|
|
||||||
|
**JSDoc/TSDoc:**
|
||||||
|
- Used selectively for exported utilities and test helpers (`backend/src/test/setup.ts`, `frontend/e2e/fixtures/index.ts`, `frontend/src/utils/logger.ts`).
|
||||||
|
- Not mandatory for every function; concise type annotations plus targeted comments are preferred.
|
||||||
|
|
||||||
|
## Function Design
|
||||||
|
|
||||||
|
**Size:**
|
||||||
|
- Small-to-medium focused functions are common in services/hooks (`parseRawIntakeUnits`, `normalizeDateTime` in `backend/src/services/medications-service.ts`).
|
||||||
|
- Larger orchestrator modules exist where domain aggregation is required (`frontend/src/context/AppContext.tsx`).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- Object parameters are used for extensibility in test factories and route payload shapes (`CreateMedicationOptions` in `backend/src/test/setup.ts`).
|
||||||
|
- Explicit primitive parameters used for concise helpers (`clickEditMed(page, medName)` in `frontend/e2e/medication-edit.spec.ts`).
|
||||||
|
|
||||||
|
**Return Values:**
|
||||||
|
- Explicit return types are common on exported functions (`Promise<TestContext>`, `UseMedicationsReturn`).
|
||||||
|
- Guard-clause returns are common for invalid input or unavailable state (`if (!intakesJson) return [];` in `backend/src/services/medications-service.ts`).
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- Named exports are preferred for utilities, hooks, and service functions (`backend/src/services/notifications/index.ts`, `frontend/src/hooks/index.ts`).
|
||||||
|
- Mixed export style is used where legacy/default exports remain practical (`default` exports in component barrel `frontend/src/components/index.ts`).
|
||||||
|
|
||||||
|
**Barrel Files:**
|
||||||
|
- Barrel files are actively used for stable import surfaces:
|
||||||
|
- `frontend/src/components/index.ts`
|
||||||
|
- `frontend/src/hooks/index.ts`
|
||||||
|
- `backend/src/services/notifications/index.ts`
|
||||||
|
- Practical rule for new code: export domain-level public APIs through local barrels, keep deep internal helpers imported directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## APIs & External Services
|
||||||
|
|
||||||
|
**Medication Data APIs:**
|
||||||
|
- European Medicines Agency (EMA) JSON catalog - medication lookup seed and periodic catalog refresh
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`EMA_MEDICINES_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
- RxNorm (NLM RxNav REST) - normalized name/search enrichment and strength/form hints
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`RXNORM_BASE_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
- openFDA NDC API - product/package metadata enrichment
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`OPENFDA_NDC_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
|
||||||
|
**Authentication/Identity Provider Integration:**
|
||||||
|
- OIDC providers (Authelia, Authentik, Pocket ID, Keycloak documented) - SSO login/callback flow
|
||||||
|
- SDK/Client: `openid-client` used in `backend/src/routes/oidc.ts`
|
||||||
|
- Auth: `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` validated in `backend/src/plugins/env.ts`
|
||||||
|
|
||||||
|
**Messaging/Notifications:**
|
||||||
|
- SMTP providers - transactional reminder/test emails
|
||||||
|
- SDK/Client: `nodemailer` in `backend/src/services/notifications/delivery.ts`
|
||||||
|
- Auth: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` or `SMTP_TOKEN`, `SMTP_FROM`, `SMTP_SECURE`
|
||||||
|
- Push endpoints via Shoutrrr-compatible URL parsing
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/routes/settings.ts` (`sendShoutrrrNotification`)
|
||||||
|
- Auth: URL-embedded creds/token per provider and optional basic auth extracted/sanitized in code
|
||||||
|
- Explicit external push provider endpoints used directly:
|
||||||
|
- `https://api.pushover.net/1/messages.json` in `backend/src/routes/settings.ts`
|
||||||
|
- `https://api.telegram.org` in `backend/src/routes/settings.ts`
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
- SQLite (file-based, local persistent volume)
|
||||||
|
- Connection: `DATA_DIR` (path resolution), optional `DOTENV_PATH` for env source
|
||||||
|
- Client: `@libsql/client` + `drizzle-orm` in `backend/src/db/client.ts`
|
||||||
|
- Migration pipeline:
|
||||||
|
- SQL migration artifacts in `backend/drizzle/*.sql`
|
||||||
|
- Runtime migration/alter execution in `backend/src/db/client.ts` and `backend/src/db/migration-utils.ts`
|
||||||
|
|
||||||
|
**File Storage:**
|
||||||
|
- Local filesystem only
|
||||||
|
- Backend data root resolved by `backend/src/db/path-utils.ts`
|
||||||
|
- Image/static user files served from `/images` in `backend/src/index.ts`
|
||||||
|
- Compose bind mount `./data:/app/data` in `docker-compose.yml`
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- In-process memory cache only for selected integration data
|
||||||
|
- OIDC discovery config cache in `backend/src/routes/oidc.ts` (`oidcConfig`)
|
||||||
|
- EMA catalog snapshot + refresh promise in `backend/src/services/medication-enrichment.ts`
|
||||||
|
- No external cache service detected (no Redis/Memcached dependency in package manifests)
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Auth Provider:**
|
||||||
|
- Custom session/JWT auth with optional OIDC SSO extension
|
||||||
|
- Implementation: Fastify cookie + JWT plugin, refresh token table, API key hashing in `backend/src/plugins/auth.ts`, `backend/src/routes/auth.ts`, `backend/src/plugins/jwt.ts`, `backend/src/routes/oidc.ts`
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Error Tracking:**
|
||||||
|
- None detected for third-party SaaS error tracking (no Sentry/Rollbar/etc. dependencies)
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
- Structured app logging via Fastify/Pino in `backend/src/index.ts`
|
||||||
|
- Pretty logging in dev through `pino-pretty` (`backend/package.json`, logger setup in `backend/src/index.ts`)
|
||||||
|
- Frontend/nginx log behavior controlled through env and `frontend/nginx-entrypoint.sh` (documented in `.env.example`)
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:**
|
||||||
|
- Container image publishing to GitHub Container Registry (`ghcr.io`) in `.github/workflows/docker-build.yml`
|
||||||
|
- Runtime deployment model is self-hosted Docker Compose stack (`docker-compose.yml`)
|
||||||
|
|
||||||
|
**CI Pipeline:**
|
||||||
|
- GitHub Actions for lint/type/test (`.github/workflows/test.yml`)
|
||||||
|
- Playwright E2E job (`.github/workflows/e2e.yml`)
|
||||||
|
- Docker build/push and optional release automation (`.github/workflows/docker-build.yml`)
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required env vars:**
|
||||||
|
- Core runtime: `PORT`, `CORS_ORIGINS`, `LOG_LEVEL`, `TZ` (`backend/src/plugins/env.ts`, `.env.example`)
|
||||||
|
- Auth when enabled: `AUTH_ENABLED=true` with `JWT_SECRET`, `REFRESH_SECRET`, `COOKIE_SECRET` (`backend/src/plugins/env.ts`)
|
||||||
|
- OIDC when enabled: `OIDC_ENABLED=true` with issuer/client/redirect vars (`backend/src/plugins/env.ts`)
|
||||||
|
- Email notifications: `SMTP_HOST`, `SMTP_USER`, plus pass/token and sender config (`backend/src/services/notifications/delivery.ts`, `.env.example`)
|
||||||
|
- Data location: `DATA_DIR` used by DB path resolver (`backend/src/db/path-utils.ts`)
|
||||||
|
|
||||||
|
**Secrets location:**
|
||||||
|
- Local runtime env file `.env` (present in repository root; values not inspected)
|
||||||
|
- CI secrets managed by GitHub Actions secret store (e.g., `${{ secrets.GITHUB_TOKEN }}` in `.github/workflows/docker-build.yml`)
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming:**
|
||||||
|
- OIDC callback endpoint: `/auth/oidc/callback` in `backend/src/routes/oidc.ts`
|
||||||
|
- No inbound third-party webhook receiver route detected in backend routes
|
||||||
|
|
||||||
|
**Outgoing:**
|
||||||
|
- Outbound HTTP notifications to webhook-style targets from `sendShoutrrrNotification` in `backend/src/routes/settings.ts`
|
||||||
|
- Provider-specific outgoing callbacks/APIs:
|
||||||
|
- Pushover API endpoint
|
||||||
|
- Telegram Bot API endpoint
|
||||||
|
- Outbound SMTP delivery through configured mail host (`backend/src/services/notifications/delivery.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-04-30*
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
**Primary:**
|
||||||
|
- TypeScript (ESM) - Backend and frontend application code in `backend/src/**/*.ts` and `frontend/src/**/*.{ts,tsx}`
|
||||||
|
- SQL (SQLite migrations) - Schema evolution files in `backend/drizzle/*.sql`
|
||||||
|
|
||||||
|
**Secondary:**
|
||||||
|
- CSS - UI styling in `frontend/src/**/*.css` and CSS modules such as `frontend/src/features/schedule/TimelineSurface.module.css`
|
||||||
|
- YAML - CI/CD and compose configuration in `.github/workflows/*.yml`, `docker-compose.yml`, `docker-compose.dev.yml`
|
||||||
|
- Shell - Container/runtime entrypoints in `backend/docker-entrypoint.sh`, `frontend/nginx-entrypoint.sh`
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Node.js 22 runtime baseline (`node:22-slim` in `backend/Dockerfile`, `frontend/Dockerfile`; `actions/setup-node@v6` with `node-version: '22'` in `.github/workflows/test.yml` and `.github/workflows/e2e.yml`)
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- npm (scripts in root `package.json`, `backend/package.json`, `frontend/package.json`)
|
||||||
|
- Lockfile: present (`backend/package-lock.json`, `frontend/package-lock.json` referenced by workflow cache in `.github/workflows/test.yml`)
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- Fastify 5 (`fastify`, `@fastify/*` in `backend/package.json`; app bootstrap in `backend/src/index.ts`)
|
||||||
|
- React 19 (`react`, `react-dom` in `frontend/package.json`; app entry in `frontend/src/main.tsx`)
|
||||||
|
- Vite 8 (`vite` and `@vitejs/plugin-react` in `frontend/package.json`; config in `frontend/vite.config.ts`)
|
||||||
|
- Drizzle ORM + libSQL client (`drizzle-orm`, `@libsql/client` in `backend/package.json`; DB init in `backend/src/db/client.ts`)
|
||||||
|
- Mantine 8 UI system (`@mantine/*` in `frontend/package.json`; provider in `frontend/src/ui/providers/AppUiProvider.tsx`)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Vitest 4 (`vitest`, `@vitest/coverage-v8` in backend/frontend package manifests; configs in `backend/vitest.config.ts`, `frontend/vitest.config.ts`)
|
||||||
|
- Playwright (`@playwright/test` in `frontend/package.json`; configs in `frontend/playwright*.config.ts`; CI run in `.github/workflows/e2e.yml`)
|
||||||
|
- Testing Library (`@testing-library/*` in `frontend/package.json`)
|
||||||
|
|
||||||
|
**Build/Dev:**
|
||||||
|
- TypeScript compiler (`tsc` scripts in `backend/package.json` and frontend type-check via `frontend/package.json`)
|
||||||
|
- TSX watcher for backend dev (`tsx watch src/index.ts` in `backend/package.json`)
|
||||||
|
- Biome for lint/format (`biome.json`, lint/check scripts across package manifests)
|
||||||
|
- Drizzle Kit for DB migration generation (`drizzle-kit` in `backend/package.json`, config in `backend/drizzle.config.ts`)
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- `fastify` and `@fastify/*` - HTTP API runtime, security middleware, docs middleware (`backend/src/index.ts`)
|
||||||
|
- `drizzle-orm` + `@libsql/client` - SQLite data access and migration execution (`backend/src/db/client.ts`)
|
||||||
|
- `openid-client` + `jose` - OIDC SSO and token operations (`backend/src/routes/oidc.ts`, `backend/package.json`)
|
||||||
|
- `nodemailer` - SMTP notification delivery (`backend/src/services/notifications/delivery.ts`)
|
||||||
|
- `react`, `react-router-dom`, `@mantine/*` - SPA UI shell, routing, and component system (`frontend/src/main.tsx`, `frontend/src/App.tsx`)
|
||||||
|
- `i18next` + `react-i18next` - Localization runtime (`frontend/src/i18n/index.ts`)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `dotenv` + `zod` - env loading/validation (`backend/src/plugins/env.ts`)
|
||||||
|
- `sharp` - image processing pipeline support (`backend/package.json`, image route usage in medication flows)
|
||||||
|
- `@fastify/swagger` + `@fastify/swagger-ui` - OpenAPI docs on `/docs` (`backend/src/index.ts`)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Runtime env schema and validation in `backend/src/plugins/env.ts`
|
||||||
|
- Example variable inventory in `.env.example`
|
||||||
|
- Frontend proxy target via `BACKEND_URL` in `frontend/vite.config.ts` and compose files
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- Backend TS build config: `backend/tsconfig.json`
|
||||||
|
- Frontend TS + Vite config: `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, `frontend/vite.config.ts`
|
||||||
|
- DB migration tooling config: `backend/drizzle.config.ts`
|
||||||
|
- Quality tooling config: `biome.json`
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Node.js 22 with npm for local runs (`backend/package.json`, `frontend/package.json` scripts)
|
||||||
|
- Optional Docker Compose local stack (`docker-compose.dev.yml`)
|
||||||
|
- Browser runtime for frontend and Playwright browser binaries for E2E (`frontend/package.json`, `.github/workflows/e2e.yml`)
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Containerized deployment using prebuilt images from GHCR (`docker-compose.yml` references `ghcr.io/danielvolz/medassist-ng-backend:latest` and `ghcr.io/danielvolz/medassist-ng-frontend:latest`)
|
||||||
|
- Backend persistent filesystem for SQLite/data in mounted `./data` (`docker-compose.yml`, DB path resolver in `backend/src/db/path-utils.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Codebase Structure
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
medassist/
|
||||||
|
├── frontend/ # React + Vite SPA, UI, hooks, page routes, frontend tests
|
||||||
|
├── backend/ # Fastify API, domain services, DB schema/migrations, backend tests
|
||||||
|
├── backend/drizzle/ # SQL migration files + drizzle meta journal
|
||||||
|
├── docs/ # Product/ops docs and screenshots
|
||||||
|
├── doku/ # Local-only working notes and reports (ignored)
|
||||||
|
├── .github/ # CI workflows, agents, local skill/runtime metadata
|
||||||
|
├── .planning/codebase/ # Generated codebase mapping documents
|
||||||
|
├── data/ # Runtime/local SQLite backups and scheduler files
|
||||||
|
└── package.json # Root workspace scripts for lint orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**frontend/src:**
|
||||||
|
- Purpose: Product UI and client-side app logic.
|
||||||
|
- Contains: `pages/`, `components/`, `context/`, `hooks/`, `ui/`, `utils/`, `i18n/`, `test/`.
|
||||||
|
- Key files: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`.
|
||||||
|
|
||||||
|
**backend/src:**
|
||||||
|
- Purpose: HTTP API, auth, domain services, and persistence access.
|
||||||
|
- Contains: `routes/`, `services/`, `plugins/`, `db/`, `utils/`, `test/`.
|
||||||
|
- Key files: `backend/src/index.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/db/client.ts`.
|
||||||
|
|
||||||
|
**backend/drizzle:**
|
||||||
|
- Purpose: SQL migration history for SQLite compatibility.
|
||||||
|
- Contains: numbered migration files and `meta/_journal.json`.
|
||||||
|
- Key files: `backend/drizzle/0000_init.sql`, `backend/drizzle/0014_add_user_settings_timezone.sql`.
|
||||||
|
|
||||||
|
**frontend/e2e:**
|
||||||
|
- Purpose: Playwright end-to-end scenarios and fixtures.
|
||||||
|
- Contains: browser tests + auth fixtures.
|
||||||
|
- Key files: `frontend/e2e/fixtures/` and spec files under `frontend/e2e/`.
|
||||||
|
|
||||||
|
**docs + doku:**
|
||||||
|
- Purpose: formal docs (`docs/`) and local-only work tracking (`doku/`).
|
||||||
|
- Contains: behavior/spec docs, screenshots, local report/memory logs.
|
||||||
|
- Key files: `docs/TECH_STACK.md`, `doku/memory_notes.md`, `doku/report.md`.
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- `frontend/src/main.tsx`: Browser bootstrap; mounts providers and router.
|
||||||
|
- `frontend/src/App.tsx`: Route graph and global modal/shell orchestration.
|
||||||
|
- `backend/src/index.ts`: Fastify app setup + startup runtime.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `frontend/vite.config.ts`: Dev server, `/api` proxy rewrite, build-time constants.
|
||||||
|
- `frontend/vitest.config.ts`: Frontend unit test config.
|
||||||
|
- `backend/vitest.config.ts`: Backend unit/integration test config.
|
||||||
|
- `backend/drizzle.config.ts`: Drizzle migration configuration.
|
||||||
|
- `.gitignore`: Local-only/generated path policy (including `.planning/`, `doku/`, `data/`, coverage/test artifacts).
|
||||||
|
|
||||||
|
**Core Logic:**
|
||||||
|
- `backend/src/routes/`: API contracts and request handlers.
|
||||||
|
- `backend/src/services/`: Scheduler, notifications, medication helpers.
|
||||||
|
- `backend/src/db/schema.ts`: Source-of-truth table definitions.
|
||||||
|
- `frontend/src/context/`: Shared app orchestration state.
|
||||||
|
- `frontend/src/pages/`: Screen-level composition.
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- `frontend/src/test/`: Frontend unit/component tests.
|
||||||
|
- `frontend/e2e/`: Playwright E2E tests.
|
||||||
|
- `backend/src/test/`: Backend route/service/db tests.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- React components/pages use PascalCase: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MedDetailModal.tsx`.
|
||||||
|
- Hooks use `use*` naming: `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useSettings.ts`.
|
||||||
|
- Backend routes/services use kebab-case: `backend/src/routes/medication-enrichment.ts`, `backend/src/services/reminder-scheduler.ts`.
|
||||||
|
- Migrations use numbered descriptive names: `backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql`.
|
||||||
|
|
||||||
|
**Directories:**
|
||||||
|
- Feature/layer folders are lowercase: `frontend/src/context`, `backend/src/services`.
|
||||||
|
- Test directories stay colocated by runtime side (`frontend/src/test`, `backend/src/test`).
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New Feature:**
|
||||||
|
- Primary code:
|
||||||
|
- Frontend UI route/screen: `frontend/src/pages/` (compose from existing `components/`, `hooks/`, `ui/`).
|
||||||
|
- Backend endpoint: `backend/src/routes/` + matching domain logic in `backend/src/services/`.
|
||||||
|
- Persistence additions: `backend/src/db/schema.ts` plus migration updates in `backend/src/db/client.ts` and `backend/drizzle/`.
|
||||||
|
- Tests:
|
||||||
|
- Frontend unit/component: `frontend/src/test/`.
|
||||||
|
- Backend unit/integration: `backend/src/test/`.
|
||||||
|
- E2E flow: `frontend/e2e/`.
|
||||||
|
|
||||||
|
**New Component/Module:**
|
||||||
|
- Implementation:
|
||||||
|
- Shared UI primitive/layout: `frontend/src/ui/`.
|
||||||
|
- Domain-specific UI component: `frontend/src/components/` (or nested feature folder).
|
||||||
|
- Backend reusable domain behavior: `backend/src/services/`.
|
||||||
|
|
||||||
|
**Utilities:**
|
||||||
|
- Shared helpers:
|
||||||
|
- Frontend: `frontend/src/utils/`.
|
||||||
|
- Backend: `backend/src/utils/`.
|
||||||
|
- DB-specific helpers: `backend/src/db/` focused utility modules.
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**frontend/dist, backend/dist:**
|
||||||
|
- Purpose: build output artifacts.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`dist/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**frontend/playwright-report, frontend/test-results, frontend/coverage, backend/coverage:**
|
||||||
|
- Purpose: test artifacts/reports.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**data/:**
|
||||||
|
- Purpose: runtime/local DB, reminder state, scheduler locks.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`data/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**doku/:**
|
||||||
|
- Purpose: local work memory/reporting and internal notes.
|
||||||
|
- Generated: Mixed (manual local notes + artifacts).
|
||||||
|
- Committed: No (`doku/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**.planning/codebase/:**
|
||||||
|
- Purpose: generated architecture/stack/convention/concern maps for GSD planning/execution.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`.planning/` ignored by policy in this workspace).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- Vitest 4.x for unit/integration tests in both packages:
|
||||||
|
- Frontend config: `frontend/vitest.config.ts`
|
||||||
|
- Backend config: `backend/vitest.config.ts`
|
||||||
|
- Config evidence:
|
||||||
|
- Frontend uses `environment: 'jsdom'` with React setup file `frontend/src/test/setup.ts`.
|
||||||
|
- Backend uses `environment: 'node'` with setup file `backend/src/test/setup.ts`.
|
||||||
|
|
||||||
|
**Assertion Library:**
|
||||||
|
- Vitest `expect`.
|
||||||
|
- Frontend extends DOM assertions via `@testing-library/jest-dom` in `frontend/src/test/setup.ts`.
|
||||||
|
|
||||||
|
**Run Commands:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm test # Watch/unit tests
|
||||||
|
cd frontend && npm run test:run # CI-style frontend run
|
||||||
|
cd frontend && npm run test:coverage # Frontend coverage
|
||||||
|
cd backend && npm test # Watch/unit tests
|
||||||
|
cd backend && npm run test:run # CI-style backend run
|
||||||
|
cd backend && npm run test:coverage # Backend coverage
|
||||||
|
cd frontend && npm run test:e2e # Stable Playwright suite
|
||||||
|
cd frontend && npm run test:e2e:all # Cross-browser Playwright suite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Organization
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Backend unit/integration tests are in `backend/src/test/*.test.ts`.
|
||||||
|
- Frontend unit/component/hook/context tests are in `frontend/src/test/**`.
|
||||||
|
- Browser E2E tests are in `frontend/e2e/*.spec.ts`.
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
- Unit/integration: `*.test.ts` or `*.test.tsx` (for example `backend/src/test/routes-real.test.ts`, `frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||||
|
- E2E: `*.spec.ts` (for example `frontend/e2e/medication-edit.spec.ts`).
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
backend/src/test/
|
||||||
|
setup.ts
|
||||||
|
*.test.ts
|
||||||
|
|
||||||
|
frontend/src/test/
|
||||||
|
setup.ts
|
||||||
|
App.test.tsx
|
||||||
|
components/*.test.tsx
|
||||||
|
context/*.test.tsx
|
||||||
|
hooks/*.test.ts
|
||||||
|
pages/*.test.tsx
|
||||||
|
utils/*.test.ts
|
||||||
|
|
||||||
|
frontend/e2e/
|
||||||
|
auth.setup.ts
|
||||||
|
fixtures/index.ts
|
||||||
|
*.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**Suite Organization:**
|
||||||
|
```typescript
|
||||||
|
describe("Feature Area", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles expected behavior", async () => {
|
||||||
|
// arrange
|
||||||
|
// act
|
||||||
|
// assert
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/components/MobileEditModal.test.tsx`, `backend/src/test/planner.test.ts`.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Setup pattern:
|
||||||
|
- Frontend centralizes browser mocks in `frontend/src/test/setup.ts` (fetch, localStorage, clipboard, history, i18n).
|
||||||
|
- Backend provides reusable app/database factories in `backend/src/test/setup.ts` (`buildTestApp`, `createTestUser`, `createTestMedication`).
|
||||||
|
- Teardown pattern:
|
||||||
|
- `afterAll` closes Fastify app and DB clients (`backend/src/test/planner.test.ts`, `backend/src/test/integration.test.ts`).
|
||||||
|
- Assertion pattern:
|
||||||
|
- Route tests assert both HTTP status and response body (`backend/src/test/routes-real.test.ts`).
|
||||||
|
- UI tests assert presence and behavior via Testing Library role/test-id queries (`frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- Vitest mocks (`vi.mock`, `vi.fn`, `vi.hoisted`, `vi.stubGlobal`).
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
```typescript
|
||||||
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
return { testClient: client, testDb: db };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/integration.test.ts`, `backend/src/test/routes-real.test.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock("../../components/ConfirmModal", () => ({
|
||||||
|
ConfirmModal: ({ onConfirm }) => <button onClick={onConfirm}>confirm</button>,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/components/MedicationDialogs.test.tsx`.
|
||||||
|
|
||||||
|
**What to Mock:**
|
||||||
|
- External side effects and infrastructure boundaries: SMTP/nodemailer, fetch network calls, auth/plugin env modules, browser APIs.
|
||||||
|
- Component dependencies in focused unit tests (replace heavy children with stubs).
|
||||||
|
|
||||||
|
**What NOT to Mock:**
|
||||||
|
- Core business behavior under direct test (route handlers in route tests, hook logic in hook tests, E2E API + UI flow in Playwright).
|
||||||
|
|
||||||
|
## Fixtures and Factories
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
```typescript
|
||||||
|
const userId = await createTestUser(client, { username: "testuser" });
|
||||||
|
const medId = await createTestMedication(client, { userId, name: "Test Medication" });
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/setup.ts`, used by `backend/src/test/medications.test.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const test = base.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
await applyVideoSafetyMode(page);
|
||||||
|
await setupAuthMeMock(page);
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Backend factories/utilities: `backend/src/test/setup.ts`.
|
||||||
|
- Frontend E2E shared fixtures and API helpers: `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Frontend global thresholds in `frontend/vitest.config.ts`: lines/functions/branches/statements = 75.
|
||||||
|
- Backend global thresholds in `backend/vitest.config.ts`: lines 60, functions 65, branches 50, statements 60.
|
||||||
|
|
||||||
|
**View Coverage:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run test:coverage
|
||||||
|
cd backend && npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- Component/hook/utils tests in `frontend/src/test/**`.
|
||||||
|
- Utility/service route-unit style tests in `backend/src/test/*.test.ts`.
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- Backend route interaction and multi-route behavior tests in files like:
|
||||||
|
- `backend/src/test/integration.test.ts`
|
||||||
|
- `backend/src/test/routes-real.test.ts`
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
- Playwright used with setup project and browser projects (`frontend/playwright.base.config.ts`).
|
||||||
|
- Auth/session and API seeding helpers in `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Async Testing:**
|
||||||
|
```typescript
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/context/AppContext.test.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/routes-real.test.ts`.
|
||||||
|
|
||||||
|
**Error Testing:**
|
||||||
|
```typescript
|
||||||
|
const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { rows: [] } });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/planner.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-04-30*
|
||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"vitest.root": "backend",
|
|
||||||
"vitest.enable": true,
|
|
||||||
"vitest.commandLine": "npm test --",
|
|
||||||
"chat.tools.terminal.autoApprove": {
|
|
||||||
"test": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+78
@@ -83,6 +83,84 @@
|
|||||||
"type": "shell",
|
"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')\"",
|
"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
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US4 T038 frontend check+build",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US4 T038 frontend check+build rerun",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US4 T038 frontend gate final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US4 T038 frontend gate pass check",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US4 T038 frontend build only",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US6 T050 backend check+build",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US6 backend biome autofix touched files",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && npx biome check --write src/db/client.ts src/db/db-utils.ts src/routes/medications.ts src/routes/planner.ts src/routes/settings.ts src/services/medication-enrichment/adapters.ts src/services/medication-enrichment/index.ts src/services/medications-service.ts",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US6 T050 backend gate rerun",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US6 T050 backend gate final",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Rewrite db-utils barrel",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cat > backend/src/db/db-utils.ts <<'EOF'\n/**\n * Compatibility barrel for DB utilities.\n *\n * New code should prefer importing from focused modules:\n * - ./path-utils.js\n * - ./migration-utils.js\n * - ./repair-utils.js\n */\n\nexport { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from \"./migration-utils.js\";\nexport { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from \"./path-utils.js\";\nexport { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from \"./repair-utils.js\";\nEOF",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "US6 T050 backend gate success attempt",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && npm run check && npm run build",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T039 targeted frontend parity tests",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd frontend && CI=true npm run test:run -- src/test/components/MedicationEditCoordinator.test.tsx src/test/components/MedicationDialogs.test.tsx src/test/components/MobileEditModal.test.tsx",
|
||||||
|
"isBackground": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T044/T051 targeted backend regression tests",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd backend && CI=true npm run test:run -- src/test/decomposition-services.test.ts src/test/medication-enrichment.test.ts src/test/database.test.ts src/test/medications.test.ts src/test/planner.test.ts src/test/settings.test.ts",
|
||||||
|
"isBackground": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-696%2F696-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-875%2F875-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -120,19 +120,19 @@ Share your medication schedule with others via a public link.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Medication Setup
|
### Medication Setup
|
||||||
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them
|
- Optional medication lookup in the editor on desktop and mobile
|
||||||
- Explicit review-and-apply flow with low-risk suggestions only
|
- Supports `RxNorm`, `openFDA`, and `EMA` with source labels
|
||||||
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
- Review-and-apply flow with package-size suggestions when available
|
||||||
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
- Manual entry remains available
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection)
|
||||||
- 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 profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container)
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with package-aware refill options for discrete containers and amount-based packages
|
||||||
- Complete refill history per medication
|
- Complete refill history per medication
|
||||||
- Automatic stock updates after each refill
|
- Automatic stock updates after each refill
|
||||||
|
|
||||||
@@ -148,7 +148,6 @@ Share your medication schedule with others via a public link.
|
|||||||
|
|
||||||
### Trip Planner
|
### Trip Planner
|
||||||
- Calculate medication demand for a trip or date range with package-aware units
|
- Calculate medication demand for a trip or date range with package-aware units
|
||||||
- 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
|
||||||
|
|
||||||
### Reports
|
### Reports
|
||||||
@@ -168,7 +167,7 @@ Share your medication schedule with others via a public link.
|
|||||||
### Notifications
|
### Notifications
|
||||||
- Email via SMTP
|
- Email via SMTP
|
||||||
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||||
- Supports both stock warnings and intake reminders
|
- Supports stock warnings and intake reminders
|
||||||
|
|
||||||
### Privacy & Security
|
### Privacy & Security
|
||||||
- Fully self-hosted
|
- Fully self-hosted
|
||||||
@@ -191,198 +190,33 @@ Open `http://localhost:4174` and start tracking your medications.
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
All configuration is done via environment variables in `.env`. Copy `.env.example` to get started.
|
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
|
||||||
|
|
||||||
### General
|
### Initial Configuration
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PUID` | `1000` | User ID for container file permissions |
|
| `PUID` | `1000` | User ID for container file permissions |
|
||||||
| `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 frontend origins |
|
||||||
| `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. |
|
| `TZ` | `Europe/Berlin` | Default timezone for reminders |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
Recommended values for API docs by environment:
|
Optional but commonly needed:
|
||||||
|
|
||||||
| 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
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `AUTH_ENABLED` | `false` | Enable user authentication |
|
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
|
||||||
| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
|
|
||||||
| `JWT_SECRET` | — | Access token signing key (required if auth enabled) |
|
|
||||||
| `REFRESH_SECRET` | — | Refresh token signing key (required if auth enabled) |
|
|
||||||
| `COOKIE_SECRET` | — | Cookie signing key (required if auth enabled) |
|
|
||||||
| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
|
|
||||||
| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
|
|
||||||
|
|
||||||
Generate secrets with: `openssl rand -hex 32`
|
Detailed configuration references:
|
||||||
|
|
||||||
### API Keys (Programmatic API Access)
|
- Full configuration reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||||
|
- Push notifications: [docs/PUSH_NOTIFICATIONS.md](docs/PUSH_NOTIFICATIONS.md)
|
||||||
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
|
- Default user settings: [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
|
|
||||||
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
|
|
||||||
| `OIDC_CLIENT_ID` | — | Client ID from OIDC provider |
|
|
||||||
| `OIDC_CLIENT_SECRET` | — | Client secret from OIDC provider |
|
|
||||||
| `OIDC_REDIRECT_URI` | — | Full callback URL (e.g., `https://your-domain.com/api/auth/oidc/callback`) |
|
|
||||||
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
|
|
||||||
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim for username |
|
|
||||||
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
|
|
||||||
| `OIDC_PROVIDER_NAME` | `SSO` | Name shown on login button |
|
|
||||||
|
|
||||||
### Email (SMTP)
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `SMTP_HOST` | — | SMTP server hostname |
|
|
||||||
| `SMTP_PORT` | `587` | SMTP server port |
|
|
||||||
| `SMTP_USER` | — | SMTP username |
|
|
||||||
| `SMTP_PASS` | — | SMTP password |
|
|
||||||
| `SMTP_TOKEN` | — | OAuth2/App token (takes precedence over password) |
|
|
||||||
| `SMTP_FROM` | — | Sender email address |
|
|
||||||
| `SMTP_SECURE` | `false` | Use TLS |
|
|
||||||
|
|
||||||
### Reminders
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
|
|
||||||
| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
|
|
||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
|
||||||
|
|
||||||
### Push Notifications (Shoutrrr)
|
|
||||||
|
|
||||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
|
||||||
|
|
||||||
**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:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
|
||||||
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
|
||||||
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
|
||||||
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
|
||||||
|
|
||||||
### Default User Settings
|
|
||||||
|
|
||||||
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
|
|
||||||
|
|
||||||
Complete list and details:
|
|
||||||
|
|
||||||
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
|
||||||
|
|
||||||
#### URL Examples
|
|
||||||
|
|
||||||
**ntfy** (free, self-hostable):
|
|
||||||
```
|
|
||||||
ntfy://ntfy.sh/your-topic
|
|
||||||
ntfy://user:password@your-server.com/topic
|
|
||||||
```
|
|
||||||
|
|
||||||
**Pushover** (free app for iOS/Android):
|
|
||||||
```
|
|
||||||
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
|
||||||
```
|
|
||||||
Get your keys at [pushover.net](https://pushover.net/):
|
|
||||||
- **User Key**: Shown on your dashboard (top right)
|
|
||||||
- **API Token**: Create an application → copy the API Token
|
|
||||||
|
|
||||||
**Gotify** (self-hosted):
|
|
||||||
```
|
|
||||||
gotify://your-server.com/TOKEN
|
|
||||||
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Discord**:
|
|
||||||
```
|
|
||||||
discord://TOKEN@WEBHOOK_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
**Telegram**:
|
|
||||||
```
|
|
||||||
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/).
|
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
```bash
|
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||||
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
|
||||||
```
|
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173` (hot reload)
|
|
||||||
- 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 `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
|||||||
"when": 1773348659979,
|
"when": 1773348659979,
|
||||||
"tag": "0013_add_share_medication_overview",
|
"tag": "0013_add_share_medication_overview",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775849300000,
|
||||||
|
"tag": "0014_add_user_settings_timezone",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+430
-1631
File diff suppressed because it is too large
Load Diff
+24
-17
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.22.0",
|
"version": "1.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,35 +19,42 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/formbody": "^8.0.2",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
|
||||||
"@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.1.3",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.6",
|
||||||
"@libsql/client": "^0.17.2",
|
"@libsql/client": "^0.17.3",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.5",
|
||||||
"nodemailer": "^8.0.3",
|
"fastify-plugin": "^5.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"jose": "^6.2.3",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.8",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.6.2",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"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": "^6.0.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@esbuild-kit/esm-loader": "2.6.5",
|
||||||
|
"@esbuild-kit/core-utils": "3.3.2",
|
||||||
|
"esbuild": "0.25.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,10 @@ import { type Client, createClient } from "@libsql/client";
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import { log } from "../utils/logger.js";
|
import { log } from "../utils/logger.js";
|
||||||
// Import utilities from db-utils (side-effect-free)
|
import { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
|
||||||
import {
|
// Import utilities from focused DB modules (side-effect-free)
|
||||||
ensureDataDirectory,
|
import { ensureDataDirectory, getDbPaths } from "./path-utils.js";
|
||||||
ensureDefaultUser,
|
import { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
|
||||||
getDbPaths,
|
|
||||||
repairOrphanedDoseIds,
|
|
||||||
repairTrailingHyphenDoseIds,
|
|
||||||
runAlterMigrations,
|
|
||||||
runDrizzleMigrations,
|
|
||||||
} from "./db-utils.js";
|
|
||||||
|
|
||||||
// Re-export all utilities so existing imports from client.ts keep working
|
// Re-export all utilities so existing imports from client.ts keep working
|
||||||
export {
|
export {
|
||||||
|
|||||||
+9
-428
@@ -1,431 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Pure utility functions for database operations.
|
* Compatibility barrel for DB utilities.
|
||||||
* Separated from client.ts to allow importing without triggering
|
*
|
||||||
* top-level database initialization side effects.
|
* New code should prefer importing from focused modules:
|
||||||
|
* - ./path-utils.js
|
||||||
|
* - ./migration-utils.js
|
||||||
|
* - ./repair-utils.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
export { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
|
||||||
import { dirname, resolve } from "node:path";
|
export { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from "./path-utils.js";
|
||||||
import { fileURLToPath } from "node:url";
|
export { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
|
||||||
import type { Client } from "@libsql/client";
|
|
||||||
import type { drizzle } from "drizzle-orm/libsql";
|
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
||||||
import {
|
|
||||||
forEachScheduledOccurrenceInRange,
|
|
||||||
getDateOnlyTimestamp,
|
|
||||||
getScheduleMatchWindowMs,
|
|
||||||
parseIntakesJson,
|
|
||||||
parseLocalDateTime,
|
|
||||||
} from "../utils/scheduler-utils.js";
|
|
||||||
|
|
||||||
// Get migrations folder path (relative to this file's location)
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Path & Directory utilities
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the data directory path.
|
|
||||||
*
|
|
||||||
* Resolution order:
|
|
||||||
* 1. DATA_DIR env var (set by docker-compose for containers)
|
|
||||||
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
|
|
||||||
* subdirectory → use ../data (project root's data folder)
|
|
||||||
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
|
|
||||||
*/
|
|
||||||
export function getDataDir(cwd: string = process.cwd()): string {
|
|
||||||
// Docker containers set DATA_DIR explicitly
|
|
||||||
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
|
|
||||||
|
|
||||||
// Local dev: detect if we're in backend/ subdirectory of the monorepo
|
|
||||||
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
|
|
||||||
return resolve(cwd, "..", "data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: data/ relative to cwd (running from project root)
|
|
||||||
return resolve(cwd, "data");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the database URL from a path */
|
|
||||||
export function buildDbUrl(dbPath: string): string {
|
|
||||||
return `file:${dbPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get data directory and database path */
|
|
||||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
|
||||||
const dataDir = getDataDir(cwd);
|
|
||||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
|
||||||
const url = buildDbUrl(dbPath);
|
|
||||||
return { dataDir, dbPath, url };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure data directory exists and is writable */
|
|
||||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
|
||||||
try {
|
|
||||||
if (!existsSync(dataDir)) {
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if directory is writable
|
|
||||||
accessSync(dataDir, constants.W_OK);
|
|
||||||
|
|
||||||
// Try to create a test file to verify write access
|
|
||||||
const testFile = resolve(dataDir, ".write-test");
|
|
||||||
writeFileSync(testFile, "test");
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Migration utilities
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** Run drizzle-kit migrations on the database */
|
|
||||||
export async function runDrizzleMigrations(
|
|
||||||
database: ReturnType<typeof drizzle>
|
|
||||||
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
|
||||||
try {
|
|
||||||
await migrate(database, { migrationsFolder });
|
|
||||||
return { success: true };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = (err as Error).message ?? "";
|
|
||||||
// Duplicate column / already exists = DB is already up-to-date (expected for existing DBs)
|
|
||||||
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, error: msg };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
|
||||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
// These add new columns to existing tables (silently fail if column already exists)
|
|
||||||
const alterMigrations = [
|
|
||||||
// Added in v1.x - repeat reminders and nagging settings
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
|
||||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
|
||||||
// Added 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)
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
|
||||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
|
||||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
|
||||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
|
||||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
|
||||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
|
||||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
|
||||||
// Added for soft-archiving medications (without deleting history)
|
|
||||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
|
||||||
// Added for explicit medication lifecycle start date
|
|
||||||
`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
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
|
||||||
// Added for package type support (blister vs bottle)
|
|
||||||
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN total_pills integer`,
|
|
||||||
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
|
|
||||||
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
|
|
||||||
// Added for intake-level takenBy: unified intakes structure
|
|
||||||
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
|
|
||||||
// Added for separate stock reminder tracking
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
|
||||||
// Added for share stock visibility toggle
|
|
||||||
`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)
|
|
||||||
`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 swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
|
||||||
// Added for prescription refill tracking and reminders
|
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
|
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
|
||||||
// Added for refill history prescription tracking
|
|
||||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
|
||||||
try {
|
|
||||||
await client.execute(sql);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Silently ignore "duplicate column" errors - column already exists
|
|
||||||
if (!(e as Error).message?.includes("duplicate column")) {
|
|
||||||
errors.push((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tables that might be missing (silently fail if already exists)
|
|
||||||
const createTableMigrations = [
|
|
||||||
// Added in v1.3.x - refill history tracking
|
|
||||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
|
||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
)`,
|
|
||||||
// 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) {
|
|
||||||
try {
|
|
||||||
await client.execute(sql);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Silently ignore "table already exists" errors
|
|
||||||
if (!(e as Error).message?.includes("already exists")) {
|
|
||||||
errors.push((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create indexes that might be missing (silently fail if already exists)
|
|
||||||
const createIndexMigrations = [
|
|
||||||
// Added in v1.6.x - case-insensitive unique usernames
|
|
||||||
`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) {
|
|
||||||
try {
|
|
||||||
await client.execute(sql);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Silently ignore "already exists" errors
|
|
||||||
if (!(e as Error).message?.includes("already exists")) {
|
|
||||||
errors.push((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// User utilities
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** Ensure default user exists for auth-disabled mode */
|
|
||||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
|
||||||
if (authEnabled) {
|
|
||||||
return false; // No default user needed
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
|
||||||
return true; // Created
|
|
||||||
}
|
|
||||||
return false; // Already exists
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Startup repair: fix orphaned dose tracking IDs from past schedule changes
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
|
||||||
* `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-"
|
|
||||||
* instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs.
|
|
||||||
*
|
|
||||||
* This function is idempotent - safe to run on every startup.
|
|
||||||
*/
|
|
||||||
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
let repaired = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.execute(
|
|
||||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
|
||||||
);
|
|
||||||
repaired = result.rowsAffected;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { repaired, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
|
|
||||||
* This fixes dose IDs that became invalid when a medication's schedule was changed
|
|
||||||
* BEFORE the on-edit migration (PR #103) was introduced.
|
|
||||||
*
|
|
||||||
* For each medication, generates all valid schedule dateOnlyMs values from each intake's
|
|
||||||
* start date up to today, then checks all dose_tracking entries. Any dose whose timestamp
|
|
||||||
* doesn't match a valid schedule date is remapped to the nearest valid date.
|
|
||||||
*
|
|
||||||
* This function is idempotent - safe to run on every startup.
|
|
||||||
*/
|
|
||||||
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
let repaired = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all medications
|
|
||||||
const medsResult = await client.execute(
|
|
||||||
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (medsResult.rows.length === 0) return { repaired, errors };
|
|
||||||
|
|
||||||
// Get all dose tracking entries
|
|
||||||
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
|
|
||||||
if (dosesResult.rows.length === 0) return { repaired, errors };
|
|
||||||
|
|
||||||
// Build a map of medId → dose entries for quick lookup
|
|
||||||
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
|
|
||||||
for (const row of dosesResult.rows) {
|
|
||||||
const doseId = row.dose_id as string;
|
|
||||||
const parts = doseId.split("-");
|
|
||||||
if (parts.length < 3) continue;
|
|
||||||
const medId = parseInt(parts[0], 10);
|
|
||||||
if (Number.isNaN(medId)) continue;
|
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
|
||||||
dosesByMed.get(medId)!.push({ id: row.id as number, doseId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
|
|
||||||
for (const med of medsResult.rows) {
|
|
||||||
const medId = med.id as number;
|
|
||||||
const medDoses = dosesByMed.get(medId);
|
|
||||||
if (!medDoses || medDoses.length === 0) continue;
|
|
||||||
|
|
||||||
// Parse intakes
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakes_json as string | null,
|
|
||||||
{
|
|
||||||
usageJson: (med.usage_json as string) || "[]",
|
|
||||||
everyJson: (med.every_json as string) || "[]",
|
|
||||||
startJson: (med.start_json as string) || "[]",
|
|
||||||
},
|
|
||||||
(med.intake_reminders_enabled as number) === 1
|
|
||||||
);
|
|
||||||
|
|
||||||
if (intakes.length === 0) continue;
|
|
||||||
|
|
||||||
// For each intake index, build the set of valid dateOnlyMs values
|
|
||||||
const validDatesByIntake = new Map<number, Set<number>>();
|
|
||||||
for (let idx = 0; idx < intakes.length; idx++) {
|
|
||||||
const intake = intakes[idx];
|
|
||||||
const start = parseLocalDateTime(intake.start);
|
|
||||||
const every = intake.every;
|
|
||||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
|
||||||
|
|
||||||
const validDates = new Set<number>();
|
|
||||||
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
|
|
||||||
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
|
||||||
});
|
|
||||||
validDatesByIntake.set(idx, validDates);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each dose entry
|
|
||||||
for (const dose of medDoses) {
|
|
||||||
const parts = dose.doseId.split("-");
|
|
||||||
if (parts.length < 3) continue;
|
|
||||||
|
|
||||||
const intakeIdx = parseInt(parts[1], 10);
|
|
||||||
const dateOnlyMs = parseInt(parts[2], 10);
|
|
||||||
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
|
|
||||||
|
|
||||||
const validDates = validDatesByIntake.get(intakeIdx);
|
|
||||||
if (!validDates) continue; // Unknown intake index - skip
|
|
||||||
|
|
||||||
// Check if this dose's timestamp is valid
|
|
||||||
if (validDates.has(dateOnlyMs)) continue; // Already valid - nothing to do
|
|
||||||
|
|
||||||
// Orphaned dose - find the nearest valid schedule date
|
|
||||||
const intake = intakes[intakeIdx];
|
|
||||||
if (!intake) continue;
|
|
||||||
|
|
||||||
const halfInterval = getScheduleMatchWindowMs(intake);
|
|
||||||
let bestMatch: number | null = null;
|
|
||||||
let bestDist = Infinity;
|
|
||||||
|
|
||||||
for (const validDate of validDates) {
|
|
||||||
const dist = Math.abs(validDate - dateOnlyMs);
|
|
||||||
if (dist < bestDist && dist <= halfInterval) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestMatch = validDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestMatch !== null) {
|
|
||||||
// Rebuild dose ID with new timestamp, preserving person suffix
|
|
||||||
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
|
|
||||||
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.execute({
|
|
||||||
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
|
|
||||||
args: [newDoseId, dose.id],
|
|
||||||
});
|
|
||||||
repaired++;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
errors.push(`Repair failed: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { repaired, errors };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { Client } from "@libsql/client";
|
||||||
|
import type { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
/** Run drizzle-kit migrations on the database */
|
||||||
|
export async function runDrizzleMigrations(
|
||||||
|
database: ReturnType<typeof drizzle>
|
||||||
|
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||||
|
try {
|
||||||
|
await migrate(database, { migrationsFolder });
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as Error).message ?? "";
|
||||||
|
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||||
|
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const alterMigrations = [
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||||
|
`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'`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN total_pills integer`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
|
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview 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 swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||||
|
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of alterMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!(e as Error).message?.includes("duplicate column")) {
|
||||||
|
errors.push((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTableMigrations = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_key TEXT NOT NULL UNIQUE,
|
||||||
|
sequence_id TEXT NOT NULL,
|
||||||
|
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
|
||||||
|
dose_ids_json TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
scheduled_for INTEGER,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
resolved_action TEXT,
|
||||||
|
resolved_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`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) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
|
errors.push((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const postCreateAlterMigrations = [
|
||||||
|
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of postCreateAlterMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!(e as Error).message?.includes("duplicate column")) {
|
||||||
|
errors.push((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIndexMigrations = [
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of createIndexMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
|
errors.push((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure default user exists for auth-disabled mode */
|
||||||
|
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||||
|
if (authEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data directory path.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. DATA_DIR env var (set by docker-compose for containers)
|
||||||
|
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
|
||||||
|
* subdirectory -> use ../data (project root's data folder)
|
||||||
|
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
|
||||||
|
*/
|
||||||
|
export function getDataDir(cwd: string = process.cwd()): string {
|
||||||
|
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
|
||||||
|
|
||||||
|
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
|
||||||
|
return resolve(cwd, "..", "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(cwd, "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the database URL from a path */
|
||||||
|
export function buildDbUrl(dbPath: string): string {
|
||||||
|
return `file:${dbPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get data directory and database path */
|
||||||
|
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||||
|
const dataDir = getDataDir(cwd);
|
||||||
|
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||||
|
const url = buildDbUrl(dbPath);
|
||||||
|
return { dataDir, dbPath, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure data directory exists and is writable */
|
||||||
|
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
accessSync(dataDir, constants.W_OK);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Client } from "@libsql/client";
|
||||||
|
import {
|
||||||
|
forEachScheduledOccurrenceInRange,
|
||||||
|
getDateOnlyTimestamp,
|
||||||
|
getScheduleMatchWindowMs,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
||||||
|
* [].toString() produced an empty string.
|
||||||
|
*/
|
||||||
|
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let repaired = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.execute(
|
||||||
|
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||||
|
);
|
||||||
|
repaired = result.rowsAffected;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repaired, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
|
||||||
|
*/
|
||||||
|
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let repaired = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const medsResult = await client.execute(
|
||||||
|
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (medsResult.rows.length === 0) return { repaired, errors };
|
||||||
|
|
||||||
|
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
|
||||||
|
if (dosesResult.rows.length === 0) return { repaired, errors };
|
||||||
|
|
||||||
|
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
|
||||||
|
for (const row of dosesResult.rows) {
|
||||||
|
const doseId = row.dose_id as string;
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const medId = parseInt(parts[0], 10);
|
||||||
|
if (Number.isNaN(medId)) continue;
|
||||||
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
|
dosesByMed.get(medId)?.push({ id: row.id as number, doseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
for (const med of medsResult.rows) {
|
||||||
|
const medId = med.id as number;
|
||||||
|
const medDoses = dosesByMed.get(medId);
|
||||||
|
if (!medDoses || medDoses.length === 0) continue;
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakes_json as string | null,
|
||||||
|
{
|
||||||
|
usageJson: (med.usage_json as string) || "[]",
|
||||||
|
everyJson: (med.every_json as string) || "[]",
|
||||||
|
startJson: (med.start_json as string) || "[]",
|
||||||
|
},
|
||||||
|
(med.intake_reminders_enabled as number) === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intakes.length === 0) continue;
|
||||||
|
|
||||||
|
const validDatesByIntake = new Map<number, Set<number>>();
|
||||||
|
for (let idx = 0; idx < intakes.length; idx++) {
|
||||||
|
const intake = intakes[idx];
|
||||||
|
const start = parseLocalDateTime(intake.start);
|
||||||
|
const every = intake.every;
|
||||||
|
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||||
|
|
||||||
|
const validDates = new Set<number>();
|
||||||
|
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
|
||||||
|
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||||
|
});
|
||||||
|
validDatesByIntake.set(idx, validDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dose of medDoses) {
|
||||||
|
const parts = dose.doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const intakeIdx = parseInt(parts[1], 10);
|
||||||
|
const dateOnlyMs = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
|
||||||
|
|
||||||
|
const validDates = validDatesByIntake.get(intakeIdx);
|
||||||
|
if (!validDates || validDates.has(dateOnlyMs)) continue;
|
||||||
|
|
||||||
|
const intake = intakes[intakeIdx];
|
||||||
|
if (!intake) continue;
|
||||||
|
|
||||||
|
const halfInterval = getScheduleMatchWindowMs(intake);
|
||||||
|
let bestMatch: number | null = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
|
||||||
|
for (const validDate of validDates) {
|
||||||
|
const dist = Math.abs(validDate - dateOnlyMs);
|
||||||
|
if (dist < bestDist && dist <= halfInterval) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestMatch = validDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch !== null) {
|
||||||
|
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
|
||||||
|
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.execute({
|
||||||
|
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
|
||||||
|
args: [newDoseId, dose.id],
|
||||||
|
});
|
||||||
|
repaired++;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repaired, errors };
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
|
|||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
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,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
|
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
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
|
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
// Current MedAssist versions no longer read or expose this setting in product flows.
|
||||||
|
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
// Whether shared schedule links also embed the medication overview section
|
// Whether shared schedule links also embed the medication overview section
|
||||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||||
// UI timeline visibility preferences
|
// UI timeline visibility preferences
|
||||||
@@ -182,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Groups - Shared action state for reminder notifications
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionGroups = sqliteTable("notification_action_groups", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
groupKey: text("group_key", { length: 255 }).notNull().unique(),
|
||||||
|
sequenceId: text("sequence_id", { length: 255 }).notNull(),
|
||||||
|
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
|
||||||
|
doseIdsJson: text("dose_ids_json").notNull(),
|
||||||
|
title: text("title", { length: 255 }).notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
|
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
resolvedAction: text("resolved_action", { length: 20 }),
|
||||||
|
resolvedAt: integer("resolved_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`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Tokens - Hashed tokens for public reminder responses
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
groupId: integer("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
|
||||||
|
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
|
||||||
|
kind: text("kind", { length: 20 }).notNull(),
|
||||||
|
usedAt: integer("used_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking - Tracks when doses are marked as taken
|
// Dose Tracking - Tracks when doses are marked as taken
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -193,8 +232,8 @@ 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
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ type TranslationKeys = {
|
|||||||
stockTitle: string;
|
stockTitle: string;
|
||||||
stockTitleMultiple: string;
|
stockTitleMultiple: string;
|
||||||
intakeTitle: string;
|
intakeTitle: string;
|
||||||
|
intakeTakenConfirmation: string;
|
||||||
|
intakeSkippedConfirmation: string;
|
||||||
pillsLeft: string;
|
pillsLeft: string;
|
||||||
daysLeft: string;
|
daysLeft: string;
|
||||||
pillsAt: string;
|
pillsAt: string;
|
||||||
@@ -179,6 +181,8 @@ type TranslationKeys = {
|
|||||||
common: {
|
common: {
|
||||||
pill: string;
|
pill: string;
|
||||||
pills: string;
|
pills: string;
|
||||||
|
puffs: string;
|
||||||
|
injections: string;
|
||||||
units: string;
|
units: string;
|
||||||
ml: string;
|
ml: string;
|
||||||
blister: string;
|
blister: string;
|
||||||
@@ -209,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
descriptionLow: "The following medications are running low and should be reordered soon:",
|
descriptionLow: "The following medications are running low and should be reordered soon:",
|
||||||
tableHeaders: {
|
tableHeaders: {
|
||||||
medication: "Medication",
|
medication: "Medication",
|
||||||
pills: "Pills",
|
pills: "Available",
|
||||||
days: "Days",
|
days: "Days",
|
||||||
runsOut: "Runs Out",
|
runsOut: "Runs Out",
|
||||||
},
|
},
|
||||||
@@ -234,6 +238,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
|
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
|
||||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
|
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
|
||||||
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
|
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
|
||||||
|
intakeTakenConfirmation: "✅ This dose was marked as taken.",
|
||||||
|
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
|
||||||
pillsLeft: "{count} pills",
|
pillsLeft: "{count} pills",
|
||||||
daysLeft: "{count} days left",
|
daysLeft: "{count} days left",
|
||||||
pillsAt: "{count} pills at {time}",
|
pillsAt: "{count} pills at {time}",
|
||||||
@@ -301,6 +307,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "pill",
|
pill: "pill",
|
||||||
pills: "pills",
|
pills: "pills",
|
||||||
|
puffs: "puffs",
|
||||||
|
injections: "injections",
|
||||||
units: "units",
|
units: "units",
|
||||||
ml: "ml",
|
ml: "ml",
|
||||||
blister: "blister",
|
blister: "blister",
|
||||||
@@ -329,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
|
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
|
||||||
tableHeaders: {
|
tableHeaders: {
|
||||||
medication: "Medikament",
|
medication: "Medikament",
|
||||||
pills: "Tabletten",
|
pills: "Verfuegbar",
|
||||||
days: "Tage",
|
days: "Tage",
|
||||||
runsOut: "Aufgebraucht",
|
runsOut: "Aufgebraucht",
|
||||||
},
|
},
|
||||||
@@ -355,6 +363,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
|
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
|
||||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
|
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
|
||||||
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
|
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
|
||||||
|
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
|
||||||
|
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
|
||||||
pillsLeft: "{count} Tabletten",
|
pillsLeft: "{count} Tabletten",
|
||||||
daysLeft: "{count} Tage übrig",
|
daysLeft: "{count} Tage übrig",
|
||||||
pillsAt: "{count} Tabletten um {time}",
|
pillsAt: "{count} Tabletten um {time}",
|
||||||
@@ -424,6 +434,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "Tablette",
|
pill: "Tablette",
|
||||||
pills: "Tabletten",
|
pills: "Tabletten",
|
||||||
|
puffs: "Hübe",
|
||||||
|
injections: "Injektionen",
|
||||||
units: "Einheiten",
|
units: "Einheiten",
|
||||||
ml: "ml",
|
ml: "ml",
|
||||||
blister: "Blister",
|
blister: "Blister",
|
||||||
|
|||||||
+63
-8
@@ -5,7 +5,6 @@ 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";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
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";
|
||||||
@@ -16,6 +15,7 @@ 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 { jwtPlugin } from "./plugins/jwt.js";
|
||||||
import { apiKeyRoutes } from "./routes/api-keys.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";
|
||||||
@@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js";
|
|||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||||
import { medicationRoutes } from "./routes/medications.js";
|
import { medicationRoutes } from "./routes/medications.js";
|
||||||
|
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||||
import { oidcRoutes } from "./routes/oidc.js";
|
import { oidcRoutes } from "./routes/oidc.js";
|
||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { refillRoutes } from "./routes/refills.js";
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
@@ -30,7 +31,7 @@ import { reportRoutes } from "./routes/report.js";
|
|||||||
import { settingsRoutes } from "./routes/settings.js";
|
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 { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js";
|
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment/index.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
@@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHelmetOptions(_isProduction: boolean) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicNotificationActionPath(url: string | undefined): boolean {
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
|
||||||
|
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
@@ -166,6 +180,7 @@ export async function createApp(options?: {
|
|||||||
app.addHook("onRequest", (request, reply, done) => {
|
app.addHook("onRequest", (request, reply, done) => {
|
||||||
request.correlationId = request.id;
|
request.correlationId = request.id;
|
||||||
reply.header("x-correlation-id", request.id);
|
reply.header("x-correlation-id", request.id);
|
||||||
|
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,14 +197,32 @@ export async function createApp(options?: {
|
|||||||
|
|
||||||
// Register plugins
|
// Register plugins
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(helmet);
|
await app.register(helmet, buildHelmetOptions(opts.isProduction));
|
||||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
await app.register(cors, {
|
||||||
|
hook: "preHandler",
|
||||||
|
delegator: (request, callback) => {
|
||||||
|
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||||
|
callback(null, {
|
||||||
|
origin: true,
|
||||||
|
credentials: false,
|
||||||
|
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||||
|
preflightContinue: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, {
|
||||||
|
origin: opts.corsOrigins,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||||
await app.register(cookie, { secret: opts.cookieSecret });
|
await app.register(cookie, { secret: opts.cookieSecret });
|
||||||
|
|
||||||
// JWT plugin
|
// JWT plugin
|
||||||
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
||||||
await app.register(jwt, jwtConfig);
|
await app.register(jwtPlugin, jwtConfig);
|
||||||
|
|
||||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||||
await registerApiDocs(app, opts.openApiDocsEnabled);
|
await registerApiDocs(app, opts.openApiDocsEnabled);
|
||||||
@@ -212,6 +245,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(medicationEnrichmentRoutes);
|
await app.register(medicationEnrichmentRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
|
await app.register(notificationActionRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
@@ -266,8 +300,26 @@ app.decorate("config", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
await app.register(helmet);
|
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
|
||||||
await app.register(cors, { origin: origins, credentials: true });
|
await app.register(cors, {
|
||||||
|
hook: "preHandler",
|
||||||
|
delegator: (request, callback) => {
|
||||||
|
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||||
|
callback(null, {
|
||||||
|
origin: true,
|
||||||
|
credentials: false,
|
||||||
|
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||||
|
preflightContinue: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, {
|
||||||
|
origin: origins,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
@@ -276,7 +328,7 @@ await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" })
|
|||||||
|
|
||||||
// JWT plugin - only register with valid secret if auth is enabled
|
// JWT plugin - only register with valid secret if auth is enabled
|
||||||
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
|
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
|
||||||
await app.register(jwt, jwtConfig);
|
await app.register(jwtPlugin, 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 registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
|
||||||
@@ -294,6 +346,7 @@ await app.register(medicationRoutes);
|
|||||||
await app.register(medicationEnrichmentRoutes);
|
await app.register(medicationEnrichmentRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
|
await app.register(notificationActionRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
@@ -309,6 +362,7 @@ const start = async () => {
|
|||||||
startReminderScheduler({
|
startReminderScheduler({
|
||||||
info: (msg) => app.log.info(msg),
|
info: (msg) => app.log.info(msg),
|
||||||
debug: (msg) => app.log.debug(msg),
|
debug: (msg) => app.log.debug(msg),
|
||||||
|
warn: (msg) => app.log.warn(msg),
|
||||||
error: (msg) => app.log.error(msg),
|
error: (msg) => app.log.error(msg),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,6 +377,7 @@ const start = async () => {
|
|||||||
startIntakeReminderScheduler({
|
startIntakeReminderScheduler({
|
||||||
info: (msg) => app.log.info(msg),
|
info: (msg) => app.log.info(msg),
|
||||||
debug: (msg) => app.log.debug(msg),
|
debug: (msg) => app.log.debug(msg),
|
||||||
|
warn: (msg) => app.log.warn(msg),
|
||||||
error: (msg) => app.log.error(msg),
|
error: (msg) => app.log.error(msg),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { apiKeys, users } from "../db/schema.js";
|
import { apiKeys, users } from "../db/schema.js";
|
||||||
|
import { log } from "../utils/logger.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -180,8 +181,14 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
|||||||
.select()
|
.select()
|
||||||
.from(apiKeys)
|
.from(apiKeys)
|
||||||
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||||
if (!keyRow) return;
|
if (!keyRow) {
|
||||||
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return;
|
log.debug("[Auth] optionalAuth API key verification failed: key not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
|
||||||
|
log.debug("[Auth] optionalAuth API key verification failed: key expired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||||
if (userByKey?.isActive) {
|
if (userByKey?.isActive) {
|
||||||
@@ -191,7 +198,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
|||||||
scope: keyRow.scope === "read" ? "read" : "write",
|
scope: keyRow.scope === "read" ? "read" : "write",
|
||||||
apiKeyId: keyRow.id,
|
apiKeyId: keyRow.id,
|
||||||
};
|
};
|
||||||
|
log.debug("[Auth] optionalAuth authenticated via API key");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
log.debug("[Auth] optionalAuth API key verification failed: user inactive or missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,9 +222,11 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
|||||||
method: "session",
|
method: "session",
|
||||||
scope: "write",
|
scope: "write",
|
||||||
};
|
};
|
||||||
|
log.debug("[Auth] optionalAuth authenticated via session token");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
// Invalid token, continue as anonymous
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
log.debug(`[Auth] optionalAuth session verification failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default("3000"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
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"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
OPENAPI_DOCS_ENABLED: z
|
OPENAPI_DOCS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
|
|||||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
// Allow new user registrations (auto-enabled if no users exist)
|
// Allow new user registrations (auto-enabled if no users exist)
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
// Disable username/password form login (useful for OIDC-only setups)
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
FORM_LOGIN_ENABLED: z
|
FORM_LOGIN_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "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(),
|
||||||
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
|
|||||||
// Token TTL settings
|
// Token TTL settings
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default("15"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default("7"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { TextEncoder } from "node:util";
|
||||||
|
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
|
||||||
|
import fastifyPlugin from "fastify-plugin";
|
||||||
|
import { SignJWT, jwtVerify as verifyJwt } from "jose";
|
||||||
|
|
||||||
|
const JWT_ALGORITHM = "HS256";
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
export interface JwtPluginOptions {
|
||||||
|
secret: string;
|
||||||
|
cookie: {
|
||||||
|
cookieName: string;
|
||||||
|
signed: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtSignOptions {
|
||||||
|
expiresIn?: string | number;
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtVerifyOptions {
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(secret: string): Uint8Array {
|
||||||
|
return encoder.encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenFromRequest(request: FastifyRequest, cookieName: string): string {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
if (authorization) {
|
||||||
|
const [scheme, rawToken] = authorization.split(" ");
|
||||||
|
if (scheme?.toLowerCase() === "bearer" && rawToken?.trim()) {
|
||||||
|
return rawToken.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = request.cookies?.[cookieName];
|
||||||
|
if (typeof token === "string" && token.length > 0) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("JWT token missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtPluginImpl: FastifyPluginAsync<JwtPluginOptions> = async (app, options) => {
|
||||||
|
const defaultKey = getKey(options.secret);
|
||||||
|
|
||||||
|
app.decorate("jwt", {
|
||||||
|
sign(payload: Record<string, unknown>, signOptions?: JwtSignOptions) {
|
||||||
|
const tokenBuilder = new SignJWT(payload).setProtectedHeader({ alg: JWT_ALGORITHM, typ: "JWT" }).setIssuedAt();
|
||||||
|
|
||||||
|
if (signOptions?.expiresIn != null) {
|
||||||
|
tokenBuilder.setExpirationTime(signOptions.expiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenBuilder.sign(getKey(signOptions?.key ?? options.secret));
|
||||||
|
},
|
||||||
|
|
||||||
|
async verify<T extends Record<string, unknown>>(token: string, verifyOptions?: JwtVerifyOptions): Promise<T> {
|
||||||
|
const { payload } = await verifyJwt(token, getKey(verifyOptions?.key ?? options.secret), {
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
typ: "JWT",
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorateRequest("jwtVerify", async function jwtVerify<
|
||||||
|
T extends Record<string, unknown>,
|
||||||
|
>(this: FastifyRequest, verifyOptions?: JwtVerifyOptions): Promise<T> {
|
||||||
|
const token = getTokenFromRequest(this, options.cookie.cookieName);
|
||||||
|
const { payload } = await verifyJwt(token, verifyOptions?.key ? getKey(verifyOptions.key) : defaultKey, {
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
typ: "JWT",
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jwtPlugin = fastifyPlugin(jwtPluginImpl, {
|
||||||
|
name: "medassist-jwt-plugin",
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { eq, sql } from "drizzle-orm";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/path-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";
|
||||||
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,7 +357,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
|
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
const accessToken = app.jwt.sign(
|
const accessToken = await app.jwt.sign(
|
||||||
{ sub: user.id, username: user.username },
|
{ sub: user.id, username: user.username },
|
||||||
{ expiresIn: `${accessTtlMinutes}m` }
|
{ expiresIn: `${accessTtlMinutes}m` }
|
||||||
);
|
);
|
||||||
@@ -371,7 +371,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
expiresAt: refreshExp,
|
expiresAt: refreshExp,
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = app.jwt.sign(
|
const refreshToken = await app.jwt.sign(
|
||||||
{ sub: user.id, jti: tokenId },
|
{ sub: user.id, jti: tokenId },
|
||||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||||
);
|
);
|
||||||
@@ -425,7 +425,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify refresh token
|
// Verify refresh token
|
||||||
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
|
const decoded = await app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
|
||||||
key: app.config.refreshSecret,
|
key: app.config.refreshSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,12 +458,12 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate new tokens
|
// Generate new tokens
|
||||||
const newAccessToken = app.jwt.sign(
|
const newAccessToken = await app.jwt.sign(
|
||||||
{ sub: user.id, username: user.username },
|
{ sub: user.id, username: user.username },
|
||||||
{ expiresIn: `${accessTtlMinutes}m` }
|
{ expiresIn: `${accessTtlMinutes}m` }
|
||||||
);
|
);
|
||||||
|
|
||||||
const newRefreshToken = app.jwt.sign(
|
const newRefreshToken = await app.jwt.sign(
|
||||||
{ sub: user.id, jti: newTokenId },
|
{ sub: user.id, jti: newTokenId },
|
||||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||||
);
|
);
|
||||||
@@ -498,7 +498,9 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (refreshTokenCookie) {
|
if (refreshTokenCookie) {
|
||||||
try {
|
try {
|
||||||
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
|
const decoded = await app.jwt.verify<{ jti: string }>(refreshTokenCookie, {
|
||||||
|
key: app.config.refreshSecret,
|
||||||
|
});
|
||||||
|
|
||||||
// Revoke the refresh token
|
// Revoke the refresh token
|
||||||
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
|
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
|
||||||
@@ -614,7 +616,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
const parsed = updateProfileSchema.safeParse(request.body);
|
const parsed = updateProfileSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-31
@@ -6,6 +6,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
|
|||||||
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 { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||||
|
import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
@@ -61,6 +62,15 @@ const doseReadResponseSchema = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function getValidationErrorMessage(error: z.ZodError): string {
|
||||||
|
const firstIssue = error.issues[0];
|
||||||
|
if (!firstIssue) {
|
||||||
|
return "Invalid input";
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
||||||
|
}
|
||||||
|
|
||||||
// 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> {
|
||||||
@@ -301,40 +311,28 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = markDoseSchema.safeParse(request.body);
|
const parsed = markDoseSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { doseId } = parsed.data;
|
const { doseId } = parsed.data;
|
||||||
|
|
||||||
// Check if already marked
|
const result = await markDoseTakenForUser({
|
||||||
const [existing] = await db
|
userId,
|
||||||
.select()
|
doseId,
|
||||||
.from(doseTracking)
|
source: "manual",
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (!result.success) {
|
||||||
|
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||||
|
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "already_taken") {
|
||||||
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
|
|
||||||
await db.insert(doseTracking).values({
|
|
||||||
userId,
|
|
||||||
doseId,
|
|
||||||
markedBy: null, // Marked by the user themselves
|
|
||||||
takenSource: "manual",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -423,23 +421,22 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { doseIds } = parsed.data;
|
const { doseIds } = parsed.data;
|
||||||
|
|
||||||
// Insert dismissed records for each dose that doesn't exist yet
|
// Preserve the existing route semantics for dismiss: any non-dismissed record
|
||||||
|
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
||||||
let dismissedCount = 0;
|
let dismissedCount = 0;
|
||||||
for (const doseId of doseIds) {
|
for (const doseId of doseIds) {
|
||||||
// Check if already exists (taken or dismissed)
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Already exists - update to dismissed if not already
|
|
||||||
if (!existing.dismissed) {
|
if (!existing.dismissed) {
|
||||||
await db
|
await db
|
||||||
.update(doseTracking)
|
.update(doseTracking)
|
||||||
@@ -448,7 +445,6 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
dismissedCount++;
|
dismissedCount++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new dismissed record
|
|
||||||
await db.insert(doseTracking).values({
|
await db.insert(doseTracking).values({
|
||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
@@ -590,7 +586,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = shareDoseSchema.safeParse(request.body);
|
const parsed = shareDoseSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { 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 { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/path-utils.js";
|
||||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, refillHistory, 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";
|
||||||
@@ -23,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.4";
|
const EXPORT_VERSION = "1.5";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -62,7 +62,7 @@ const medicationExportSchema = z.object({
|
|||||||
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
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", "injections"]).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(),
|
medicationEndDate: z.string().nullable().optional(),
|
||||||
@@ -96,7 +96,8 @@ const doseHistorySchema = z.object({
|
|||||||
const refillHistoryExportSchema = z.object({
|
const refillHistoryExportSchema = z.object({
|
||||||
medicationRef: z.string(), // References _exportId
|
medicationRef: z.string(), // References _exportId
|
||||||
packsAdded: z.number().int().min(0).default(0),
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
loosePillsAdded: z.number().int().min(0).default(0),
|
loosePillsAdded: z.number().int().min(0).optional(),
|
||||||
|
quantityAdded: z.number().int().min(0).optional(),
|
||||||
usedPrescription: z.boolean().default(false),
|
usedPrescription: z.boolean().default(false),
|
||||||
refillDate: z.string(), // ISO datetime
|
refillDate: z.string(), // ISO datetime
|
||||||
});
|
});
|
||||||
@@ -108,37 +109,44 @@ const shareLinkSchema = z.object({
|
|||||||
regenerateToken: z.boolean().default(true),
|
regenerateToken: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsExportSchema = z
|
const settingsSchemaBase = z.object({
|
||||||
.object({
|
// Email notifications
|
||||||
// Email notifications
|
emailEnabled: z.boolean().default(false),
|
||||||
emailEnabled: z.boolean().default(false),
|
notificationEmail: z.string().nullable().optional(),
|
||||||
notificationEmail: z.string().nullable().optional(),
|
emailStockReminders: z.boolean().default(true),
|
||||||
emailStockReminders: z.boolean().default(true),
|
emailIntakeReminders: z.boolean().default(true),
|
||||||
emailIntakeReminders: z.boolean().default(true),
|
emailPrescriptionReminders: z.boolean().default(true),
|
||||||
emailPrescriptionReminders: z.boolean().default(true),
|
// Push notifications
|
||||||
// Push notifications
|
shoutrrrEnabled: z.boolean().optional(),
|
||||||
shoutrrrEnabled: z.boolean().optional(),
|
shoutrrrUrl: z.string().nullable().optional(),
|
||||||
shoutrrrUrl: z.string().nullable().optional(),
|
shoutrrrStockReminders: z.boolean().default(true),
|
||||||
shoutrrrStockReminders: z.boolean().default(true),
|
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
||||||
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
// Reminder settings
|
||||||
// Reminder settings
|
reminderDaysBefore: z.number().int().default(7),
|
||||||
reminderDaysBefore: z.number().int().default(7),
|
repeatDailyReminders: z.boolean().default(false),
|
||||||
repeatDailyReminders: z.boolean().default(false),
|
skipRemindersForTakenDoses: z.boolean().default(false),
|
||||||
skipRemindersForTakenDoses: z.boolean().default(false),
|
repeatRemindersEnabled: z.boolean().default(false),
|
||||||
repeatRemindersEnabled: z.boolean().default(false),
|
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
||||||
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
maxNaggingReminders: z.number().int().default(5),
|
||||||
maxNaggingReminders: z.number().int().default(5),
|
// Stock thresholds
|
||||||
// Stock thresholds
|
lowStockDays: z.number().int().default(30),
|
||||||
lowStockDays: z.number().int().default(30),
|
normalStockDays: z.number().int().default(90),
|
||||||
normalStockDays: z.number().int().default(90),
|
highStockDays: z.number().int().default(180),
|
||||||
highStockDays: z.number().int().default(180),
|
expiryWarningDays: z.number().int().default(90),
|
||||||
expiryWarningDays: z.number().int().default(90),
|
// UI preferences
|
||||||
// UI preferences
|
language: z.string().default("en"),
|
||||||
language: z.string().default("en"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
shareMedicationOverview: z.boolean().default(false),
|
||||||
shareStockStatus: z.boolean().default(true),
|
});
|
||||||
shareMedicationOverview: z.boolean().default(false),
|
|
||||||
|
const exportSettingsSchema = settingsSchemaBase.optional();
|
||||||
|
|
||||||
|
const importSettingsSchema = settingsSchemaBase
|
||||||
|
.extend({
|
||||||
|
// Accept the removed field from legacy exports so old backups still import,
|
||||||
|
// but do not map it back into current runtime settings.
|
||||||
|
shareStockStatus: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -149,7 +157,7 @@ const importDataSchema = z.object({
|
|||||||
medications: z.array(medicationExportSchema).default([]),
|
medications: z.array(medicationExportSchema).default([]),
|
||||||
doseHistory: z.array(doseHistorySchema).default([]),
|
doseHistory: z.array(doseHistorySchema).default([]),
|
||||||
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||||
settings: settingsExportSchema,
|
settings: importSettingsSchema,
|
||||||
shareLinks: z.array(shareLinkSchema).default([]),
|
shareLinks: z.array(shareLinkSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +218,7 @@ const importBodyOpenApiSchema = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
||||||
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
|
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||||
},
|
},
|
||||||
@@ -370,6 +378,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// 1. Load all medications
|
// 1. Load all medications
|
||||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
|
const medicationById = new Map(meds.map((med) => [med.id, med]));
|
||||||
|
|
||||||
// Build medication ID to export ID mapping
|
// Build medication ID to export ID mapping
|
||||||
const medIdToExportId = new Map<number, string>();
|
const medIdToExportId = new Map<number, string>();
|
||||||
@@ -509,7 +518,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
expiryWarningDays: settings.expiryWarningDays,
|
expiryWarningDays: settings.expiryWarningDays,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode,
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
shareStockStatus: settings.shareStockStatus,
|
|
||||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -548,6 +556,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
.map((refill) => {
|
.map((refill) => {
|
||||||
const exportId = medIdToExportId.get(refill.medicationId);
|
const exportId = medIdToExportId.get(refill.medicationId);
|
||||||
if (!exportId) return null; // Orphaned refill, skip
|
if (!exportId) return null; // Orphaned refill, skip
|
||||||
|
const medication = medicationById.get(refill.medicationId);
|
||||||
|
const packageType = normalizePackageType(medication?.packageType);
|
||||||
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||||
|
const quantityAdded =
|
||||||
|
packageType === "bottle" ||
|
||||||
|
packageType === "inhaler" ||
|
||||||
|
packageType === "injection" ||
|
||||||
|
packageType === "tube" ||
|
||||||
|
packageType === "liquid_container"
|
||||||
|
? (refill.loosePillsAdded ?? 0)
|
||||||
|
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
|
||||||
|
|
||||||
// Safely convert refillDate to ISO string
|
// Safely convert refillDate to ISO string
|
||||||
let refillDateIso: string;
|
let refillDateIso: string;
|
||||||
@@ -568,6 +587,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
medicationRef: exportId,
|
medicationRef: exportId,
|
||||||
packsAdded: refill.packsAdded ?? 0,
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
quantityAdded,
|
||||||
usedPrescription: refill.usedPrescription ?? false,
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
refillDate: refillDateIso,
|
refillDate: refillDateIso,
|
||||||
};
|
};
|
||||||
@@ -778,6 +798,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// 5. Import settings
|
// 5. Import settings
|
||||||
if (importData.settings) {
|
if (importData.settings) {
|
||||||
|
// Legacy exports may still contain shareStockStatus. The current app no longer
|
||||||
|
// uses that setting, so imports accept it for compatibility and then ignore it.
|
||||||
await db.insert(userSettings).values({
|
await db.insert(userSettings).values({
|
||||||
userId,
|
userId,
|
||||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||||
@@ -802,7 +824,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||||
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,
|
|
||||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -830,7 +851,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
medicationId: newMedId,
|
medicationId: newMedId,
|
||||||
userId,
|
userId,
|
||||||
packsAdded: refill.packsAdded ?? 0,
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||||
usedPrescription: refill.usedPrescription ?? false,
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
refillDate: new Date(refill.refillDate),
|
refillDate: new Date(refill.refillDate),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type MedicationEnrichmentEnrichRequest,
|
type MedicationEnrichmentEnrichRequest,
|
||||||
MedicationEnrichmentServiceError,
|
MedicationEnrichmentServiceError,
|
||||||
searchMedicationEnrichment,
|
searchMedicationEnrichment,
|
||||||
} from "../services/medication-enrichment.js";
|
} from "../services/medication-enrichment/index.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
genericErrorSchema,
|
genericErrorSchema,
|
||||||
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
|
|||||||
label: { type: "string" },
|
label: { type: "string" },
|
||||||
pillWeightMg: { type: "number", nullable: true },
|
pillWeightMg: { type: "number", nullable: true },
|
||||||
doseUnit: {
|
doseUnit: {
|
||||||
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
anyOf: [
|
||||||
|
{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
|
||||||
|
{ type: "null" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -80,7 +83,7 @@ const packageOptionSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
label: { type: "string" },
|
label: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
|
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
|
||||||
packCount: { type: "integer", minimum: 1 },
|
packCount: { type: "integer", minimum: 1 },
|
||||||
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||||
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { and, eq, like } 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 { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/path-utils.js";
|
||||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, 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 { calculateUsageInRange, normalizeDateTime, parseIntakesWithUnits } from "../services/medications-service.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
ALLOWED_IMAGE_MIME_TYPES,
|
ALLOWED_IMAGE_MIME_TYPES,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
import {
|
import {
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
|
isDiscreteCountPackageType,
|
||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
normalizePackageType,
|
normalizePackageType,
|
||||||
@@ -37,70 +39,12 @@ import {
|
|||||||
type Intake,
|
type Intake,
|
||||||
normalizeIntake,
|
normalizeIntake,
|
||||||
normalizeIntakeUsageForStock,
|
normalizeIntakeUsageForStock,
|
||||||
parseIntakesJson,
|
|
||||||
parseLocalDateTime,
|
parseLocalDateTime,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
|
|
||||||
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
|
|
||||||
return value === "ml" || value === "tsp" || value === "tbsp";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
|
|
||||||
if (!intakesJson) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(intakesJson);
|
|
||||||
if (!Array.isArray(parsed)) return [];
|
|
||||||
return parsed.map((item: unknown) => {
|
|
||||||
if (!item || typeof item !== "object") return null;
|
|
||||||
const unit = (item as Record<string, unknown>).intakeUnit;
|
|
||||||
return isIntakeUnit(unit) ? unit : null;
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIntakesWithUnits(
|
|
||||||
intakesJson: string | null | undefined,
|
|
||||||
legacyRow: { usageJson: string; everyJson: string; startJson: string },
|
|
||||||
medicationIntakeRemindersEnabled?: boolean
|
|
||||||
): Intake[] {
|
|
||||||
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
|
|
||||||
const rawUnits = parseRawIntakeUnits(intakesJson);
|
|
||||||
if (rawUnits.length === 0) return intakes;
|
|
||||||
|
|
||||||
return intakes.map((intake, idx) => ({
|
|
||||||
...intake,
|
|
||||||
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New intake schema with per-intake takenBy
|
// New intake schema with per-intake takenBy
|
||||||
const intakeSchema = z.object({
|
const intakeSchema = z.object({
|
||||||
usage: z.number().nonnegative(),
|
usage: z.number().nonnegative(),
|
||||||
@@ -124,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
|
|||||||
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
||||||
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||||
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
||||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg");
|
||||||
const medicationStartDateSchema = z
|
const medicationStartDateSchema = z
|
||||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||||
.optional();
|
.optional();
|
||||||
@@ -321,7 +265,7 @@ const medicationBodyOpenApiSchema = {
|
|||||||
totalPills: { type: ["integer", "null"], minimum: 1 },
|
totalPills: { type: ["integer", "null"], minimum: 1 },
|
||||||
looseTablets: { type: "integer", minimum: 0 },
|
looseTablets: { type: "integer", minimum: 0 },
|
||||||
pillWeightMg: { type: ["number", "null"], minimum: 0 },
|
pillWeightMg: { type: ["number", "null"], minimum: 0 },
|
||||||
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] },
|
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
|
||||||
medicationStartDate: {
|
medicationStartDate: {
|
||||||
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
|
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
|
||||||
},
|
},
|
||||||
@@ -1258,17 +1202,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const packageType = normalizePackageType(existing.packageType);
|
const packageType = normalizePackageType(existing.packageType);
|
||||||
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
const allowsBottleCapacityUpdate = packageType === "bottle";
|
const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
|
||||||
if (allowsAmountBaseUpdate) {
|
if (allowsAmountBaseUpdate) {
|
||||||
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
const normalizedAmountBase = looseTablets ?? totalPills;
|
||||||
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
if (normalizedAmountBase !== undefined) {
|
||||||
|
updateFields.totalPills = normalizedAmountBase;
|
||||||
|
updateFields.looseTablets = normalizedAmountBase;
|
||||||
|
}
|
||||||
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||||
}
|
}
|
||||||
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
|
||||||
updateFields.totalPills = totalPills;
|
updateFields.totalPills = totalPills;
|
||||||
}
|
}
|
||||||
if (packCount !== undefined) updateFields.packCount = packCount;
|
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||||
if (looseTablets !== undefined) {
|
if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
|
||||||
updateFields.looseTablets = looseTablets;
|
updateFields.looseTablets = looseTablets;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1711,7 +1658,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const parsed = dismissUntilSchema.safeParse(req.body);
|
const parsed = dismissUntilSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" });
|
return reply.status(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
@@ -1765,21 +1712,3 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateUsageInRange(
|
|
||||||
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
|
|
||||||
start: Date,
|
|
||||||
end: Date
|
|
||||||
) {
|
|
||||||
if (end.getTime() <= start.getTime()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
blisters.forEach((blister) => {
|
|
||||||
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
|
|
||||||
total += blister.usage;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Number(total.toFixed(2));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import formbody from "@fastify/formbody";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
|
||||||
|
import { getTranslations, type Language } from "../i18n/translations.js";
|
||||||
|
import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
|
||||||
|
import {
|
||||||
|
getNotificationActionTokenRecord,
|
||||||
|
isNotificationActionExpired,
|
||||||
|
} from "../services/notification-actions-service.js";
|
||||||
|
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
|
||||||
|
import { sendPushNotification } from "../services/notifications/delivery.js";
|
||||||
|
import { sanitizeNotificationUrl } from "../services/settings-service.js";
|
||||||
|
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
action: z.enum(["taken", "skip", "dismiss"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type NotificationMutationAction = "taken" | "skip";
|
||||||
|
|
||||||
|
function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
|
||||||
|
if (action === "taken") {
|
||||||
|
return "taken";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "skip" || action === "dismiss") {
|
||||||
|
return "skip";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
|
||||||
|
const reminderFooterSeparator = "\n\n---\n";
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHtmlText(value: string): string {
|
||||||
|
return escapeHtml(value).replaceAll("\n", "<br />");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguage(language: string | null): Language {
|
||||||
|
return language === "de" ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wantsHtml(request: FastifyRequest): boolean {
|
||||||
|
return request.headers.accept?.includes("text/html") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPublicNotificationCorsHeaders(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: { header: (name: string, value: string) => unknown }
|
||||||
|
) {
|
||||||
|
const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
|
||||||
|
reply.header("access-control-allow-origin", requestOrigin);
|
||||||
|
reply.header("access-control-allow-methods", publicNotificationActionMethods);
|
||||||
|
reply.header("access-control-allow-headers", "content-type");
|
||||||
|
if (requestOrigin !== "*") {
|
||||||
|
reply.header("vary", "Origin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
|
||||||
|
if (resolvedAction === "taken") {
|
||||||
|
return {
|
||||||
|
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||||
|
bodyText:
|
||||||
|
language === "de"
|
||||||
|
? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
|
||||||
|
: "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
|
||||||
|
jsonMessage:
|
||||||
|
language === "de"
|
||||||
|
? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
|
||||||
|
: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||||
|
bodyText:
|
||||||
|
language === "de"
|
||||||
|
? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
|
||||||
|
: "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
|
||||||
|
jsonMessage:
|
||||||
|
language === "de"
|
||||||
|
? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
|
||||||
|
: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionRecordedText(language: Language, action: NotificationMutationAction) {
|
||||||
|
if (action === "taken") {
|
||||||
|
return {
|
||||||
|
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||||
|
bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||||
|
bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplacementReminderMessage(
|
||||||
|
language: Language,
|
||||||
|
action: NotificationMutationAction,
|
||||||
|
originalMessage: string
|
||||||
|
): string {
|
||||||
|
const tr = getTranslations(language);
|
||||||
|
const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation;
|
||||||
|
const separatorIndex = originalMessage.indexOf(reminderFooterSeparator);
|
||||||
|
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd();
|
||||||
|
const footer = originalMessage.slice(separatorIndex);
|
||||||
|
return `${beforeFooter}\n\n${confirmationLine}${footer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${originalMessage.trimEnd()}\n\n${confirmationLine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||||
|
const [settings] = await db
|
||||||
|
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||||
|
.from(userSettings)
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
|
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||||
|
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUrl = new URL(sanitized.url);
|
||||||
|
clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (sanitized.auth) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(clearUrl.toString(), {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||||
|
const normalizedSequenceId = sequenceId.trim();
|
||||||
|
if (normalizedSequenceId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settings] = await db
|
||||||
|
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||||
|
.from(userSettings)
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
|
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||||
|
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUrl = new URL(sanitized.url);
|
||||||
|
deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (sanitized.auth) {
|
||||||
|
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(deleteUrl.toString(), {
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceNtfyNotificationSequence(options: {
|
||||||
|
userId: number;
|
||||||
|
sequenceId: string;
|
||||||
|
language: Language;
|
||||||
|
title: string;
|
||||||
|
originalMessage: string;
|
||||||
|
action: NotificationMutationAction;
|
||||||
|
viewUrl: string | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const normalizedSequenceId = options.sequenceId.trim();
|
||||||
|
if (normalizedSequenceId.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settings] = await db
|
||||||
|
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||||
|
.from(userSettings)
|
||||||
|
.where(eq(userSettings.userId, options.userId));
|
||||||
|
|
||||||
|
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||||
|
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = getNotificationActionLabels(options.language);
|
||||||
|
const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage);
|
||||||
|
const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, {
|
||||||
|
actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined,
|
||||||
|
viewUrl: options.viewUrl ?? undefined,
|
||||||
|
clickUrl: options.viewUrl ?? undefined,
|
||||||
|
sequenceId: normalizedSequenceId,
|
||||||
|
tags: ["pill"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(options: {
|
||||||
|
language: Language;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
bodyTitle: string;
|
||||||
|
bodyText: string;
|
||||||
|
viewUrl: string | null;
|
||||||
|
actionButtons: Array<{ label: string; formAction?: string }>;
|
||||||
|
}): string {
|
||||||
|
const labels = getNotificationActionLabels(options.language);
|
||||||
|
const forms =
|
||||||
|
options.actionButtons.length > 0
|
||||||
|
? `<div class="actions">${options.actionButtons
|
||||||
|
.map((button) => {
|
||||||
|
const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
|
||||||
|
return `<form method="POST"${formAction}><button type="submit">${escapeHtml(button.label)}</button></form>`;
|
||||||
|
})
|
||||||
|
.join("")}</div>`
|
||||||
|
: "";
|
||||||
|
const viewLink = options.viewUrl
|
||||||
|
? `<p><a href="${escapeHtml(options.viewUrl)}">${escapeHtml(labels.view)}</a></p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="${options.language}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(options.bodyTitle)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f4f5f7; color: #1f2937; }
|
||||||
|
main { max-width: 640px; margin: 48px auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); }
|
||||||
|
h1 { margin-top: 0; font-size: 1.5rem; }
|
||||||
|
.card { padding: 16px; border-radius: 12px; background: #f9fafb; margin: 16px 0 24px; }
|
||||||
|
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
form { margin: 0; }
|
||||||
|
button, a { display: inline-flex; align-items: center; justify-content: center; min-width: 140px; border-radius: 10px; padding: 12px 16px; font: inherit; text-decoration: none; }
|
||||||
|
button { border: none; background: #0f766e; color: white; cursor: pointer; }
|
||||||
|
form:last-of-type button { background: #475569; }
|
||||||
|
a { background: #e2e8f0; color: #0f172a; }
|
||||||
|
p { line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapeHtml(options.bodyTitle)}</h1>
|
||||||
|
<p>${escapeHtml(options.bodyText)}</p>
|
||||||
|
<div class="card">
|
||||||
|
<strong>${escapeHtml(options.title)}</strong>
|
||||||
|
<p>${toHtmlText(options.message)}</p>
|
||||||
|
</div>
|
||||||
|
${forms}
|
||||||
|
${viewLink}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
|
||||||
|
const normalizedTokenAction = normalizeNotificationAction(tokenKind);
|
||||||
|
if (normalizedTokenAction) {
|
||||||
|
return normalizedTokenAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedQuery = querySchema.safeParse(request.query);
|
||||||
|
if (parsedQuery.success && parsedQuery.data.action) {
|
||||||
|
return normalizeNotificationAction(parsedQuery.data.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body;
|
||||||
|
if (body && typeof body === "object" && "action" in body) {
|
||||||
|
const actionValue = (body as { action?: unknown }).action;
|
||||||
|
if (typeof actionValue === "string") {
|
||||||
|
return normalizeNotificationAction(actionValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotificationActionLogContext(
|
||||||
|
record: Awaited<ReturnType<typeof getNotificationActionTokenRecord>> extends infer T ? Exclude<T, null> : never,
|
||||||
|
extra: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
groupId: record.group.id,
|
||||||
|
userId: record.group.userId,
|
||||||
|
tokenKind: record.token.kind,
|
||||||
|
doseCount: record.doseIds.length,
|
||||||
|
hasViewUrl: record.viewUrl !== null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
hasOrigin: typeof request.headers.origin === "string",
|
||||||
|
expectsHtml: wantsHtml(request),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notificationActionRoutes(app: FastifyInstance) {
|
||||||
|
await app.register(formbody);
|
||||||
|
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
|
||||||
|
|
||||||
|
app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
|
||||||
|
applyPublicNotificationCorsHeaders(request, reply);
|
||||||
|
return reply.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { token: string } }>(
|
||||||
|
"/notification-actions/:token",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
tags: ["notification-actions"],
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token"],
|
||||||
|
properties: { token: { type: "string", minLength: 1 } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
404: genericErrorSchema,
|
||||||
|
405: genericErrorSchema,
|
||||||
|
410: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
applyPublicNotificationCorsHeaders(request, reply);
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||||
|
if (!record) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationRequestLogContext(request),
|
||||||
|
"[NotificationActions] Unknown notification action token requested"
|
||||||
|
);
|
||||||
|
return reply.status(404).send({ error: "Notification action not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotificationActionExpired(record)) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record),
|
||||||
|
"[NotificationActions] Rejected expired notification action GET request"
|
||||||
|
);
|
||||||
|
return reply.status(410).send({ error: "Notification action has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record),
|
||||||
|
"[NotificationActions] Rejected direct GET for unresolved non-respond token"
|
||||||
|
);
|
||||||
|
return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = getLanguage(record.group.language ?? null);
|
||||||
|
const labels = getNotificationActionLabels(language);
|
||||||
|
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||||
|
let bodyTitle: string;
|
||||||
|
let bodyText: string;
|
||||||
|
let actionButtons: Array<{ label: string; formAction?: string }> = [];
|
||||||
|
|
||||||
|
if (resolvedAction) {
|
||||||
|
({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
|
||||||
|
} else {
|
||||||
|
if (record.token.kind === "taken") {
|
||||||
|
bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
|
||||||
|
bodyText =
|
||||||
|
language === "de"
|
||||||
|
? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
|
||||||
|
: "Confirm that this dose should be marked as taken.";
|
||||||
|
actionButtons = [{ label: labels.taken }];
|
||||||
|
} else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
|
||||||
|
bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
|
||||||
|
bodyText =
|
||||||
|
language === "de"
|
||||||
|
? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
|
||||||
|
: "Confirm that this intake should be marked as skipped.";
|
||||||
|
actionButtons = [{ label: labels.skip }];
|
||||||
|
} else {
|
||||||
|
bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
|
||||||
|
bodyText =
|
||||||
|
language === "de"
|
||||||
|
? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
|
||||||
|
: "Choose an action for this medication reminder.";
|
||||||
|
actionButtons = [
|
||||||
|
{ label: labels.taken, formAction: "?action=taken" },
|
||||||
|
{ label: labels.skip, formAction: "?action=skip" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.type("text/html; charset=utf-8").send(
|
||||||
|
renderPage({
|
||||||
|
language,
|
||||||
|
title: record.group.title,
|
||||||
|
message: record.group.message,
|
||||||
|
bodyTitle,
|
||||||
|
bodyText,
|
||||||
|
viewUrl: record.viewUrl,
|
||||||
|
actionButtons: resolvedAction ? [] : actionButtons,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { token: string } }>(
|
||||||
|
"/notification-actions/:token",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
tags: ["notification-actions"],
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token"],
|
||||||
|
properties: { token: { type: "string", minLength: 1 } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
400: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
409: genericErrorSchema,
|
||||||
|
410: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
applyPublicNotificationCorsHeaders(request, reply);
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||||
|
if (!record) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationRequestLogContext(request),
|
||||||
|
"[NotificationActions] Unknown notification action token requested"
|
||||||
|
);
|
||||||
|
return reply.status(404).send({ error: "Notification action not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotificationActionExpired(record)) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record),
|
||||||
|
"[NotificationActions] Rejected expired notification action POST request"
|
||||||
|
);
|
||||||
|
return reply.status(410).send({ error: "Notification action has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = parseRequestedAction(request, record.token.kind);
|
||||||
|
if (!action) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record),
|
||||||
|
"[NotificationActions] Missing or invalid action for notification action POST request"
|
||||||
|
);
|
||||||
|
return reply.status(400).send({ error: "Notification action is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = getLanguage(record.group.language ?? null);
|
||||||
|
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||||
|
if (resolvedAction) {
|
||||||
|
request.log.info(
|
||||||
|
buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
|
||||||
|
"[NotificationActions] Ignored notification action because it was already resolved"
|
||||||
|
);
|
||||||
|
const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
|
||||||
|
|
||||||
|
if (wantsHtml(request)) {
|
||||||
|
return reply.type("text/html; charset=utf-8").send(
|
||||||
|
renderPage({
|
||||||
|
language,
|
||||||
|
title: record.group.title,
|
||||||
|
message: record.group.message,
|
||||||
|
bodyTitle: alreadyProcessedText.bodyTitle,
|
||||||
|
bodyText: alreadyProcessedText.bodyText,
|
||||||
|
viewUrl: record.viewUrl,
|
||||||
|
actionButtons: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
action: resolvedAction,
|
||||||
|
alreadyProcessed: true,
|
||||||
|
message: alreadyProcessedText.jsonMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "taken") {
|
||||||
|
for (const [doseIndex, doseId] of record.doseIds.entries()) {
|
||||||
|
const result = await markDoseTakenForUser({
|
||||||
|
userId: record.group.userId,
|
||||||
|
doseId,
|
||||||
|
source: "notification",
|
||||||
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record, {
|
||||||
|
requestedAction: action,
|
||||||
|
failedDoseIndex: doseIndex,
|
||||||
|
code: result.code,
|
||||||
|
}),
|
||||||
|
"[NotificationActions] Failed to record taken notification action"
|
||||||
|
);
|
||||||
|
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||||
|
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(notificationActionGroups)
|
||||||
|
.set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(notificationActionGroups.id, record.group.id));
|
||||||
|
await db
|
||||||
|
.update(notificationActionTokens)
|
||||||
|
.set({ usedAt: new Date() })
|
||||||
|
.where(eq(notificationActionTokens.id, record.token.id));
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
buildNotificationActionLogContext(record, { requestedAction: action }),
|
||||||
|
"[NotificationActions] Recorded notification action"
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordedText = getActionRecordedText(language, action);
|
||||||
|
let replacedNtfyNotification = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
replacedNtfyNotification = await replaceNtfyNotificationSequence({
|
||||||
|
userId: record.group.userId,
|
||||||
|
sequenceId: record.group.sequenceId,
|
||||||
|
language,
|
||||||
|
title: record.group.title,
|
||||||
|
originalMessage: record.group.message,
|
||||||
|
action,
|
||||||
|
viewUrl: record.viewUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||||
|
"[NotificationActions] Failed to replace ntfy notification after resolved action"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacedNtfyNotification) {
|
||||||
|
try {
|
||||||
|
await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||||
|
"[NotificationActions] Failed to delete ntfy notification after resolved action"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||||
|
} catch (clearError) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
|
||||||
|
"[NotificationActions] Failed to clear ntfy notification after delete fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsHtml(request)) {
|
||||||
|
return reply.type("text/html; charset=utf-8").send(
|
||||||
|
renderPage({
|
||||||
|
language,
|
||||||
|
title: record.group.title,
|
||||||
|
message: record.group.message,
|
||||||
|
bodyTitle: recordedText.bodyTitle,
|
||||||
|
bodyText: recordedText.bodyText,
|
||||||
|
viewUrl: record.viewUrl,
|
||||||
|
actionButtons: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ success: true, action });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-11
@@ -119,7 +119,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
return reply.redirect(authUrl.href);
|
return reply.redirect(authUrl.href);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
request.log.error({ err }, "[OIDC] Login initialization failed");
|
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -151,25 +151,25 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Handle OIDC provider errors
|
// Handle OIDC provider errors
|
||||||
if (error) {
|
if (error) {
|
||||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
const sub = tokens.claims()?.sub;
|
const sub = tokens.claims()?.sub;
|
||||||
if (!sub) {
|
if (!sub) {
|
||||||
request.log.error("[OIDC] Missing sub claim in token response");
|
request.log.error("[OIDC] Missing sub claim in token response");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||||
"[OIDC] Missing required user info"
|
"[OIDC] Missing required user info"
|
||||||
);
|
);
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean cookies
|
// Clean cookies
|
||||||
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
return reply.redirect(getFrontendUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -312,7 +312,7 @@ async function findOrCreateOIDCUser(
|
|||||||
// JWT Token Generation (reused from auth.ts logic)
|
// JWT Token Generation (reused from auth.ts logic)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
|
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
|
||||||
return app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
|
return await app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateRefreshToken(
|
async function generateRefreshToken(
|
||||||
@@ -322,7 +322,7 @@ async function generateRefreshToken(
|
|||||||
const tokenId = randomBytes(32).toString("hex");
|
const tokenId = randomBytes(32).toString("hex");
|
||||||
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const refreshToken = app.jwt.sign(
|
const refreshToken = await app.jwt.sign(
|
||||||
{ sub: userId, jti: tokenId, type: "refresh" },
|
{ sub: userId, jti: tokenId, type: "refresh" },
|
||||||
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
||||||
);
|
);
|
||||||
|
|||||||
+61
-175
@@ -1,6 +1,5 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications } from "../db/schema.js";
|
import { medications } from "../db/schema.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +12,14 @@ import {
|
|||||||
} from "../i18n/translations.js";
|
} from "../i18n/translations.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 {
|
||||||
|
buildPrescriptionReminderPushNotification,
|
||||||
|
buildStockReminderPushNotification,
|
||||||
|
type PrescriptionReminderItem as SharedPrescriptionReminderItem,
|
||||||
|
type StockReminderItem as SharedStockReminderItem,
|
||||||
|
} from "../services/notifications/builders.js";
|
||||||
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
|
||||||
|
import { escapeHtml, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,56 +27,9 @@ import {
|
|||||||
genericErrorSchema,
|
genericErrorSchema,
|
||||||
validationErrorSchema,
|
validationErrorSchema,
|
||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
import {
|
import { isTubePackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
getPlannerUnitKind,
|
|
||||||
isAmountBasedPackageType,
|
|
||||||
isTubePackageType,
|
|
||||||
normalizePackageType,
|
|
||||||
} from "../utils/package-profiles.js";
|
|
||||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||||
|
|
||||||
// Escape HTML to prevent XSS in email templates
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
const htmlEscapes: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
};
|
|
||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
|
||||||
}
|
|
||||||
|
|
||||||
type 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.";
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlannerRow = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
@@ -83,17 +43,6 @@ type PlannerRow = {
|
|||||||
packageType?: string;
|
packageType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isContainerPackage(packageType?: string): boolean {
|
|
||||||
return isAmountBasedPackageType(packageType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
|
|
||||||
const unitKind = getPlannerUnitKind(packageType);
|
|
||||||
if (unitKind === "units") return tr.common.units;
|
|
||||||
if (unitKind === "ml") return tr.common.ml;
|
|
||||||
return tr.common.pills;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SendEmailBody = {
|
type SendEmailBody = {
|
||||||
email: string;
|
email: string;
|
||||||
from: string;
|
from: string;
|
||||||
@@ -105,6 +54,7 @@ type SendEmailBody = {
|
|||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
|
packageType?: string;
|
||||||
daysLeft: number | null;
|
daysLeft: number | null;
|
||||||
depletionDate: string | null;
|
depletionDate: string | null;
|
||||||
isCritical?: boolean;
|
isCritical?: boolean;
|
||||||
@@ -478,19 +428,9 @@ ${getFooterPlain(language)}`;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||||
@@ -498,9 +438,8 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Failed to send demand email");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
@@ -629,11 +568,10 @@ ${getFooterPlain(language)}`;
|
|||||||
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
||||||
.filter(([name]) => name.length > 0)
|
.filter(([name]) => name.length > 0)
|
||||||
);
|
);
|
||||||
const filteredLowStock = lowStock.filter((item) => {
|
const filteredLowStock = lowStock.flatMap((item) => {
|
||||||
const packageType = activeMedicationByName.get(item.name);
|
const packageType = activeMedicationByName.get(item.name);
|
||||||
if (!packageType) return false;
|
if (!packageType || isTubePackageType(packageType)) return [];
|
||||||
if (isTubePackageType(packageType)) return false;
|
return [{ ...item, packageType }];
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
if (filteredLowStock.length === 0) {
|
if (filteredLowStock.length === 0) {
|
||||||
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||||
@@ -682,7 +620,6 @@ ${getFooterPlain(language)}`;
|
|||||||
if (lowStockMeds.length > 0) {
|
if (lowStockMeds.length > 0) {
|
||||||
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||||
}
|
}
|
||||||
const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
|
||||||
|
|
||||||
// Build description text
|
// Build description text
|
||||||
let descriptionText: string;
|
let descriptionText: string;
|
||||||
@@ -707,7 +644,7 @@ ${getFooterPlain(language)}`;
|
|||||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||||
criticalMeds.forEach((r) =>
|
criticalMeds.forEach((r) =>
|
||||||
messageParts.push(
|
messageParts.push(
|
||||||
` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -716,35 +653,30 @@ ${getFooterPlain(language)}`;
|
|||||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||||
lowStockMeds.forEach((r) =>
|
lowStockMeds.forEach((r) =>
|
||||||
messageParts.push(
|
messageParts.push(
|
||||||
` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email if enabled
|
// Send email if enabled
|
||||||
if (notificationSettings.emailEnabled && email) {
|
if (notificationSettings.emailEnabled && email) {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
hasSmtpPass: Boolean(smtpPass),
|
hasSmtpPass: Boolean(smtp.pass),
|
||||||
smtpPort,
|
smtpPort: smtp.port,
|
||||||
smtpSecure,
|
smtpSecure: smtp.secure,
|
||||||
hasSmtpFrom: Boolean(smtpFrom),
|
hasSmtpFrom: Boolean(smtp.from),
|
||||||
recipientEmail: email,
|
recipientEmail: email,
|
||||||
},
|
},
|
||||||
"[ReminderManual] Stock email path selected"
|
"[ReminderManual] Stock email path selected"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtp.host && smtp.user) {
|
||||||
// Build subject line from shared title parts
|
// Build subject line from shared title parts
|
||||||
const subjectText = titleParts.join(", ");
|
const subjectText = titleParts.join(", ");
|
||||||
|
|
||||||
@@ -802,12 +734,13 @@ ${getFooterPlain(language)}`;
|
|||||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
const safeName = escapeHtml(row.name);
|
const safeName = escapeHtml(row.name);
|
||||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||||
|
const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
|
||||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||||
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
|
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${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>${safeMedsLeft}</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>${safeQuantity}</strong></td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -847,29 +780,18 @@ ${getFooterPlain(language)}`;
|
|||||||
const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email");
|
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
|
||||||
to: email,
|
to: email,
|
||||||
subject: `MedAssist-ng: ${subjectText}`,
|
subject: `MedAssist-ng: ${subjectText}`,
|
||||||
text: plainText,
|
text: plainText,
|
||||||
html,
|
html,
|
||||||
|
from: smtp.from,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Unknown error");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
@@ -886,8 +808,8 @@ ${getFooterPlain(language)}`;
|
|||||||
request.log.warn(
|
request.log.warn(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
recipientEmail: email,
|
recipientEmail: email,
|
||||||
},
|
},
|
||||||
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
||||||
@@ -902,13 +824,13 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
const pushPayload = buildStockReminderPushNotification(filteredLowStock as SharedStockReminderItem[], language);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(
|
const pushResult = await sendPushNotification(
|
||||||
notificationSettings.shoutrrrUrl,
|
notificationSettings.shoutrrrUrl,
|
||||||
notificationTitle,
|
pushPayload.title,
|
||||||
message
|
pushPayload.message
|
||||||
);
|
);
|
||||||
if (pushResult.success) {
|
if (pushResult.success) {
|
||||||
results.push = true;
|
results.push = true;
|
||||||
@@ -1046,39 +968,24 @@ ${getFooterPlain(language)}`;
|
|||||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||||
|
|
||||||
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
|
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
hasSmtpPass: Boolean(smtpPass),
|
hasSmtpPass: Boolean(smtp.pass),
|
||||||
smtpPort,
|
smtpPort: smtp.port,
|
||||||
smtpSecure,
|
smtpSecure: smtp.secure,
|
||||||
hasSmtpFrom: Boolean(smtpFrom),
|
hasSmtpFrom: Boolean(smtp.from),
|
||||||
recipientEmail: email,
|
recipientEmail: email,
|
||||||
},
|
},
|
||||||
"[ReminderManual] Prescription email path selected"
|
"[ReminderManual] Prescription email path selected"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtp.host && smtp.user) {
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const subject =
|
const subject =
|
||||||
filteredPrescriptionLow.length === 1
|
filteredPrescriptionLow.length === 1
|
||||||
? tr.prescriptionReminder.subjectSingle
|
? tr.prescriptionReminder.subjectSingle
|
||||||
@@ -1152,17 +1059,16 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
|
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
from: smtp.from,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Unknown error");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
@@ -1182,8 +1088,8 @@ ${getFooterPlain(language)}`;
|
|||||||
request.log.warn(
|
request.log.warn(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
recipientEmail: email,
|
recipientEmail: email,
|
||||||
},
|
},
|
||||||
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
||||||
@@ -1201,37 +1107,17 @@ ${getFooterPlain(language)}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
||||||
const titleParts: string[] = [];
|
const pushPayload = buildPrescriptionReminderPushNotification(
|
||||||
if (emptyRx.length > 0)
|
filteredPrescriptionLow as SharedPrescriptionReminderItem[],
|
||||||
titleParts.push(
|
language
|
||||||
`🚨 ${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)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
const pushResult = await sendPushNotification(
|
||||||
|
userSettings.shoutrrrUrl,
|
||||||
|
pushPayload.title,
|
||||||
|
pushPayload.message
|
||||||
|
);
|
||||||
if (pushResult.success) {
|
if (pushResult.success) {
|
||||||
results.push = true;
|
results.push = true;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,16 +12,22 @@ import {
|
|||||||
idParamsSchema,
|
idParamsSchema,
|
||||||
validationErrorSchema,
|
validationErrorSchema,
|
||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isDiscreteCountPackageType,
|
||||||
|
isPackageAmountPackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
|
|
||||||
const refillSchema = z
|
const refillSchema = z
|
||||||
.object({
|
.object({
|
||||||
packsAdded: z.number().int().min(0).default(0),
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
loosePillsAdded: z.number().int().min(0).default(0),
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
quantityAdded: z.number().int().min(0).default(0),
|
||||||
usePrescription: z.boolean().default(false),
|
usePrescription: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
|
||||||
message: "Must add at least one pack or some loose pills",
|
message: "Must add at least one pack or some quantity",
|
||||||
});
|
});
|
||||||
|
|
||||||
const refillBodyOpenApiSchema = {
|
const refillBodyOpenApiSchema = {
|
||||||
@@ -29,12 +35,14 @@ const refillBodyOpenApiSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer", minimum: 0, default: 0 },
|
packsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
|
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
|
quantityAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
usePrescription: { type: "boolean", default: false },
|
usePrescription: { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
description: "Provide at least one pack or some loose pills.",
|
description: "Provide at least one pack or some quantity.",
|
||||||
example: {
|
example: {
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 4,
|
loosePillsAdded: 4,
|
||||||
|
quantityAdded: 4,
|
||||||
usePrescription: true,
|
usePrescription: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -49,6 +57,7 @@ const refillResponseSchema = {
|
|||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "number" },
|
||||||
totalPillsAdded: { type: "number" },
|
totalPillsAdded: { type: "number" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -80,6 +89,7 @@ const refillHistoryItemSchema = {
|
|||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "number" },
|
||||||
totalPillsAdded: { type: "number" },
|
totalPillsAdded: { type: "number" },
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
@@ -136,11 +146,12 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
.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, quantityAdded, usePrescription } = parsed.data;
|
||||||
const packageType = normalizePackageType(med.packageType);
|
const packageType = normalizePackageType(med.packageType);
|
||||||
const isBottle = packageType === "bottle";
|
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||||
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
const isPackageAmountPackage = isPackageAmountPackageType(packageType);
|
||||||
|
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
|
||||||
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
||||||
const fallbackAmountPerPackage = Math.max(
|
const fallbackAmountPerPackage = Math.max(
|
||||||
@@ -153,19 +164,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
: fallbackAmountPerPackage;
|
: fallbackAmountPerPackage;
|
||||||
|
|
||||||
const requestedPackAdds = Math.max(0, packsAdded);
|
const requestedPackAdds = Math.max(0, packsAdded);
|
||||||
const requestedAmountAdds = Math.max(0, loosePillsAdded);
|
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||||
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||||
|
|
||||||
let effectivePacksAdded = requestedPackAdds;
|
let effectivePacksAdded = requestedPackAdds;
|
||||||
if (isBottle) {
|
if (isDiscreteCountPackage) {
|
||||||
effectivePacksAdded = 0;
|
effectivePacksAdded = 0;
|
||||||
} else if (isCountBasedAmountPackage) {
|
|
||||||
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
|
|
||||||
}
|
}
|
||||||
const effectiveLoosePillsAdded = isCountBasedAmountPackage
|
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||||
? effectivePacksAdded * amountPerPackage
|
|
||||||
: requestedAmountAdds;
|
|
||||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||||
|
const totalPillsAdded = isAmountBased
|
||||||
|
? effectiveLoosePillsAdded
|
||||||
|
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||||
@@ -178,29 +187,50 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
if (remainingPrescriptionRefills <= 0) {
|
if (remainingPrescriptionRefills <= 0) {
|
||||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||||
}
|
}
|
||||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update medication stock
|
const refillBaselineAt = new Date();
|
||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const baselineStockBeforeRefill = isAmountBased
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
const previousAmountBase = med.totalPills ?? med.looseTablets;
|
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
|
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||||
|
|
||||||
|
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||||
|
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||||
|
let newPackCount = med.packCount + effectivePacksAdded;
|
||||||
|
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||||
|
let newTotalAmount = med.totalPills ?? med.looseTablets;
|
||||||
|
|
||||||
|
if (isDiscreteCountPackage) {
|
||||||
|
newLooseTablets = targetCurrentStock;
|
||||||
|
newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
|
||||||
|
newStockAdjustment = 0;
|
||||||
|
} else if (isPackageAmountPackage) {
|
||||||
|
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
|
||||||
|
newLooseTablets = targetCurrentStock;
|
||||||
|
newTotalAmount = targetCurrentStock;
|
||||||
|
newStockAdjustment = 0;
|
||||||
|
} else {
|
||||||
|
const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
|
||||||
|
newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
|
||||||
|
}
|
||||||
|
|
||||||
let consumedRefills = 0;
|
let consumedRefills = 0;
|
||||||
if (usePrescription) {
|
if (usePrescription) {
|
||||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
|
||||||
}
|
}
|
||||||
const newRemainingRefills = usePrescription
|
const newRemainingRefills = usePrescription
|
||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
const refillBaselineAt = new Date();
|
|
||||||
const updatePayload: {
|
const updatePayload: {
|
||||||
packCount: number;
|
packCount: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
|
stockAdjustment: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
packageAmountValue?: number;
|
packageAmountValue?: number;
|
||||||
prescriptionRemainingRefills: number | null;
|
prescriptionRemainingRefills: number | null;
|
||||||
@@ -209,12 +239,13 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
} = {
|
} = {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
|
stockAdjustment: newStockAdjustment,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
lastStockCorrectionAt: refillBaselineAt,
|
lastStockCorrectionAt: refillBaselineAt,
|
||||||
updatedAt: refillBaselineAt,
|
updatedAt: refillBaselineAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCountBasedAmountPackage) {
|
if (isPackageAmountPackage) {
|
||||||
updatePayload.totalPills = newTotalAmount;
|
updatePayload.totalPills = newTotalAmount;
|
||||||
updatePayload.packageAmountValue = amountPerPackage;
|
updatePayload.packageAmountValue = amountPerPackage;
|
||||||
}
|
}
|
||||||
@@ -236,31 +267,20 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Calculate pills added for response (packageType-aware)
|
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
|
||||||
const totalPillsAdded = isAmountBased
|
|
||||||
? effectiveLoosePillsAdded
|
|
||||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
|
||||||
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
if (isCountBasedAmountPackage) {
|
|
||||||
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
|
|
||||||
} else if (isBottle) {
|
|
||||||
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
refill: {
|
refill: {
|
||||||
id: refill.id,
|
id: refill.id,
|
||||||
packsAdded: effectivePacksAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded: effectiveLoosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
|
quantityAdded: totalPillsAdded,
|
||||||
totalPillsAdded,
|
totalPillsAdded,
|
||||||
refillDate: refill.refillDate,
|
refillDate: refill.refillDate,
|
||||||
},
|
},
|
||||||
newStock: {
|
newStock: {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
totalPills: newTotalPills,
|
totalPills: targetCurrentStock,
|
||||||
},
|
},
|
||||||
prescription: {
|
prescription: {
|
||||||
used: usePrescription,
|
used: usePrescription,
|
||||||
@@ -308,14 +328,15 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
.orderBy(desc(refillHistory.refillDate));
|
.orderBy(desc(refillHistory.refillDate));
|
||||||
|
|
||||||
const packageType = normalizePackageType(med.packageType);
|
const packageType = normalizePackageType(med.packageType);
|
||||||
const isBottle = packageType === "bottle";
|
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
const pillsPerPack = isDiscreteCountPackage ? 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,
|
||||||
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
totalPillsAdded: isAmountBased ? 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,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
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),
|
||||||
|
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reportDataBodyOpenApiSchema = {
|
const reportDataBodyOpenApiSchema = {
|
||||||
@@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = {
|
|||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: { type: "integer", minimum: 1 },
|
items: { type: "integer", minimum: 1 },
|
||||||
},
|
},
|
||||||
|
takenByFilter: {
|
||||||
|
type: "array",
|
||||||
|
maxItems: 50,
|
||||||
|
items: { type: "string", minLength: 1, maxLength: 100 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
medicationIds: [1, 3, 5],
|
medicationIds: [1, 3, 5],
|
||||||
|
takenByFilter: ["Daniel"],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
||||||
|
if (!takenByFilter) return true;
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 4) return false;
|
||||||
|
const takenBy = parts.at(-1)?.trim();
|
||||||
|
if (!takenBy) return false;
|
||||||
|
return takenByFilter.has(takenBy);
|
||||||
|
}
|
||||||
|
|
||||||
const reportDataResponseSchema = {
|
const reportDataResponseSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
@@ -39,7 +55,7 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
dosesTaken: { type: "integer" },
|
dosesTaken: { type: "integer" },
|
||||||
automaticDosesTaken: { type: "integer" },
|
automaticDosesTaken: { type: "integer" },
|
||||||
dosesDismissed: { type: "integer" },
|
dosesSkipped: { type: "integer" },
|
||||||
firstDoseAt: { type: "string" },
|
firstDoseAt: { type: "string" },
|
||||||
lastDoseAt: { type: "string" },
|
lastDoseAt: { type: "string" },
|
||||||
refills: {
|
refills: {
|
||||||
@@ -49,6 +65,7 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "integer" },
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -93,10 +110,22 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
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, takenByFilter } = parsed.data;
|
||||||
|
const normalizedTakenByFilter = takenByFilter?.length
|
||||||
|
? new Set(takenByFilter.map((value) => value.trim()))
|
||||||
|
: null;
|
||||||
|
|
||||||
// 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,
|
||||||
|
packageType: medications.packageType,
|
||||||
|
blistersPerPack: medications.blistersPerPack,
|
||||||
|
pillsPerBlister: medications.pillsPerBlister,
|
||||||
|
})
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, userId));
|
||||||
|
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
||||||
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) {
|
||||||
@@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
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 (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({
|
dosesByMed.get(medId)!.push({
|
||||||
takenAt: dose.takenAt,
|
takenAt: dose.takenAt,
|
||||||
@@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
automaticDosesTaken: number;
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesSkipped: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
refills: {
|
||||||
|
packsAdded: number;
|
||||||
|
loosePillsAdded: number;
|
||||||
|
quantityAdded: number;
|
||||||
|
usedPrescription: boolean;
|
||||||
|
refillDate: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -147,9 +183,12 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const doses = dosesByMed.get(medId) ?? [];
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
|
const medication = medMap.get(medId);
|
||||||
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||||
|
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||||
|
|
||||||
// Get refills for this medication scoped to the authenticated user.
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
const refills = await db
|
const refills = await db
|
||||||
@@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
result[medId] = {
|
result[medId] = {
|
||||||
dosesTaken: takenDoses.length,
|
dosesTaken: takenDoses.length,
|
||||||
automaticDosesTaken: automaticTakenDoses.length,
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
dosesDismissed: dismissedDoses.length,
|
dosesSkipped: skippedDoses.length,
|
||||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
refills: refills.map((r) => ({
|
refills: refills.map((r) => ({
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
})),
|
})),
|
||||||
|
|||||||
+110
-379
@@ -1,55 +1,37 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
import type { Language } from "../i18n/translations.js";
|
import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.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 {
|
||||||
|
createTestNotificationActionContext,
|
||||||
|
storeNotificationActionGroupNtfyMessageId,
|
||||||
|
} from "../services/notification-actions-service.js";
|
||||||
|
import {
|
||||||
|
type PushNotificationOptions,
|
||||||
|
renderNotificationActionPayload,
|
||||||
|
} from "../services/notifications/action-renderer.js";
|
||||||
|
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
||||||
|
import {
|
||||||
|
classifyTestEmailFailure,
|
||||||
|
getAllUserSettingsFromDb,
|
||||||
|
getAvailableTimezones,
|
||||||
|
getDefaultSettings,
|
||||||
|
getNotificationProvider,
|
||||||
|
loadUserSettingsFromDb,
|
||||||
|
normalizeSettingsTimezone,
|
||||||
|
sanitizeNotificationUrl,
|
||||||
|
type UserSettings,
|
||||||
|
validateNotificationHostname,
|
||||||
|
} from "../services/settings-service.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
// Exported type for use in schedulers
|
export type { UserSettings } from "../services/settings-service.js";
|
||||||
export type UserSettings = {
|
|
||||||
userId: number;
|
|
||||||
emailEnabled: boolean;
|
|
||||||
notificationEmail: string | null;
|
|
||||||
emailStockReminders: boolean;
|
|
||||||
emailIntakeReminders: boolean;
|
|
||||||
emailPrescriptionReminders: boolean;
|
|
||||||
shoutrrrEnabled: boolean;
|
|
||||||
shoutrrrUrl: string | null;
|
|
||||||
shoutrrrStockReminders: boolean;
|
|
||||||
shoutrrrIntakeReminders: boolean;
|
|
||||||
shoutrrrPrescriptionReminders: boolean;
|
|
||||||
reminderDaysBefore: number;
|
|
||||||
repeatDailyReminders: boolean;
|
|
||||||
skipRemindersForTakenDoses: boolean;
|
|
||||||
repeatRemindersEnabled: boolean;
|
|
||||||
reminderRepeatIntervalMinutes: number;
|
|
||||||
maxNaggingReminders: number;
|
|
||||||
lowStockDays: number;
|
|
||||||
normalStockDays: number;
|
|
||||||
highStockDays: number;
|
|
||||||
language: Language;
|
|
||||||
stockCalculationMode: "automatic" | "manual";
|
|
||||||
shareMedicationOverview: boolean;
|
|
||||||
upcomingTodayOnly: boolean;
|
|
||||||
shareScheduleTodayOnly: boolean;
|
|
||||||
swapDashboardMainSections: boolean;
|
|
||||||
lastAutoEmailSent: string | null;
|
|
||||||
lastNotificationType: string | null;
|
|
||||||
lastNotificationChannel: string | null;
|
|
||||||
lastReminderMedName: string | null;
|
|
||||||
lastReminderTakenBy: string | null;
|
|
||||||
lastStockReminderSent: string | null;
|
|
||||||
lastStockReminderChannel: string | null;
|
|
||||||
lastStockReminderMedNames: string | null;
|
|
||||||
lastPrescriptionReminderSent: string | null;
|
|
||||||
lastPrescriptionReminderChannel: string | null;
|
|
||||||
lastPrescriptionReminderMedNames: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SettingsBody = {
|
type SettingsBody = {
|
||||||
|
timezone: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string;
|
notificationEmail: string;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
@@ -97,91 +79,6 @@ const settingsErrorSchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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 classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
const normalizedMessage = errorMessage.toLowerCase();
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalizedMessage.includes("smtp rejected all recipients") ||
|
|
||||||
normalizedMessage.includes("all recipients were rejected") ||
|
|
||||||
normalizedMessage.includes("recipient address rejected") ||
|
|
||||||
normalizedMessage.includes("nullmx")
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
code: "EMAIL_RECIPIENT_REJECTED",
|
|
||||||
message: `Failed to send email: ${errorMessage}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
|
|
||||||
return {
|
|
||||||
status: 502,
|
|
||||||
code: "SMTP_DELIVERY_UNCONFIRMED",
|
|
||||||
message: `Failed to send email: ${errorMessage}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 500,
|
|
||||||
code: "TEST_EMAIL_FAILED",
|
|
||||||
message: `Failed to send email: ${errorMessage}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNotificationProvider(url: string): string {
|
|
||||||
if (url.startsWith("discord://")) return "discord";
|
|
||||||
if (url.startsWith("telegram://")) return "telegram";
|
|
||||||
if (url.startsWith("gotify://")) return "gotify";
|
|
||||||
if (url.startsWith("pushover://")) return "pushover";
|
|
||||||
if (url.startsWith("ntfy://")) return "ntfy";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.hostname || "https";
|
|
||||||
} catch {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to parse boolean env vars
|
|
||||||
function envBool(key: string, defaultVal: boolean): boolean {
|
|
||||||
const val = process.env[key];
|
|
||||||
if (val === undefined) return defaultVal;
|
|
||||||
return val === "true" || val === "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to parse integer env vars
|
|
||||||
function envInt(key: string, defaultVal: number): number {
|
function envInt(key: string, defaultVal: number): number {
|
||||||
const val = process.env[key];
|
const val = process.env[key];
|
||||||
if (val === undefined) return defaultVal;
|
if (val === undefined) return defaultVal;
|
||||||
@@ -189,54 +86,28 @@ function envInt(key: string, defaultVal: number): number {
|
|||||||
return Number.isNaN(parsed) ? defaultVal : parsed;
|
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default settings for new users - read from ENV with fallbacks
|
function getLanguage(language: string | null | undefined): Language {
|
||||||
function getDefaultSettings() {
|
return language === "de" ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } {
|
||||||
|
const tr = getTranslations(language);
|
||||||
|
const reminderAt = new Date(Date.now() + 60 * 1000);
|
||||||
|
const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(reminderAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
title: t(tr.push.intakeTitle, { minutes: 1 }),
|
||||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
|
||||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
|
||||||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
|
||||||
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
|
|
||||||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
|
||||||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
|
||||||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
|
||||||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
|
||||||
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
|
|
||||||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
|
||||||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
|
||||||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
|
||||||
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
|
||||||
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
|
||||||
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
|
||||||
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
|
||||||
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
|
||||||
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
|
||||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
|
||||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
|
||||||
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
|
|
||||||
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
|
||||||
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
|
||||||
swapDashboardMainSections: false,
|
|
||||||
lastAutoEmailSent: null,
|
|
||||||
lastNotificationType: null,
|
|
||||||
lastNotificationChannel: null,
|
|
||||||
lastReminderMedName: null,
|
|
||||||
lastReminderTakenBy: null,
|
|
||||||
lastStockReminderSent: null,
|
|
||||||
lastStockReminderChannel: null,
|
|
||||||
lastStockReminderMedNames: null,
|
|
||||||
lastPrescriptionReminderSent: null,
|
|
||||||
lastPrescriptionReminderChannel: null,
|
|
||||||
lastPrescriptionReminderMedNames: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get or create user settings
|
|
||||||
async function getOrCreateUserSettings(userId: number) {
|
async function getOrCreateUserSettings(userId: number) {
|
||||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Create default settings for user (using ENV defaults)
|
|
||||||
[settings] = await db
|
[settings] = await db
|
||||||
.insert(userSettings)
|
.insert(userSettings)
|
||||||
.values({
|
.values({
|
||||||
@@ -251,90 +122,12 @@ async function getOrCreateUserSettings(userId: number) {
|
|||||||
|
|
||||||
// Export for use in reminder scheduler
|
// Export for use in reminder scheduler
|
||||||
export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||||
const settings = await getOrCreateUserSettings(userId);
|
return loadUserSettingsFromDb(userId);
|
||||||
return {
|
|
||||||
userId: settings.userId,
|
|
||||||
emailEnabled: settings.emailEnabled,
|
|
||||||
notificationEmail: settings.notificationEmail,
|
|
||||||
emailStockReminders: settings.emailStockReminders,
|
|
||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
|
||||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
|
||||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
|
||||||
shoutrrrUrl: settings.shoutrrrUrl,
|
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
|
||||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
|
||||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
|
||||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
|
||||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
|
||||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
|
||||||
lowStockDays: settings.lowStockDays,
|
|
||||||
normalStockDays: settings.normalStockDays,
|
|
||||||
highStockDays: settings.highStockDays,
|
|
||||||
language: settings.language as Language,
|
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
|
||||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
|
||||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
|
||||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
|
||||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
|
||||||
lastNotificationType: settings.lastNotificationType,
|
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
|
||||||
lastReminderMedName: settings.lastReminderMedName ?? null,
|
|
||||||
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
|
||||||
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
|
||||||
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
|
||||||
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
|
||||||
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
|
||||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
|
||||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all users with settings for scheduler
|
// Get all users with settings for scheduler
|
||||||
export async function getAllUserSettings(): Promise<UserSettings[]> {
|
export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||||
const allSettings = await db.select().from(userSettings);
|
return getAllUserSettingsFromDb();
|
||||||
return allSettings.map((settings) => ({
|
|
||||||
userId: settings.userId,
|
|
||||||
emailEnabled: settings.emailEnabled,
|
|
||||||
notificationEmail: settings.notificationEmail,
|
|
||||||
emailStockReminders: settings.emailStockReminders,
|
|
||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
|
||||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
|
||||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
|
||||||
shoutrrrUrl: settings.shoutrrrUrl,
|
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
|
||||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
|
||||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
|
||||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
|
||||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
|
||||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
|
||||||
lowStockDays: settings.lowStockDays,
|
|
||||||
normalStockDays: settings.normalStockDays,
|
|
||||||
highStockDays: settings.highStockDays,
|
|
||||||
language: settings.language as Language,
|
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
|
||||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
|
||||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
|
||||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
|
||||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
|
||||||
lastNotificationType: settings.lastNotificationType,
|
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
|
||||||
lastReminderMedName: settings.lastReminderMedName ?? null,
|
|
||||||
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
|
||||||
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
|
||||||
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
|
||||||
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
|
||||||
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
|
||||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
|
||||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
@@ -381,6 +174,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
|
timezone: settings.timezone ?? "",
|
||||||
|
availableTimezones: getAvailableTimezones(),
|
||||||
|
serverTimezone: process.env.TZ || "UTC",
|
||||||
// User notification settings (from DB)
|
// User notification settings (from DB)
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail ?? "",
|
notificationEmail: settings.notificationEmail ?? "",
|
||||||
@@ -448,6 +244,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
||||||
properties: {
|
properties: {
|
||||||
|
timezone: { type: "string" },
|
||||||
emailEnabled: { type: "boolean" },
|
emailEnabled: { type: "boolean" },
|
||||||
notificationEmail: { type: "string" },
|
notificationEmail: { type: "string" },
|
||||||
reminderDaysBefore: { type: "number" },
|
reminderDaysBefore: { type: "number" },
|
||||||
@@ -500,6 +297,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
|
timezone: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
@@ -525,6 +323,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
const settingsData = {
|
const settingsData = {
|
||||||
|
timezone: normalizeSettingsTimezone(body.timezone),
|
||||||
emailEnabled: body.emailEnabled,
|
emailEnabled: body.emailEnabled,
|
||||||
notificationEmail: body.notificationEmail || null,
|
notificationEmail: body.notificationEmail || null,
|
||||||
emailStockReminders: body.emailStockReminders ?? true,
|
emailStockReminders: body.emailStockReminders ?? true,
|
||||||
@@ -652,49 +451,34 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { email } = request.body;
|
const { email } = request.body;
|
||||||
|
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
hasSmtpPass: Boolean(smtpPass),
|
hasSmtpPass: Boolean(smtp.pass),
|
||||||
hasSmtpFrom: Boolean(smtpFrom),
|
hasSmtpFrom: Boolean(smtp.from),
|
||||||
smtpPort,
|
smtpPort: smtp.port,
|
||||||
smtpSecure,
|
smtpSecure: smtp.secure,
|
||||||
},
|
},
|
||||||
"[Settings] Test email request received"
|
"[Settings] Test email request received"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
if (!smtp.host || !smtp.user) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
{ to: email, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user) },
|
||||||
"[Settings] Test email skipped: SMTP not configured"
|
"[Settings] Test email skipped: SMTP not configured"
|
||||||
);
|
);
|
||||||
return reply.status(400).send({ error: "SMTP not configured" });
|
return reply.status(400).send({ error: "SMTP not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
request.log.info({ to: email }, "[Settings] Sending test email");
|
request.log.info({ to: email }, "[Settings] Sending test email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
from: smtp.from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "MedAssist-ng - Test Email",
|
subject: "MedAssist-ng - Test Email",
|
||||||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||||||
@@ -709,9 +493,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Failed to send test email");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||||
@@ -766,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
|
const language = getLanguage(settings.language);
|
||||||
|
const { title, message } = buildInteractiveTestPushNotification(language);
|
||||||
|
const actionContext = await createTestNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
publicAppUrl: env.PUBLIC_APP_URL,
|
||||||
|
language,
|
||||||
|
});
|
||||||
const provider = getNotificationProvider(url);
|
const provider = getNotificationProvider(url);
|
||||||
const result = await sendShoutrrrNotification(
|
const result = await sendShoutrrrNotification(url, title, message, {
|
||||||
url,
|
actions: actionContext?.actions,
|
||||||
"MedAssist-ng Test",
|
respondUrl: actionContext?.respondUrl,
|
||||||
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
|
viewUrl: actionContext?.viewUrl,
|
||||||
);
|
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||||
|
sequenceId: actionContext?.sequenceId,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (actionContext?.groupId && result.providerMessageId) {
|
||||||
|
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
request.log.info({ provider }, "[Settings] Test push notification sent");
|
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||||
} else {
|
} else {
|
||||||
@@ -792,103 +594,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and sanitize URL to prevent SSRF attacks
|
|
||||||
// Returns a reconstructed URL from validated components to break taint tracking
|
|
||||||
function sanitizeNotificationUrl(
|
|
||||||
urlStr: string
|
|
||||||
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
|
||||||
try {
|
|
||||||
// Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID
|
|
||||||
if (urlStr.startsWith("discord://")) {
|
|
||||||
const parsedDiscord = new URL(urlStr);
|
|
||||||
const webhookId = parsedDiscord.hostname;
|
|
||||||
const webhookToken = parsedDiscord.username;
|
|
||||||
|
|
||||||
if (!webhookId || !webhookToken) {
|
|
||||||
return { error: "Invalid Discord URL format" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^\d+$/.test(webhookId)) {
|
|
||||||
return { error: "Invalid Discord webhook ID" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
|
|
||||||
return { error: "Invalid Discord webhook token" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
|
|
||||||
return { url: discordWebhookUrl, isNtfy: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert ntfy:// to https:// for parsing, track if it was ntfy
|
|
||||||
const isNtfy = urlStr.startsWith("ntfy://");
|
|
||||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
|
||||||
|
|
||||||
const parsed = new URL(normalizedUrl);
|
|
||||||
|
|
||||||
// Only allow http and https protocols
|
|
||||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
||||||
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostValidationError = validateNotificationHostname(parsed.hostname);
|
|
||||||
if (hostValidationError) {
|
|
||||||
return { error: hostValidationError };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconstruct URL from validated components - this breaks taint tracking
|
|
||||||
// because we're building a new string from validated parts, not passing through user input
|
|
||||||
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
|
|
||||||
|
|
||||||
// Extract auth credentials separately for ntfy (they're in the URL but not in host)
|
|
||||||
const auth =
|
|
||||||
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
|
|
||||||
|
|
||||||
return { url: reconstructedUrl, isNtfy, auth };
|
|
||||||
} catch {
|
|
||||||
return { error: "Invalid URL format" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateNotificationHostname(hostnameRaw: string): string | null {
|
|
||||||
const hostname = hostnameRaw.toLowerCase();
|
|
||||||
|
|
||||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
|
||||||
return "Localhost URLs are not allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
||||||
if (ipMatch) {
|
|
||||||
const [, a, b] = ipMatch.map(Number);
|
|
||||||
if (
|
|
||||||
a === 10 ||
|
|
||||||
a === 127 ||
|
|
||||||
(a === 172 && b >= 16 && b <= 31) ||
|
|
||||||
(a === 192 && b === 168) ||
|
|
||||||
(a === 169 && b === 254)
|
|
||||||
) {
|
|
||||||
return "Private IP addresses are not allowed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".lan") ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return "Internal hostnames are not allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||||
export async function sendShoutrrrNotification(
|
export async function sendShoutrrrNotification(
|
||||||
urlStr: string,
|
urlStr: string,
|
||||||
title: string,
|
title: string,
|
||||||
message: string
|
message: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
options: PushNotificationOptions = {}
|
||||||
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
try {
|
try {
|
||||||
if (urlStr.startsWith("pushover://")) {
|
if (urlStr.startsWith("pushover://")) {
|
||||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||||
@@ -1041,12 +753,13 @@ export async function sendShoutrrrNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
const method = "POST";
|
const method = "POST";
|
||||||
let headers: Record<string, string> = {};
|
let headers: Record<string, string> = {};
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
|
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
|
||||||
|
|
||||||
// Remove emojis from title for header compatibility
|
// Remove emojis from title for header compatibility
|
||||||
const cleanTitle = title
|
const cleanTitle = title
|
||||||
@@ -1091,19 +804,27 @@ export async function sendShoutrrrNotification(
|
|||||||
// characters (umlauts, accents, etc.) through HTTP headers
|
// characters (umlauts, accents, etc.) through HTTP headers
|
||||||
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
||||||
headers = { Title: encodedTitle, Tags: "pill" };
|
headers = { Title: encodedTitle, Tags: "pill" };
|
||||||
body = message;
|
body = renderedPayload.message;
|
||||||
|
|
||||||
// Add auth if present (extracted during sanitization)
|
// Add auth if present (extracted during sanitization)
|
||||||
if (auth) {
|
if (auth) {
|
||||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNtfy) {
|
||||||
|
headers = { ...headers, ...renderedPayload.headers };
|
||||||
|
}
|
||||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||||
targetUrl = sanitizedUrl;
|
targetUrl = sanitizedUrl;
|
||||||
headers = { "Content-Type": "application/json" };
|
headers = { "Content-Type": "application/json" };
|
||||||
if (isDiscordWebhook) {
|
if (isDiscordWebhook) {
|
||||||
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
|
||||||
} else {
|
} else {
|
||||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
body = JSON.stringify({
|
||||||
|
title,
|
||||||
|
message: renderedPayload.message,
|
||||||
|
text: `${title}\n\n${renderedPayload.message}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@@ -1128,7 +849,17 @@ export async function sendShoutrrrNotification(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return { success: true };
|
let providerMessageId: string | undefined;
|
||||||
|
if (isNtfy) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { id?: unknown };
|
||||||
|
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
|
||||||
|
} catch {
|
||||||
|
providerMessageId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, providerMessageId };
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
const parsed = createShareSchema.safeParse(request.body);
|
const parsed = createShareSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
|
|||||||
const match = doseIdPattern.exec(dose.doseId);
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
if (
|
||||||
|
Number.isNaN(parsedMedicationId) ||
|
||||||
|
Number.isNaN(parsedIntakeIndex) ||
|
||||||
|
Number.isNaN(doseDateOnlyMs) ||
|
||||||
|
parsedMedicationId !== medication.id ||
|
||||||
|
parsedIntakeIndex !== intakeIndex
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
|
|||||||
const match = doseIdPattern.exec(dose.doseId);
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
if (
|
||||||
|
Number.isNaN(parsedMedicationId) ||
|
||||||
|
Number.isNaN(parsedIntakeIndex) ||
|
||||||
|
Number.isNaN(doseDateOnlyMs) ||
|
||||||
|
parsedMedicationId !== medication.id ||
|
||||||
|
parsedIntakeIndex !== intakeIndex
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
|
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||||
|
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||||
|
|
||||||
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
type ParsedDoseId = {
|
||||||
|
medicationId: number;
|
||||||
|
intakeIndex: number;
|
||||||
|
timestampMs: number;
|
||||||
|
personSuffix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoseTrackingSource = "manual" | "automatic" | "notification";
|
||||||
|
|
||||||
|
export type MarkDoseTakenResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
status: "marked" | "already_taken";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DismissDosesResult = {
|
||||||
|
success: true;
|
||||||
|
dismissedCount: number;
|
||||||
|
alreadyTakenCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkipDosesResult = {
|
||||||
|
success: true;
|
||||||
|
skippedCount: number;
|
||||||
|
alreadySkippedCount: number;
|
||||||
|
switchedFromTakenCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRealTakenTimestamp(takenAt: Date | null): boolean {
|
||||||
|
return takenAt instanceof Date && takenAt.getTime() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDoseOutOfStock(options: { userId: number; doseId: string }): 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 [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
|
||||||
|
const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
|
||||||
|
|
||||||
|
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,
|
||||||
|
nowMs: stockBeforeDoseMs,
|
||||||
|
}) <= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDoseTakenForUser(input: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
source: DoseTrackingSource;
|
||||||
|
markedBy?: string | null;
|
||||||
|
}): Promise<MarkDoseTakenResult> {
|
||||||
|
const parsedDose = parseDoseId(input.doseId);
|
||||||
|
if (!parsedDose) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "INVALID_DOSE",
|
||||||
|
message: "Invalid dose ID",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||||
|
|
||||||
|
if (existing && !existing.dismissed) {
|
||||||
|
return { success: true, status: "already_taken" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
|
||||||
|
return { success: true, status: "already_taken" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing?.dismissed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "ALREADY_SKIPPED",
|
||||||
|
message: "Dose is already skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
|
||||||
|
if (outOfStock) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "OUT_OF_STOCK",
|
||||||
|
message: "Medication is out of stock",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseId: input.doseId,
|
||||||
|
takenAt: new Date(),
|
||||||
|
markedBy: input.markedBy ?? null,
|
||||||
|
takenSource: input.source,
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, status: "marked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise<SkipDosesResult> {
|
||||||
|
let skippedCount = 0;
|
||||||
|
let alreadySkippedCount = 0;
|
||||||
|
let switchedFromTakenCount = 0;
|
||||||
|
|
||||||
|
for (const doseId of input.doseIds) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseId,
|
||||||
|
markedBy: null,
|
||||||
|
takenAt: new Date(0),
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.dismissed) {
|
||||||
|
alreadySkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||||
|
switchedFromTakenCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(doseTracking)
|
||||||
|
.set({
|
||||||
|
dismissed: true,
|
||||||
|
takenAt: new Date(0),
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: null,
|
||||||
|
})
|
||||||
|
.where(eq(doseTracking.id, existing.id));
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
skippedCount,
|
||||||
|
alreadySkippedCount,
|
||||||
|
switchedFromTakenCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise<DismissDosesResult> {
|
||||||
|
let dismissedCount = 0;
|
||||||
|
let alreadyTakenCount = 0;
|
||||||
|
|
||||||
|
for (const doseId of input.doseIds) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseId,
|
||||||
|
markedBy: null,
|
||||||
|
takenAt: new Date(0),
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.dismissed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||||
|
alreadyTakenCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(doseTracking)
|
||||||
|
.set({
|
||||||
|
dismissed: true,
|
||||||
|
takenAt: new Date(0),
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: null,
|
||||||
|
})
|
||||||
|
.where(eq(doseTracking.id, existing.id));
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
dismissedCount,
|
||||||
|
alreadyTakenCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { and, eq, gte, lte } from "drizzle-orm";
|
import { and, eq, gte, lte } from "drizzle-orm";
|
||||||
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/path-utils.js";
|
||||||
import { doseTracking, medications, users } from "../db/schema.js";
|
import { doseTracking, medications, users } from "../db/schema.js";
|
||||||
import {
|
import {
|
||||||
getDateLocale,
|
getDateLocale,
|
||||||
@@ -13,13 +12,15 @@ import {
|
|||||||
type Language,
|
type Language,
|
||||||
t,
|
t,
|
||||||
} from "../i18n/translations.js";
|
} from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
import {
|
import {
|
||||||
cleanOldIntakeReminders,
|
cleanOldIntakeReminders,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
getTimezone,
|
getEffectiveTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
@@ -30,20 +31,26 @@ import {
|
|||||||
type UpcomingIntake,
|
type UpcomingIntake,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
import {
|
||||||
|
createNotificationActionContext,
|
||||||
|
storeNotificationActionGroupNtfyMessageId,
|
||||||
|
} from "./notification-actions-service.js";
|
||||||
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||||
|
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||||
|
|
||||||
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||||
|
|
||||||
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
|
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
|
||||||
|
|
||||||
function loadIntakeReminderState(): IntakeReminderState {
|
function loadIntakeReminderState(logger: ServiceLogger): IntakeReminderState {
|
||||||
try {
|
try {
|
||||||
if (existsSync(intakeReminderStateFile)) {
|
if (existsSync(intakeReminderStateFile)) {
|
||||||
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
// ignore
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`[IntakeReminder] Failed to load reminder state file=${intakeReminderStateFile}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
return createDefaultIntakeReminderState();
|
return createDefaultIntakeReminderState();
|
||||||
}
|
}
|
||||||
@@ -52,36 +59,6 @@ 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 {
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
const intakeDate = intake.intakeTime;
|
const intakeDate = intake.intakeTime;
|
||||||
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
@@ -112,6 +89,41 @@ function formatIntakeLog(intake: {
|
|||||||
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
|
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
|
||||||
|
const commercialName = med.name?.trim() ?? "";
|
||||||
|
if (commercialName) return commercialName;
|
||||||
|
|
||||||
|
const genericName = med.genericName?.trim() ?? "";
|
||||||
|
if (genericName) return genericName;
|
||||||
|
|
||||||
|
return `Medication #${med.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPushProviderLabel(url: string): string {
|
||||||
|
const normalizedUrl = url.trim().toLowerCase();
|
||||||
|
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
|
||||||
|
if (normalizedUrl.startsWith("discord://")) return "discord";
|
||||||
|
if (normalizedUrl.startsWith("pushover://")) return "pushover";
|
||||||
|
if (normalizedUrl.startsWith("gotify://")) return "gotify";
|
||||||
|
if (normalizedUrl.startsWith("telegram://")) return "telegram";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionContextLog(options: {
|
||||||
|
actionMode: "full" | "view-only";
|
||||||
|
doseCount: number;
|
||||||
|
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
|
||||||
|
}): string {
|
||||||
|
const { actionMode, doseCount, actionContext } = options;
|
||||||
|
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function autoMarkDueIntakesAsTaken(
|
async function autoMarkDueIntakesAsTaken(
|
||||||
settings: UserSettings & { userId: number },
|
settings: UserSettings & { userId: number },
|
||||||
rows: (typeof medications.$inferSelect)[],
|
rows: (typeof medications.$inferSelect)[],
|
||||||
@@ -166,7 +178,7 @@ async function autoMarkDueIntakesAsTaken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
const medDisplayName = med.name || med.genericName || "";
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
let remainingStock = computeMedicationCurrentStock({
|
let remainingStock = computeMedicationCurrentStock({
|
||||||
medication: med,
|
medication: med,
|
||||||
doses: trackedDoses,
|
doses: trackedDoses,
|
||||||
@@ -269,14 +281,9 @@ async function sendIntakeReminderEmail(
|
|||||||
currentCount?: number,
|
currentCount?: number,
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
if (!smtp.host || !smtp.user) {
|
||||||
return { success: false, error: "SMTP not configured" };
|
return { success: false, error: "SMTP not configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,39 +408,23 @@ ${getFooterPlain(language)}`;
|
|||||||
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
|
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
|
||||||
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
|
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
|
||||||
|
|
||||||
try {
|
const mailResult = await sendEmailNotification({
|
||||||
const transporter = nodemailer.createTransport({
|
to: email,
|
||||||
host: smtpHost,
|
subject: `💊 ${subject}`,
|
||||||
port: smtpPort,
|
text: plainText,
|
||||||
secure: smtpSecure,
|
html,
|
||||||
auth: {
|
from: smtp.from,
|
||||||
user: smtpUser,
|
});
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
if (!mailResult.success) {
|
||||||
from: smtpFrom,
|
return { success: false, error: mailResult.error ?? "Unknown error" };
|
||||||
to: email,
|
|
||||||
subject: `💊 ${subject}`,
|
|
||||||
text: plainText,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: mailResult.messageId,
|
||||||
|
smtpResponse: mailResult.smtpResponse,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void> {
|
async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void> {
|
||||||
@@ -475,7 +466,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
||||||
|
|
||||||
const locale = getDateLocale(language);
|
const locale = getDateLocale(language);
|
||||||
const tz = getTimezone();
|
const tz = getEffectiveTimezone(settings.timezone ?? null);
|
||||||
|
|
||||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||||
if (autoMarkedCount > 0) {
|
if (autoMarkedCount > 0) {
|
||||||
@@ -523,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const now = new Date();
|
||||||
|
const state = loadIntakeReminderState(logger);
|
||||||
|
const trackedDoses = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
currentStock: computeMedicationCurrentStock({
|
||||||
|
medication: entry.med,
|
||||||
|
doses: trackedDoses,
|
||||||
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
nowMs: now.getTime(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
|
||||||
|
if (suppressedEmptyStockEntries.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
|
||||||
|
.map((entry) =>
|
||||||
|
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
|
||||||
|
)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
|
||||||
|
if (reminderEntriesEligible.length === 0) {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
let scheduledIntakesTodayCount = 0;
|
let scheduledIntakesTodayCount = 0;
|
||||||
// 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 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);
|
||||||
|
|
||||||
@@ -535,10 +557,10 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
todayEnd.setHours(23, 59, 59, 999);
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// 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, intakes, intakesWithReminders } of reminderEntries) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) {
|
||||||
// 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 || "";
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
@@ -841,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
.join("\n") +
|
.join("\n") +
|
||||||
repeatNote +
|
repeatNote +
|
||||||
`\n\n---\n${getFooterPlain(language)}`;
|
`\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const actionMode = remindersToSend.length === 1 ? "full" : "view-only";
|
||||||
|
const actionDoseIds = remindersToSend.map((intake) =>
|
||||||
|
buildDoseIdForIntake({
|
||||||
|
...intake,
|
||||||
|
medicationId: intake.medicationId,
|
||||||
|
blisterIndex: intake.blisterIndex,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null = null;
|
||||||
|
let actionContextFailed = false;
|
||||||
|
try {
|
||||||
|
actionContext = await createNotificationActionContext({
|
||||||
|
userId: settings.userId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
doseIds: actionDoseIds,
|
||||||
|
scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(),
|
||||||
|
publicAppUrl: env.PUBLIC_APP_URL,
|
||||||
|
language,
|
||||||
|
actionMode,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
actionContextFailed = true;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(
|
||||||
|
`[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!actionContext) {
|
||||||
|
if (actionContextFailed) {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||||
|
settings.shoutrrrUrl!
|
||||||
|
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!);
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, {
|
||||||
|
actions: actionContext?.actions,
|
||||||
|
respondUrl: actionContext?.respondUrl,
|
||||||
|
viewUrl: actionContext?.viewUrl,
|
||||||
|
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||||
|
sequenceId: actionContext?.sequenceId,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: hasNaggingReminder ? 4 : 3,
|
||||||
|
});
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (actionContext?.groupId && result.providerMessageId) {
|
||||||
|
try {
|
||||||
|
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||||
|
logger.info(
|
||||||
|
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (actionContext?.groupId && pushProvider === "ntfy") {
|
||||||
|
logger.warn(
|
||||||
|
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
|
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1125,10 +1125,20 @@ export function startMedicationEnrichmentService(logger: MedicationEnrichmentLog
|
|||||||
if (schedulerStarted) return;
|
if (schedulerStarted) return;
|
||||||
|
|
||||||
schedulerStarted = true;
|
schedulerStarted = true;
|
||||||
void refreshEmaCatalog("startup").catch(() => undefined);
|
void refreshEmaCatalog("startup").catch((error: unknown) => {
|
||||||
|
activeLogger.error(
|
||||||
|
`[MedicationEnrichment] startup refresh failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
refreshTimer = setInterval(() => {
|
refreshTimer = setInterval(() => {
|
||||||
void refreshEmaCatalog("scheduled").catch(() => undefined);
|
void refreshEmaCatalog("scheduled").catch((error: unknown) => {
|
||||||
|
activeLogger.error(
|
||||||
|
`[MedicationEnrichment] scheduled refresh failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
}, EMA_REFRESH_INTERVAL_MS);
|
}, EMA_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
if (typeof refreshTimer.unref === "function") {
|
if (typeof refreshTimer.unref === "function") {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export {
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||||
|
type MedicationEnrichmentCombinedSource,
|
||||||
|
type MedicationEnrichmentEnrichRequest,
|
||||||
|
type MedicationEnrichmentEnrichResponse,
|
||||||
|
type MedicationEnrichmentPackageOption,
|
||||||
|
type MedicationEnrichmentSearchResponse,
|
||||||
|
type MedicationEnrichmentSearchResult,
|
||||||
|
type MedicationEnrichmentSearchSource,
|
||||||
|
MedicationEnrichmentServiceError,
|
||||||
|
type MedicationEnrichmentStrengthOption,
|
||||||
|
} from "../medication-enrichment.js";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export {
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||||
|
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||||
|
type MedicationEnrichmentCombinedSource,
|
||||||
|
type MedicationEnrichmentEnrichRequest,
|
||||||
|
type MedicationEnrichmentEnrichResponse,
|
||||||
|
type MedicationEnrichmentPackageOption,
|
||||||
|
type MedicationEnrichmentSearchResponse,
|
||||||
|
type MedicationEnrichmentSearchResult,
|
||||||
|
type MedicationEnrichmentSearchSource,
|
||||||
|
MedicationEnrichmentServiceError,
|
||||||
|
type MedicationEnrichmentStrengthOption,
|
||||||
|
} from "./adapters.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
enrichMedicationSelection,
|
||||||
|
searchMedicationEnrichment,
|
||||||
|
startMedicationEnrichmentCatalogRefresh,
|
||||||
|
startMedicationEnrichmentService,
|
||||||
|
} from "./search.js";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
enrichMedicationSelection,
|
||||||
|
searchMedicationEnrichment,
|
||||||
|
startMedicationEnrichmentCatalogRefresh,
|
||||||
|
startMedicationEnrichmentService,
|
||||||
|
} from "../medication-enrichment.js";
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { forEachScheduledOccurrenceInRange, type Intake, parseIntakesJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
|
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
|
||||||
|
return value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
|
||||||
|
if (!intakesJson) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(intakesJson);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.map((item: unknown) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const unit = (item as Record<string, unknown>).intakeUnit;
|
||||||
|
return isIntakeUnit(unit) ? unit : null;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIntakesWithUnits(
|
||||||
|
intakesJson: string | null | undefined,
|
||||||
|
legacyRow: { usageJson: string; everyJson: string; startJson: string },
|
||||||
|
medicationIntakeRemindersEnabled?: boolean
|
||||||
|
): Intake[] {
|
||||||
|
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
|
||||||
|
const rawUnits = parseRawIntakeUnits(intakesJson);
|
||||||
|
if (rawUnits.length === 0) return intakes;
|
||||||
|
|
||||||
|
return intakes.map((intake, idx) => ({
|
||||||
|
...intake,
|
||||||
|
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateUsageInRange(
|
||||||
|
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
|
||||||
|
start: Date,
|
||||||
|
end: Date
|
||||||
|
): number {
|
||||||
|
if (end.getTime() <= start.getTime()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
blisters.forEach((blister) => {
|
||||||
|
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
|
||||||
|
total += blister.usage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Number(total.toFixed(2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { notificationActionGroups, notificationActionTokens } from "../db/schema.js";
|
||||||
|
import type { Language } from "../i18n/translations.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js";
|
||||||
|
|
||||||
|
export type NotificationActionKind = "taken" | "skip" | "respond" | "view";
|
||||||
|
|
||||||
|
type TokenKind = Exclude<NotificationActionKind, "view">;
|
||||||
|
type ActiveTokenKind = "taken" | "skip" | "respond";
|
||||||
|
|
||||||
|
export type NotificationActionContext = {
|
||||||
|
groupId?: number;
|
||||||
|
sequenceId?: string;
|
||||||
|
respondUrl?: string;
|
||||||
|
viewUrl: string;
|
||||||
|
actions: PushNotificationAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationActionMode = "full" | "view-only";
|
||||||
|
|
||||||
|
export type NotificationActionTokenRecord = {
|
||||||
|
token: typeof notificationActionTokens.$inferSelect;
|
||||||
|
group: typeof notificationActionGroups.$inferSelect;
|
||||||
|
doseIds: string[];
|
||||||
|
viewUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function normalizePublicAppUrl(publicAppUrl: string): string {
|
||||||
|
return publicAppUrl.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfiguredUrl(value: string | null | undefined): URL | null {
|
||||||
|
const trimmedValue = value?.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(trimmedValue);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHostname(hostname: string): boolean {
|
||||||
|
const normalizedHostname = hostname.toLowerCase();
|
||||||
|
return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null {
|
||||||
|
const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL);
|
||||||
|
if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) {
|
||||||
|
return normalizePublicAppUrl(configuredUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsOrigins = env.CORS_ORIGINS.split(",")
|
||||||
|
.map((origin) => parseConfiguredUrl(origin))
|
||||||
|
.filter((origin): origin is URL => origin !== null);
|
||||||
|
const reachableCorsOrigin =
|
||||||
|
corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null;
|
||||||
|
if (reachableCorsOrigin) {
|
||||||
|
return normalizePublicAppUrl(reachableCorsOrigin.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduledKey(scheduledFor: Date): string {
|
||||||
|
return String(Math.floor(scheduledFor.getTime() / 60000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateParam(value: Date): string {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const primaryDoseId = doseIds[0];
|
||||||
|
|
||||||
|
if (scheduledFor) {
|
||||||
|
params.set("day", formatDateParam(scheduledFor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryDoseId) {
|
||||||
|
params.set("dose", primaryDoseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDoseIdsJson(value: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSequenceId(groupKey: string): string {
|
||||||
|
return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActionToken(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashActionToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> {
|
||||||
|
const token = createActionToken();
|
||||||
|
await db.insert(notificationActionTokens).values({
|
||||||
|
groupId,
|
||||||
|
tokenHash: hashActionToken(token),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { kind, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActionTokens(groupId: number): Promise<Record<ActiveTokenKind, string>> {
|
||||||
|
const createdTokens = await Promise.all([
|
||||||
|
createTokenRow(groupId, "taken"),
|
||||||
|
createTokenRow(groupId, "skip"),
|
||||||
|
createTokenRow(groupId, "respond"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createdTokens.reduce(
|
||||||
|
(accumulator, entry) => {
|
||||||
|
accumulator[entry.kind] = entry.token;
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{ taken: "", skip: "", respond: "" } as Record<ActiveTokenKind, string>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNotificationActionContext(input: {
|
||||||
|
userId: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
doseIds: string[];
|
||||||
|
scheduledFor: Date;
|
||||||
|
publicAppUrl?: string | null;
|
||||||
|
language: Language;
|
||||||
|
actionMode?: NotificationActionMode;
|
||||||
|
}): Promise<NotificationActionContext | null> {
|
||||||
|
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||||
|
if (!publicAppUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort();
|
||||||
|
if (uniqueDoseIds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = publicAppUrl;
|
||||||
|
const actionMode = input.actionMode ?? "full";
|
||||||
|
const labels = getNotificationActionLabels(input.language);
|
||||||
|
const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds);
|
||||||
|
|
||||||
|
if (actionMode === "view-only") {
|
||||||
|
return {
|
||||||
|
viewUrl,
|
||||||
|
actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`;
|
||||||
|
const sequenceId = createSequenceId(groupKey);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||||
|
|
||||||
|
let [group] = await db
|
||||||
|
.select()
|
||||||
|
.from(notificationActionGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(notificationActionGroups.groupKey, groupKey),
|
||||||
|
isNull(notificationActionGroups.resolvedAction),
|
||||||
|
gt(notificationActionGroups.expiresAt, now)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
[group] = await db
|
||||||
|
.insert(notificationActionGroups)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
groupKey,
|
||||||
|
sequenceId,
|
||||||
|
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
language: input.language,
|
||||||
|
scheduledFor: input.scheduledFor,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await createActionTokens(group.id);
|
||||||
|
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||||
|
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||||
|
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||||
|
const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
sequenceId: group.sequenceId,
|
||||||
|
respondUrl,
|
||||||
|
viewUrl: resolvedViewUrl,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: groupLabels.taken,
|
||||||
|
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: groupLabels.skip,
|
||||||
|
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{ kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestNotificationActionContext(input: {
|
||||||
|
userId: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
publicAppUrl?: string | null;
|
||||||
|
language: Language;
|
||||||
|
}): Promise<NotificationActionContext | null> {
|
||||||
|
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||||
|
if (!publicAppUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = publicAppUrl;
|
||||||
|
const now = new Date();
|
||||||
|
const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`;
|
||||||
|
const sequenceId = createSequenceId(groupKey);
|
||||||
|
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||||
|
const viewUrl = buildViewUrl(baseUrl, null, []);
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.insert(notificationActionGroups)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
groupKey,
|
||||||
|
sequenceId,
|
||||||
|
doseIdsJson: "[]",
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
language: input.language,
|
||||||
|
scheduledFor: now,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const tokens = await createActionTokens(group.id);
|
||||||
|
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||||
|
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||||
|
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
sequenceId: group.sequenceId,
|
||||||
|
respondUrl,
|
||||||
|
viewUrl,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: groupLabels.taken,
|
||||||
|
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: groupLabels.skip,
|
||||||
|
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{ kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationActionTokenRecord(
|
||||||
|
rawToken: string
|
||||||
|
): Promise<NotificationActionTokenRecord | null> {
|
||||||
|
const tokenHash = hashActionToken(rawToken);
|
||||||
|
const rows = await db
|
||||||
|
.select({ token: notificationActionTokens, group: notificationActionGroups })
|
||||||
|
.from(notificationActionTokens)
|
||||||
|
.innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id))
|
||||||
|
.where(eq(notificationActionTokens.tokenHash, tokenHash));
|
||||||
|
|
||||||
|
const record = rows[0];
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL);
|
||||||
|
return {
|
||||||
|
token: record.token,
|
||||||
|
group: record.group,
|
||||||
|
doseIds: parseDoseIdsJson(record.group.doseIdsJson),
|
||||||
|
viewUrl: baseUrl
|
||||||
|
? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean {
|
||||||
|
return record.group.expiresAt.getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise<void> {
|
||||||
|
const normalizedMessageId = ntfyMessageId.trim();
|
||||||
|
if (normalizedMessageId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(notificationActionGroups)
|
||||||
|
.set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() })
|
||||||
|
.where(eq(notificationActionGroups.id, groupId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Language } from "../../i18n/translations.js";
|
||||||
|
|
||||||
|
export type PushNotificationAction =
|
||||||
|
| {
|
||||||
|
kind: "taken";
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
method: "POST";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "skip";
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
method: "POST";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "view";
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
method: "GET";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PushNotificationOptions = {
|
||||||
|
actions?: PushNotificationAction[];
|
||||||
|
respondUrl?: string;
|
||||||
|
viewUrl?: string;
|
||||||
|
clickUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
|
priority?: number;
|
||||||
|
sequenceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NtfyActionPayload = {
|
||||||
|
action: "http" | "view";
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
method?: "POST";
|
||||||
|
clear: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function encodeHeaderValue(value: string): string {
|
||||||
|
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNtfyNotificationUrl(urlStr: string): boolean {
|
||||||
|
if (urlStr.startsWith("ntfy://")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(urlStr);
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationProvider(urlStr: string): string {
|
||||||
|
if (isNtfyNotificationUrl(urlStr)) {
|
||||||
|
return "ntfy";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationActionLabels(language: Language): {
|
||||||
|
taken: string;
|
||||||
|
skip: string;
|
||||||
|
respond: string;
|
||||||
|
view: string;
|
||||||
|
} {
|
||||||
|
if (language === "de") {
|
||||||
|
return {
|
||||||
|
taken: "Einnehmen",
|
||||||
|
skip: "Überspringen",
|
||||||
|
respond: "Antworten",
|
||||||
|
view: "Öffnen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taken: "Take",
|
||||||
|
skip: "Skip",
|
||||||
|
respond: "Respond",
|
||||||
|
view: "View",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
|
||||||
|
const actions = options.actions ?? [];
|
||||||
|
|
||||||
|
return actions.map((action) => {
|
||||||
|
if (action.kind === "view") {
|
||||||
|
return {
|
||||||
|
action: "view",
|
||||||
|
label: action.label,
|
||||||
|
url: action.url,
|
||||||
|
clear: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "http",
|
||||||
|
label: action.label,
|
||||||
|
url: action.url,
|
||||||
|
method: "POST",
|
||||||
|
// Clear the original actionable ntfy notification locally after a successful mutation.
|
||||||
|
clear: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
|
||||||
|
if (!options.respondUrl && !options.viewUrl) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [message.trimEnd()];
|
||||||
|
|
||||||
|
if (options.respondUrl) {
|
||||||
|
lines.push("", "Respond:", options.respondUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.viewUrl) {
|
||||||
|
lines.push("", "View:", options.viewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNotificationActionPayload(
|
||||||
|
urlStr: string,
|
||||||
|
message: string,
|
||||||
|
options: PushNotificationOptions
|
||||||
|
): { message: string; headers: Record<string, string> } {
|
||||||
|
if (!isNtfyNotificationUrl(urlStr)) {
|
||||||
|
return {
|
||||||
|
message: appendFallbackActionLinks(message, options),
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const ntfyActions = buildNtfyActions(options);
|
||||||
|
if (ntfyActions.length > 0) {
|
||||||
|
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
|
||||||
|
}
|
||||||
|
if (options.clickUrl && ntfyActions.length === 0) {
|
||||||
|
headers.Click = options.clickUrl;
|
||||||
|
}
|
||||||
|
if (options.tags && options.tags.length > 0) {
|
||||||
|
headers.Tags = options.tags.join(",");
|
||||||
|
}
|
||||||
|
if (typeof options.priority === "number") {
|
||||||
|
headers.Priority = String(options.priority);
|
||||||
|
}
|
||||||
|
if (options.sequenceId) {
|
||||||
|
headers["X-Sequence-ID"] = options.sequenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message, headers };
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
|
||||||
|
import { formatPlannerQuantity } from "../planner-service.js";
|
||||||
|
|
||||||
|
export type StockReminderItem = {
|
||||||
|
name: string;
|
||||||
|
medsLeft: number;
|
||||||
|
packageType?: string;
|
||||||
|
daysLeft: number | null;
|
||||||
|
depletionDate: string | null;
|
||||||
|
isCritical?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrescriptionReminderItem = {
|
||||||
|
name: string;
|
||||||
|
remainingRefills: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitStockItems(items: StockReminderItem[]): {
|
||||||
|
emptyItems: StockReminderItem[];
|
||||||
|
criticalItems: StockReminderItem[];
|
||||||
|
lowItems: StockReminderItem[];
|
||||||
|
} {
|
||||||
|
const emptyItems = items.filter((item) => item.medsLeft <= 0);
|
||||||
|
const criticalItems = items.filter((item) => item.medsLeft > 0 && item.isCritical !== false);
|
||||||
|
const lowItems = items.filter((item) => item.medsLeft > 0 && item.isCritical === false);
|
||||||
|
return { emptyItems, criticalItems, lowItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStockReminderPushNotification(
|
||||||
|
items: StockReminderItem[],
|
||||||
|
language: Language
|
||||||
|
): { title: string; message: string } {
|
||||||
|
const tr = getTranslations(language);
|
||||||
|
const { emptyItems, criticalItems, lowItems } = splitStockItems(items);
|
||||||
|
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyItems.length > 0) titleParts.push(`🚨 ${emptyItems.length} ${tr.push.empty}`);
|
||||||
|
if (criticalItems.length > 0) titleParts.push(`🚨 ${criticalItems.length} ${tr.push.critical}`);
|
||||||
|
if (lowItems.length > 0) titleParts.push(`⚠️ ${lowItems.length} ${tr.push.lowStock}`);
|
||||||
|
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyItems.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||||
|
emptyItems.forEach((item) => messageParts.push(` • ${item.name}`));
|
||||||
|
}
|
||||||
|
if (criticalItems.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||||
|
criticalItems.forEach((item) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lowItems.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||||
|
lowItems.forEach((item) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPrescriptionReminderPushNotification(
|
||||||
|
items: PrescriptionReminderItem[],
|
||||||
|
language: Language
|
||||||
|
): { title: string; message: string } {
|
||||||
|
const tr = getTranslations(language);
|
||||||
|
const emptyItems = items.filter((item) => item.remainingRefills <= 0);
|
||||||
|
const lowItems = items.filter((item) => item.remainingRefills > 0);
|
||||||
|
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyItems.length > 0) {
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${emptyItems.length} ${emptyItems.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lowItems.length > 0) {
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${lowItems.length} ${lowItems.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyItems.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||||
|
emptyItems.forEach((item) => messageParts.push(` • ${item.name}`));
|
||||||
|
}
|
||||||
|
if (lowItems.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||||
|
lowItems.forEach((item) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${item.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: item.remainingRefills })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`,
|
||||||
|
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
||||||
|
import type { PushNotificationOptions } from "./action-renderer.js";
|
||||||
|
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailDeliveryRequest = {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
from?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmailDeliveryResult = {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
messageId?: string;
|
||||||
|
smtpResponse?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSmtpConfig(): {
|
||||||
|
host?: string;
|
||||||
|
user?: string;
|
||||||
|
pass?: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
from?: string;
|
||||||
|
} {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||||
|
const port = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||||
|
const secure = process.env.SMTP_SECURE === "true";
|
||||||
|
const from = process.env.SMTP_FROM ?? user;
|
||||||
|
|
||||||
|
return { host, user, pass, port, secure, from };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSmtpTransport(smtp = getSmtpConfig()) {
|
||||||
|
if (!smtp.host || !smtp.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SMTP endpoint is configured by the server operator via environment variables,
|
||||||
|
// not derived from request-controlled input.
|
||||||
|
// lgtm [js/request-forgery]
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: smtp.host,
|
||||||
|
port: smtp.port,
|
||||||
|
secure: smtp.secure,
|
||||||
|
auth: {
|
||||||
|
user: smtp.user,
|
||||||
|
pass: smtp.pass ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
|
||||||
|
const smtp = getSmtpConfig();
|
||||||
|
if (!smtp.host || !smtp.user) {
|
||||||
|
return { success: false, error: "SMTP not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = createSmtpTransport(smtp);
|
||||||
|
if (!transporter) {
|
||||||
|
return { success: false, error: "SMTP not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
|
from: input.from ?? smtp.from,
|
||||||
|
to: input.to,
|
||||||
|
subject: input.subject,
|
||||||
|
text: input.text,
|
||||||
|
html: input.html,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushNotification(
|
||||||
|
url: string,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
options: PushNotificationOptions = {}
|
||||||
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
|
try {
|
||||||
|
const result = await sendShoutrrrNotification(url, title, message, options);
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.error };
|
||||||
|
}
|
||||||
|
return { success: true, providerMessageId: result.providerMessageId };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export {
|
||||||
|
buildPrescriptionReminderPushNotification,
|
||||||
|
buildStockReminderPushNotification,
|
||||||
|
type PrescriptionReminderItem,
|
||||||
|
type StockReminderItem,
|
||||||
|
} from "./builders.js";
|
||||||
|
export {
|
||||||
|
type EmailDeliveryRequest,
|
||||||
|
type EmailDeliveryResult,
|
||||||
|
getSmtpConfig,
|
||||||
|
sendEmailNotification,
|
||||||
|
sendPushNotification,
|
||||||
|
} from "./delivery.js";
|
||||||
|
export {
|
||||||
|
getReminderState,
|
||||||
|
loadReminderState,
|
||||||
|
saveReminderState,
|
||||||
|
updateReminderSentTime,
|
||||||
|
updateUserReminderSentTime,
|
||||||
|
} from "./state.js";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { getDataDir } from "../../db/db-utils.js";
|
||||||
|
import { userSettings } from "../../db/schema.js";
|
||||||
|
import {
|
||||||
|
createDefaultReminderState,
|
||||||
|
getTodayInTimezone,
|
||||||
|
parseReminderState,
|
||||||
|
type ReminderState,
|
||||||
|
} from "../../utils/scheduler-utils.js";
|
||||||
|
|
||||||
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
|
|
||||||
|
export function loadReminderState(): ReminderState {
|
||||||
|
try {
|
||||||
|
if (existsSync(reminderStateFile)) {
|
||||||
|
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return createDefaultReminderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveReminderState(state: ReminderState): void {
|
||||||
|
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReminderState(): ReminderState {
|
||||||
|
return loadReminderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReminderSentTime(
|
||||||
|
type: "stock" | "intake" | "prescription" = "stock",
|
||||||
|
channel: "email" | "push" | "both" = "email"
|
||||||
|
): void {
|
||||||
|
const state = loadReminderState();
|
||||||
|
const today = getTodayInTimezone();
|
||||||
|
saveReminderState({
|
||||||
|
...state,
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
lastNotificationType: type,
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stock and intake reminders are tracked separately so neither overwrites the other.
|
||||||
|
export async function updateUserReminderSentTime(
|
||||||
|
userId: number,
|
||||||
|
type: "stock" | "intake" | "prescription" = "stock",
|
||||||
|
channel: "email" | "push" | "both" = "email",
|
||||||
|
medName?: string,
|
||||||
|
takenBy?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (type === "stock") {
|
||||||
|
await db
|
||||||
|
.update(userSettings)
|
||||||
|
.set({
|
||||||
|
lastStockReminderSent: now,
|
||||||
|
lastStockReminderChannel: channel,
|
||||||
|
lastStockReminderMedNames: medName ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "prescription") {
|
||||||
|
await db
|
||||||
|
.update(userSettings)
|
||||||
|
.set({
|
||||||
|
lastPrescriptionReminderSent: now,
|
||||||
|
lastPrescriptionReminderChannel: channel,
|
||||||
|
lastPrescriptionReminderMedNames: medName ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(userSettings)
|
||||||
|
.set({
|
||||||
|
lastAutoEmailSent: now,
|
||||||
|
lastNotificationType: type,
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
lastReminderMedName: medName ?? null,
|
||||||
|
lastReminderTakenBy: takenBy ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { getPlannerUnitKind, isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS in email templates.
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
const htmlEscapes: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContainerPackage(packageType?: string): boolean {
|
||||||
|
return isAmountBasedPackageType(packageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlannerUnit(
|
||||||
|
packageType: string | undefined,
|
||||||
|
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||||
|
): string {
|
||||||
|
const unitKind = getPlannerUnitKind(packageType);
|
||||||
|
if (unitKind === "units") return tr.common.units;
|
||||||
|
if (unitKind === "ml") return tr.common.ml;
|
||||||
|
if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills;
|
||||||
|
if (unitKind === "injections") return tr.common.injections ?? tr.common.pills;
|
||||||
|
return tr.common.pills;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPlannerQuantity(
|
||||||
|
packageType: string | undefined,
|
||||||
|
count: number,
|
||||||
|
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||||
|
): string {
|
||||||
|
return `${count} ${getPlannerUnit(packageType, tr)}`;
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
import { closeSync, existsSync, mkdirSync, openSync, statSync, unlinkSync } 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 { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/path-utils.js";
|
||||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
import { doseTracking, medications } 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, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
@@ -19,10 +18,10 @@ import {
|
|||||||
type Blister,
|
type Blister,
|
||||||
calculateDepletionInfo,
|
calculateDepletionInfo,
|
||||||
countScheduledOccurrencesInRange,
|
countScheduledOccurrencesInRange,
|
||||||
createDefaultReminderState,
|
|
||||||
formatInTimezone,
|
formatInTimezone,
|
||||||
getCurrentHourInTimezone,
|
getCurrentHourInTimezone,
|
||||||
getDateOnlyTimestamp,
|
getDateOnlyTimestamp,
|
||||||
|
getEffectiveTimezone,
|
||||||
getMsUntilNextCheck,
|
getMsUntilNextCheck,
|
||||||
getNextScheduledOccurrenceTime,
|
getNextScheduledOccurrenceTime,
|
||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
@@ -31,10 +30,17 @@ import {
|
|||||||
normalizeIntakeUsageForStock,
|
normalizeIntakeUsageForStock,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
parseLocalDateTime,
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
type ReminderState,
|
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
import {
|
||||||
|
buildPrescriptionReminderPushNotification,
|
||||||
|
buildStockReminderPushNotification,
|
||||||
|
} from "./notifications/builders.js";
|
||||||
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||||
|
import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js";
|
||||||
|
import { formatPlannerQuantity } from "./planner-service.js";
|
||||||
|
|
||||||
|
export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
const htmlEscapes: Record<string, string> = {
|
const htmlEscapes: Record<string, string> = {
|
||||||
@@ -47,39 +53,8 @@ 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 reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||||
const LOCK_STALE_MS = 15 * 60 * 1000;
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
@@ -131,89 +106,10 @@ function releaseReminderSendLock(lockFilePath: string | null): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
|
||||||
try {
|
|
||||||
if (existsSync(reminderStateFile)) {
|
|
||||||
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return createDefaultReminderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveReminderState(state: ReminderState): void {
|
|
||||||
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getReminderState(): ReminderState {
|
|
||||||
return loadReminderState();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateReminderSentTime(
|
|
||||||
type: "stock" | "intake" | "prescription" = "stock",
|
|
||||||
channel: "email" | "push" | "both" = "email"
|
|
||||||
): void {
|
|
||||||
const state = loadReminderState();
|
|
||||||
const today = getTodayInTimezone();
|
|
||||||
saveReminderState({
|
|
||||||
...state,
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
lastNotificationType: type,
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user settings in database when reminder is sent
|
|
||||||
// Stock and intake reminders are tracked separately so neither overwrites the other
|
|
||||||
export async function updateUserReminderSentTime(
|
|
||||||
userId: number,
|
|
||||||
type: "stock" | "intake" | "prescription" = "stock",
|
|
||||||
channel: "email" | "push" | "both" = "email",
|
|
||||||
medName?: string,
|
|
||||||
takenBy?: string
|
|
||||||
): Promise<void> {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
if (type === "stock") {
|
|
||||||
// Write to dedicated stock reminder columns only — do NOT touch the shared
|
|
||||||
// lastNotificationType column, as that would block intake reminder display
|
|
||||||
await db
|
|
||||||
.update(userSettings)
|
|
||||||
.set({
|
|
||||||
lastStockReminderSent: now,
|
|
||||||
lastStockReminderChannel: channel,
|
|
||||||
lastStockReminderMedNames: medName ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(userSettings.userId, userId));
|
|
||||||
} else if (type === "prescription") {
|
|
||||||
// Write to dedicated prescription reminder columns only
|
|
||||||
await db
|
|
||||||
.update(userSettings)
|
|
||||||
.set({
|
|
||||||
lastPrescriptionReminderSent: now,
|
|
||||||
lastPrescriptionReminderChannel: channel,
|
|
||||||
lastPrescriptionReminderMedNames: medName ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(userSettings.userId, userId));
|
|
||||||
} else {
|
|
||||||
// Write to intake reminder columns
|
|
||||||
await db
|
|
||||||
.update(userSettings)
|
|
||||||
.set({
|
|
||||||
lastAutoEmailSent: now,
|
|
||||||
lastNotificationType: type,
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
lastReminderMedName: medName ?? null,
|
|
||||||
lastReminderTakenBy: takenBy ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(userSettings.userId, userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
|
packageType?: string;
|
||||||
daysLeft: number | null;
|
daysLeft: number | null;
|
||||||
depletionDate: string | null;
|
depletionDate: string | null;
|
||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
@@ -232,6 +128,16 @@ type PrescriptionReminderItem = {
|
|||||||
expiryDate: string | null;
|
expiryDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string {
|
||||||
|
const commercialName = row.name?.trim() ?? "";
|
||||||
|
if (commercialName) return commercialName;
|
||||||
|
|
||||||
|
const genericName = row.genericName?.trim() ?? "";
|
||||||
|
if (genericName) return genericName;
|
||||||
|
|
||||||
|
return `Medication #${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getMedicationsNeedingReminder(
|
async function getMedicationsNeedingReminder(
|
||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
@@ -403,8 +309,9 @@ async function getMedicationsNeedingReminder(
|
|||||||
|
|
||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||||
medsLeft: currentPills,
|
medsLeft: currentPills,
|
||||||
|
packageType,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
isCritical,
|
isCritical,
|
||||||
@@ -429,7 +336,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
|||||||
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
||||||
)
|
)
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
name: row.name,
|
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||||
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
||||||
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
||||||
expiryDate: row.prescriptionExpiryDate ?? null,
|
expiryDate: row.prescriptionExpiryDate ?? null,
|
||||||
@@ -461,14 +368,8 @@ async function sendReminderEmail(
|
|||||||
language: Language,
|
language: Language,
|
||||||
isRepeatDaily: boolean = false
|
isRepeatDaily: boolean = false
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
if (!smtp.host || !smtp.user) {
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
|
||||||
return { success: false, error: "SMTP not configured" };
|
return { success: false, error: "SMTP not configured" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,10 +435,11 @@ async function sendReminderEmail(
|
|||||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
|
const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr);
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${quantityText}</strong></td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -581,7 +483,7 @@ async function sendReminderEmail(
|
|||||||
|
|
||||||
${tr.stockReminder.description}
|
${tr.stockReminder.description}
|
||||||
|
|
||||||
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
|
${lowStock.map((r) => `${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
|
||||||
|
|
||||||
---
|
---
|
||||||
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||||
@@ -590,35 +492,19 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
||||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||||
|
|
||||||
try {
|
const emailResult = await sendEmailNotification({
|
||||||
const transporter = nodemailer.createTransport({
|
to: email,
|
||||||
host: smtpHost,
|
subject,
|
||||||
port: smtpPort,
|
text: plainText,
|
||||||
secure: smtpSecure,
|
html,
|
||||||
auth: {
|
from: smtp.from,
|
||||||
user: smtpUser,
|
});
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
if (!emailResult.success) {
|
||||||
from: smtpFrom,
|
return { success: false, error: emailResult.error ?? "Unknown error" };
|
||||||
to: email,
|
|
||||||
subject,
|
|
||||||
text: plainText,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
|
||||||
if (deliveryError) {
|
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
||||||
@@ -663,7 +549,8 @@ async function checkAndSendReminderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = loadReminderState();
|
const state = loadReminderState();
|
||||||
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
|
const userTimezone = getEffectiveTimezone(settings.timezone ?? null);
|
||||||
|
const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone
|
||||||
const userStateKey = `user_${settings.userId}`;
|
const userStateKey = `user_${settings.userId}`;
|
||||||
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
||||||
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
||||||
@@ -703,41 +590,8 @@ async function checkAndSendReminderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stockPushEnabled) {
|
if (stockPushEnabled) {
|
||||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
const pushPayload = buildStockReminderPushNotification(allLowStock, language);
|
||||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message);
|
||||||
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;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
|
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
|
||||||
@@ -824,22 +678,9 @@ async function checkAndSendReminderForUser(
|
|||||||
let shoutrrrSuccess = false;
|
let shoutrrrSuccess = false;
|
||||||
|
|
||||||
if (prescriptionEmailEnabled) {
|
if (prescriptionEmailEnabled) {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
if (smtp.host && smtp.user) {
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const subject =
|
const subject =
|
||||||
allPrescriptionLow.length === 1
|
allPrescriptionLow.length === 1
|
||||||
? tr.prescriptionReminder.subjectSingle
|
? tr.prescriptionReminder.subjectSingle
|
||||||
@@ -919,16 +760,15 @@ async function checkAndSendReminderForUser(
|
|||||||
`;
|
`;
|
||||||
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}` : ""}`;
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
from: smtp.from,
|
||||||
});
|
});
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Unknown error");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
emailSuccess = true;
|
emailSuccess = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -939,35 +779,8 @@ async function checkAndSendReminderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prescriptionPushEnabled) {
|
if (prescriptionPushEnabled) {
|
||||||
const titleParts: string[] = [];
|
const pushPayload = buildPrescriptionReminderPushNotification(allPrescriptionLow, language);
|
||||||
if (emptyRx.length > 0)
|
const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message);
|
||||||
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;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
|
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { userSettings } from "../db/schema.js";
|
||||||
|
import type { Language } from "../i18n/translations.js";
|
||||||
|
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
|
||||||
|
|
||||||
|
export type UserSettings = {
|
||||||
|
userId: number;
|
||||||
|
timezone?: string | null;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
notificationEmail: string | null;
|
||||||
|
emailStockReminders: boolean;
|
||||||
|
emailIntakeReminders: boolean;
|
||||||
|
emailPrescriptionReminders: boolean;
|
||||||
|
shoutrrrEnabled: boolean;
|
||||||
|
shoutrrrUrl: string | null;
|
||||||
|
shoutrrrStockReminders: boolean;
|
||||||
|
shoutrrrIntakeReminders: boolean;
|
||||||
|
shoutrrrPrescriptionReminders: boolean;
|
||||||
|
reminderDaysBefore: number;
|
||||||
|
repeatDailyReminders: boolean;
|
||||||
|
skipRemindersForTakenDoses: boolean;
|
||||||
|
repeatRemindersEnabled: boolean;
|
||||||
|
reminderRepeatIntervalMinutes: number;
|
||||||
|
maxNaggingReminders: number;
|
||||||
|
lowStockDays: number;
|
||||||
|
normalStockDays: number;
|
||||||
|
highStockDays: number;
|
||||||
|
language: Language;
|
||||||
|
stockCalculationMode: "automatic" | "manual";
|
||||||
|
shareMedicationOverview: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
|
lastAutoEmailSent: string | null;
|
||||||
|
lastNotificationType: string | null;
|
||||||
|
lastNotificationChannel: string | null;
|
||||||
|
lastReminderMedName: string | null;
|
||||||
|
lastReminderTakenBy: string | null;
|
||||||
|
lastStockReminderSent: string | null;
|
||||||
|
lastStockReminderChannel: string | null;
|
||||||
|
lastStockReminderMedNames: string | null;
|
||||||
|
lastPrescriptionReminderSent: string | null;
|
||||||
|
lastPrescriptionReminderChannel: string | null;
|
||||||
|
lastPrescriptionReminderMedNames: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
const normalizedMessage = errorMessage.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedMessage.includes("smtp rejected all recipients") ||
|
||||||
|
normalizedMessage.includes("all recipients were rejected") ||
|
||||||
|
normalizedMessage.includes("recipient address rejected") ||
|
||||||
|
normalizedMessage.includes("nullmx")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
code: "EMAIL_RECIPIENT_REJECTED",
|
||||||
|
message: `Failed to send email: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
code: "SMTP_DELIVERY_UNCONFIRMED",
|
||||||
|
message: `Failed to send email: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
code: "TEST_EMAIL_FAILED",
|
||||||
|
message: `Failed to send email: ${errorMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationProvider(url: string): string {
|
||||||
|
if (url.startsWith("discord://")) return "discord";
|
||||||
|
if (url.startsWith("telegram://")) return "telegram";
|
||||||
|
if (url.startsWith("gotify://")) return "gotify";
|
||||||
|
if (url.startsWith("pushover://")) return "pushover";
|
||||||
|
if (isNtfyNotificationUrl(url)) return "ntfy";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname || "https";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function envBool(key: string, defaultVal: boolean): boolean {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (val === undefined) return defaultVal;
|
||||||
|
return val === "true" || val === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function envInt(key: string, defaultVal: number): number {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (val === undefined) return defaultVal;
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSettings() {
|
||||||
|
return {
|
||||||
|
timezone: "",
|
||||||
|
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||||
|
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||||
|
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||||
|
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||||
|
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
|
||||||
|
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||||
|
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||||
|
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||||
|
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||||
|
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
|
||||||
|
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||||
|
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||||
|
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||||
|
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||||||
|
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||||||
|
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||||||
|
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||||||
|
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||||||
|
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||||||
|
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||||
|
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||||
|
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
|
||||||
|
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
||||||
|
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
lastAutoEmailSent: null,
|
||||||
|
lastNotificationType: null,
|
||||||
|
lastNotificationChannel: null,
|
||||||
|
lastReminderMedName: null,
|
||||||
|
lastReminderTakenBy: null,
|
||||||
|
lastStockReminderSent: null,
|
||||||
|
lastStockReminderChannel: null,
|
||||||
|
lastStockReminderMedNames: null,
|
||||||
|
lastPrescriptionReminderSent: null,
|
||||||
|
lastPrescriptionReminderChannel: null,
|
||||||
|
lastPrescriptionReminderMedNames: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntlWithSupportedValuesOf = typeof Intl & {
|
||||||
|
supportedValuesOf?: (key: string) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedTimezones: Set<string> | null = null;
|
||||||
|
|
||||||
|
function getTimezoneSet(): Set<string> {
|
||||||
|
if (cachedTimezones) return cachedTimezones;
|
||||||
|
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
|
||||||
|
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
|
||||||
|
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
|
||||||
|
return cachedTimezones;
|
||||||
|
}
|
||||||
|
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
|
||||||
|
return cachedTimezones;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableTimezones(): string[] {
|
||||||
|
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSettingsTimezone(value: string | null | undefined): string {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return getTimezoneSet().has(trimmed) ? trimmed : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNotificationHostname(hostnameRaw: string): string | null {
|
||||||
|
const hostname = hostnameRaw.toLowerCase();
|
||||||
|
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||||
|
return "Localhost URLs are not allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (ipMatch) {
|
||||||
|
const [, a, b] = ipMatch.map(Number);
|
||||||
|
if (
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
(a === 169 && b === 254)
|
||||||
|
) {
|
||||||
|
return "Private IP addresses are not allowed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".lan") ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return "Internal hostnames are not allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNotificationUrl(
|
||||||
|
urlStr: string
|
||||||
|
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
||||||
|
try {
|
||||||
|
if (urlStr.startsWith("discord://")) {
|
||||||
|
const parsedDiscord = new URL(urlStr);
|
||||||
|
const webhookId = parsedDiscord.hostname;
|
||||||
|
const webhookToken = parsedDiscord.username;
|
||||||
|
|
||||||
|
if (!webhookId || !webhookToken) {
|
||||||
|
return { error: "Invalid Discord URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(webhookId)) {
|
||||||
|
return { error: "Invalid Discord webhook ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
|
||||||
|
return { error: "Invalid Discord webhook token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
|
||||||
|
return { url: discordWebhookUrl, isNtfy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNtfy = isNtfyNotificationUrl(urlStr);
|
||||||
|
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||||
|
const parsed = new URL(normalizedUrl);
|
||||||
|
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostValidationError = validateNotificationHostname(parsed.hostname);
|
||||||
|
if (hostValidationError) {
|
||||||
|
return { error: hostValidationError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||||
|
const auth =
|
||||||
|
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
|
||||||
|
|
||||||
|
return { url: reconstructedUrl, isNtfy, auth };
|
||||||
|
} catch {
|
||||||
|
return { error: "Invalid URL format" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateUserSettings(userId: number) {
|
||||||
|
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
[settings] = await db
|
||||||
|
.insert(userSettings)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
...getDefaultSettings(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserSettingsFromDb(userId: number): Promise<UserSettings> {
|
||||||
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
|
return {
|
||||||
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
|
emailEnabled: settings.emailEnabled,
|
||||||
|
notificationEmail: settings.notificationEmail,
|
||||||
|
emailStockReminders: settings.emailStockReminders,
|
||||||
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
|
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||||
|
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||||
|
shoutrrrUrl: settings.shoutrrrUrl,
|
||||||
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||||
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
|
lowStockDays: settings.lowStockDays,
|
||||||
|
normalStockDays: settings.normalStockDays,
|
||||||
|
highStockDays: settings.highStockDays,
|
||||||
|
language: settings.language as Language,
|
||||||
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
|
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
|
lastNotificationType: settings.lastNotificationType,
|
||||||
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
|
lastReminderMedName: settings.lastReminderMedName ?? null,
|
||||||
|
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
||||||
|
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
||||||
|
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
||||||
|
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
||||||
|
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
||||||
|
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||||
|
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
|
||||||
|
const allSettings = await db.select().from(userSettings);
|
||||||
|
return allSettings.map((settings) => ({
|
||||||
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
|
emailEnabled: settings.emailEnabled,
|
||||||
|
notificationEmail: settings.notificationEmail,
|
||||||
|
emailStockReminders: settings.emailStockReminders,
|
||||||
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
|
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||||
|
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||||
|
shoutrrrUrl: settings.shoutrrrUrl,
|
||||||
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||||
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
|
lowStockDays: settings.lowStockDays,
|
||||||
|
normalStockDays: settings.normalStockDays,
|
||||||
|
highStockDays: settings.highStockDays,
|
||||||
|
language: settings.language as Language,
|
||||||
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
|
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
|
lastNotificationType: settings.lastNotificationType,
|
||||||
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
|
lastReminderMedName: settings.lastReminderMedName ?? null,
|
||||||
|
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
||||||
|
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
||||||
|
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
||||||
|
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
||||||
|
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
||||||
|
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||||
|
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import sensible from "@fastify/sensible";
|
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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
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
|
||||||
@@ -102,7 +102,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
|
|
||||||
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" });
|
||||||
await app.register(jwt, {
|
await app.register(jwtPlugin, {
|
||||||
secret: "test-jwt-secret-12345",
|
secret: "test-jwt-secret-12345",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
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 { 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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
@@ -77,8 +77,8 @@ async function createUser(username: string) {
|
|||||||
return Number(result.rows[0].id);
|
return Number(result.rows[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
const token = app.jwt.sign({ sub: userId, username });
|
const token = await app.jwt.sign({ sub: userId, username });
|
||||||
return `access_token=${token}`;
|
return `access_token=${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ describe("Real business route authz contracts", () => {
|
|||||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
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(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
@@ -277,7 +277,7 @@ describe("Real business route authz contracts", () => {
|
|||||||
it("scopes medication listing and export output to the authenticated user", async () => {
|
it("scopes medication listing and export output to the authenticated user", async () => {
|
||||||
const ownerId = await createUser("owner-medications");
|
const ownerId = await createUser("owner-medications");
|
||||||
const otherId = await createUser("other-medications");
|
const otherId = await createUser("other-medications");
|
||||||
const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications");
|
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-medications");
|
||||||
|
|
||||||
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
|
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
|
||||||
await seedMedication({ userId: otherId, name: "Other User Med" });
|
await seedMedication({ userId: otherId, name: "Other User Med" });
|
||||||
@@ -306,7 +306,7 @@ describe("Real business route authz contracts", () => {
|
|||||||
it("returns 404 when a user updates or deletes another user's medication", async () => {
|
it("returns 404 when a user updates or deletes another user's medication", async () => {
|
||||||
const ownerId = await createUser("owner-update");
|
const ownerId = await createUser("owner-update");
|
||||||
const otherId = await createUser("other-update");
|
const otherId = await createUser("other-update");
|
||||||
const otherCookie = buildSessionCookie(app, otherId, "other-update");
|
const otherCookie = await buildSessionCookie(app, otherId, "other-update");
|
||||||
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
|
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
|
||||||
|
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
@@ -336,8 +336,8 @@ describe("Real business route authz contracts", () => {
|
|||||||
it("scopes dose reads and writes to the authenticated user", async () => {
|
it("scopes dose reads and writes to the authenticated user", async () => {
|
||||||
const ownerId = await createUser("owner-dose");
|
const ownerId = await createUser("owner-dose");
|
||||||
const otherId = await createUser("other-dose");
|
const otherId = await createUser("other-dose");
|
||||||
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
|
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-dose");
|
||||||
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
|
const otherCookie = await buildSessionCookie(app, otherId, "other-dose");
|
||||||
|
|
||||||
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
|
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
|
||||||
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
|
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
|
||||||
@@ -370,7 +370,7 @@ describe("Real business route authz contracts", () => {
|
|||||||
it("enforces medication ownership on refill history and report generation", async () => {
|
it("enforces medication ownership on refill history and report generation", async () => {
|
||||||
const ownerId = await createUser("owner-refill");
|
const ownerId = await createUser("owner-refill");
|
||||||
const otherId = await createUser("other-refill");
|
const otherId = await createUser("other-refill");
|
||||||
const otherCookie = buildSessionCookie(app, otherId, "other-refill");
|
const otherCookie = await buildSessionCookie(app, otherId, "other-refill");
|
||||||
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
|
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
|
||||||
await seedRefill({ userId: ownerId, medicationId });
|
await seedRefill({ userId: ownerId, medicationId });
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ describe("Real business route authz contracts", () => {
|
|||||||
it("scopes share people to the authenticated user's medications", async () => {
|
it("scopes share people to the authenticated user's medications", async () => {
|
||||||
const ownerId = await createUser("owner-share");
|
const ownerId = await createUser("owner-share");
|
||||||
const otherId = await createUser("other-share");
|
const otherId = await createUser("other-share");
|
||||||
const ownerCookie = buildSessionCookie(app, ownerId, "owner-share");
|
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-share");
|
||||||
|
|
||||||
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
|
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
|
||||||
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
|
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
|
||||||
|
|||||||
@@ -248,10 +248,10 @@ describe("Database Client Utilities", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create .write-test file", () => {
|
it("should not leave .write-test residue", () => {
|
||||||
const result = ensureDataDirectory(testDir);
|
const result = ensureDataDirectory(testDir);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(existsSync(resolve(testDir, ".write-test"))).toBe(true);
|
expect(existsSync(resolve(testDir, ".write-test"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error for invalid path", () => {
|
it("should return error for invalid path", () => {
|
||||||
|
|||||||
@@ -41,16 +41,22 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
|||||||
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||||
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
vi.doMock("../db/db-utils.js", () => ({
|
vi.doMock("../db/path-utils.js", () => ({
|
||||||
buildDbUrl: vi.fn(),
|
|
||||||
getDataDir: vi.fn(),
|
getDataDir: vi.fn(),
|
||||||
|
buildDbUrl: vi.fn(),
|
||||||
ensureDataDirectory,
|
ensureDataDirectory,
|
||||||
getDbPaths,
|
getDbPaths,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock("../db/migration-utils.js", () => ({
|
||||||
runDrizzleMigrations,
|
runDrizzleMigrations,
|
||||||
runAlterMigrations,
|
runAlterMigrations,
|
||||||
|
ensureDefaultUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock("../db/repair-utils.js", () => ({
|
||||||
repairTrailingHyphenDoseIds,
|
repairTrailingHyphenDoseIds,
|
||||||
repairOrphanedDoseIds,
|
repairOrphanedDoseIds,
|
||||||
ensureDefaultUser,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const log = {
|
const log = {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
calculateUsageInRange,
|
||||||
|
normalizeDateTime,
|
||||||
|
parseIntakesWithUnits,
|
||||||
|
parseRawIntakeUnits,
|
||||||
|
} from "../services/medications-service.js";
|
||||||
|
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||||
|
|
||||||
|
describe("medications-service decomposition regression", () => {
|
||||||
|
it("preserves intake unit parsing from unified intakes_json", () => {
|
||||||
|
const intakesJson = JSON.stringify([
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", intakeUnit: "ml" },
|
||||||
|
{ usage: 2, every: 1, start: "2026-01-01T20:00:00.000Z", intakeUnit: "bogus" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parseRawIntakeUnits(intakesJson)).toEqual(["ml", null]);
|
||||||
|
|
||||||
|
const parsed = parseIntakesWithUnits(
|
||||||
|
intakesJson,
|
||||||
|
{
|
||||||
|
usageJson: "[1,2]",
|
||||||
|
everyJson: "[1,1]",
|
||||||
|
startJson: '["2026-01-01T08:00:00.000Z","2026-01-01T20:00:00.000Z"]',
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed[0]?.intakeUnit).toBe("ml");
|
||||||
|
expect(parsed[1]?.intakeUnit).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes date-time values and keeps invalid input null-safe", () => {
|
||||||
|
expect(normalizeDateTime("2026-01-01T00:00:00.000Z")).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
expect(normalizeDateTime(1_767_225_600)).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
expect(normalizeDateTime("not-a-date")).toBeNull();
|
||||||
|
expect(normalizeDateTime(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates range usage with split-safe helper behavior", () => {
|
||||||
|
const usage = calculateUsageInRange(
|
||||||
|
[
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", scheduleMode: "interval", weekdays: [] },
|
||||||
|
{ usage: 0.5, every: 1, start: "2026-01-01T20:00:00.000Z", scheduleMode: "interval", weekdays: [] },
|
||||||
|
],
|
||||||
|
new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
new Date("2026-01-02T00:00:00.000Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usage).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("planner-service decomposition regression", () => {
|
||||||
|
it("keeps HTML escaping and SMTP delivery error parsing stable", () => {
|
||||||
|
expect(escapeHtml(`<script>alert('x')</script>`)).toBe("<script>alert('x')</script>");
|
||||||
|
expect(getDeliveryError({ accepted: ["ok@example.com"], rejected: [] })).toBeNull();
|
||||||
|
expect(getDeliveryError({ accepted: [], rejected: ["bad@example.com"] })).toContain("SMTP rejected all recipients");
|
||||||
|
expect(getDeliveryError({ accepted: [], rejected: [], response: "550 relay denied" })).toContain(
|
||||||
|
"550 relay denied"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps package type to expected planner units after service extraction", () => {
|
||||||
|
const tr = { common: { units: "units", ml: "ml", pills: "pills", puffs: "puffs", injections: "injections" } };
|
||||||
|
|
||||||
|
expect(isContainerPackage("bottle")).toBe(true);
|
||||||
|
expect(isContainerPackage("inhaler")).toBe(true);
|
||||||
|
expect(isContainerPackage("injection")).toBe(true);
|
||||||
|
expect(isContainerPackage("blister")).toBe(false);
|
||||||
|
expect(getPlannerUnit("tube", tr)).toBe("units");
|
||||||
|
expect(getPlannerUnit("liquid_container", tr)).toBe("ml");
|
||||||
|
expect(getPlannerUnit("bottle", tr)).toBe("pills");
|
||||||
|
expect(getPlannerUnit("inhaler", tr)).toBe("puffs");
|
||||||
|
expect(getPlannerUnit("injection", tr)).toBe("injections");
|
||||||
|
expect(getPlannerUnit("blister", tr)).toBe("pills");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings-service decomposition regression", () => {
|
||||||
|
it("keeps notification URL and classification helpers stable", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("../db/client.js", () => ({ db: {} }));
|
||||||
|
vi.doMock("../db/schema.js", () => ({ userSettings: { userId: "userId" } }));
|
||||||
|
|
||||||
|
const { classifyTestEmailFailure, getNotificationProvider, sanitizeNotificationUrl, validateNotificationHostname } =
|
||||||
|
await import("../services/settings-service.js");
|
||||||
|
|
||||||
|
expect(classifyTestEmailFailure(new Error("SMTP rejected all recipients: person@example.com"))).toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
code: "EMAIL_RECIPIENT_REJECTED",
|
||||||
|
});
|
||||||
|
expect(classifyTestEmailFailure(new Error("SMTP did not confirm accepted recipients."))).toMatchObject({
|
||||||
|
status: 502,
|
||||||
|
code: "SMTP_DELIVERY_UNCONFIRMED",
|
||||||
|
});
|
||||||
|
expect(getNotificationProvider("telegram://token@chat-id")).toBe("telegram");
|
||||||
|
expect(getNotificationProvider("https://hooks.slack.com/services/a/b/c")).toBe("hooks.slack.com");
|
||||||
|
|
||||||
|
expect(validateNotificationHostname("127.0.0.1")).toContain("not allowed");
|
||||||
|
expect(validateNotificationHostname("example.com")).toBeNull();
|
||||||
|
|
||||||
|
expect(sanitizeNotificationUrl("discord://abc@not-a-number")).toEqual({ error: "Invalid Discord webhook ID" });
|
||||||
|
expect(sanitizeNotificationUrl("ntfy://user:pass@ntfy.sh/topic")).toMatchObject({
|
||||||
|
url: "https://ntfy.sh/topic",
|
||||||
|
isNtfy: true,
|
||||||
|
auth: { user: "user", pass: "pass" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
|
||||||
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { dismissDosesForUser, markDoseTakenForUser } = await import("../services/dose-tracking-service.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
|
||||||
|
const start = "2025-01-01T08:00:00.000Z";
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
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
|
||||||
|
) VALUES (?, ?, 'Test Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 0)`,
|
||||||
|
args: [
|
||||||
|
options.id,
|
||||||
|
options.userId,
|
||||||
|
options.packCount ?? 1,
|
||||||
|
options.looseTablets ?? 0,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([start]),
|
||||||
|
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
|
||||||
|
args: [userId, stockCalculationMode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertDose(options: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
dismissed?: boolean;
|
||||||
|
takenAt?: number;
|
||||||
|
takenSource?: "manual" | "automatic" | "notification";
|
||||||
|
markedBy?: string | null;
|
||||||
|
}) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed, taken_at, taken_source, marked_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
options.doseId,
|
||||||
|
options.dismissed ? 1 : 0,
|
||||||
|
options.takenAt ?? Math.floor(Date.now() / 1000),
|
||||||
|
options.takenSource ?? "manual",
|
||||||
|
options.markedBy ?? null,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dose-tracking-service", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a taken row for a valid in-stock dose", async () => {
|
||||||
|
const userId = await createUser("dose-service-user");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
|
||||||
|
const result = await markDoseTakenForUser({
|
||||||
|
userId,
|
||||||
|
doseId: "5-0-1736064000000",
|
||||||
|
source: "notification",
|
||||||
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, status: "marked" });
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT dismissed, taken_source, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, "5-0-1736064000000"],
|
||||||
|
});
|
||||||
|
expect(rows.rows).toEqual([
|
||||||
|
expect.objectContaining({ dismissed: 0, taken_source: "notification", marked_by: null }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when the dose is already taken", async () => {
|
||||||
|
const userId = await createUser("dose-service-existing");
|
||||||
|
await insertDose({ userId, doseId: "5-0-1736064000000", dismissed: false });
|
||||||
|
|
||||||
|
const result = await markDoseTakenForUser({
|
||||||
|
userId,
|
||||||
|
doseId: "5-0-1736064000000",
|
||||||
|
source: "manual",
|
||||||
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, status: "already_taken" });
|
||||||
|
|
||||||
|
const count = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, "5-0-1736064000000"],
|
||||||
|
});
|
||||||
|
expect(Number(count.rows[0].count)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects taking a dose that is already skipped", async () => {
|
||||||
|
const userId = await createUser("dose-service-dismissed");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
await insertDose({
|
||||||
|
userId,
|
||||||
|
doseId: "5-0-1736064000000",
|
||||||
|
dismissed: true,
|
||||||
|
takenAt: 0,
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await markDoseTakenForUser({
|
||||||
|
userId,
|
||||||
|
doseId: "5-0-1736064000000",
|
||||||
|
source: "notification",
|
||||||
|
markedBy: "reminder",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, code: "ALREADY_SKIPPED", message: "Dose is already skipped" });
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT dismissed, taken_source, marked_by, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, "5-0-1736064000000"],
|
||||||
|
});
|
||||||
|
expect(rows.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_source: "manual", marked_by: null })]);
|
||||||
|
expect(Number(rows.rows[0].taken_at)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns OUT_OF_STOCK without mutating dose tracking", async () => {
|
||||||
|
const userId = await createUser("dose-service-stock");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
|
||||||
|
const result = await markDoseTakenForUser({
|
||||||
|
userId,
|
||||||
|
doseId: "5-0-1736064000000",
|
||||||
|
source: "notification",
|
||||||
|
markedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, code: "OUT_OF_STOCK", message: "Medication is out of stock" });
|
||||||
|
|
||||||
|
const count = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(Number(count.rows[0].count)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses new doses, stays idempotent for dismissed rows, and preserves real taken rows", async () => {
|
||||||
|
const userId = await createUser("dose-service-dismiss");
|
||||||
|
await insertDose({ userId, doseId: "5-1-1736064000000", dismissed: true, takenAt: 0 });
|
||||||
|
await insertDose({ userId, doseId: "5-2-1736064000000", dismissed: false });
|
||||||
|
|
||||||
|
const result = await dismissDosesForUser({
|
||||||
|
userId,
|
||||||
|
doseIds: ["5-0-1736064000000", "5-1-1736064000000", "5-2-1736064000000"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, dismissedCount: 1, alreadyTakenCount: 1 });
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(rows.rows).toEqual([
|
||||||
|
expect.objectContaining({ dose_id: "5-0-1736064000000", dismissed: 1, taken_at: 0 }),
|
||||||
|
expect.objectContaining({ dose_id: "5-1-1736064000000", dismissed: 1, taken_at: 0 }),
|
||||||
|
expect.objectContaining({ dose_id: "5-2-1736064000000", dismissed: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
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 { 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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
@@ -110,8 +110,8 @@ async function _insertShareToken(userId: number, token: string, takenBy: string)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
const token = app.jwt.sign({ sub: userId, username });
|
const token = await app.jwt.sign({ sub: userId, username });
|
||||||
return `access_token=${token}`;
|
return `access_token=${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ describe("Dose Tracking API", () => {
|
|||||||
|
|
||||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
await app.register(jwt, {
|
await app.register(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
@@ -164,7 +164,7 @@ describe("Dose Tracking API", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clearTables();
|
await clearTables();
|
||||||
userId = await createUser("dose-test-user");
|
userId = await createUser("dose-test-user");
|
||||||
cookieHeader = buildSessionCookie(app, userId, "dose-test-user");
|
cookieHeader = await buildSessionCookie(app, userId, "dose-test-user");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /doses/taken", () => {
|
describe("POST /doses/taken", () => {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import sensible from "@fastify/sensible";
|
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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
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
|
||||||
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
@@ -253,7 +254,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
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(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
@@ -307,10 +308,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.json().error).toBe("Access denied to medication");
|
expect(response.json().error).toBe("Access denied to medication");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should aggregate taken/dismissed doses and refill history", async () => {
|
it("should aggregate taken/skipped doses and refill history", async () => {
|
||||||
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||||
|
|
||||||
// One taken dose and one dismissed dose for the same medication
|
// One taken dose and one skipped dose for the same medication
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
VALUES (?, ?, ?, 0)`,
|
VALUES (?, ?, ?, 0)`,
|
||||||
@@ -337,13 +338,14 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data[medId].dosesTaken).toBe(1);
|
expect(data[medId].dosesTaken).toBe(1);
|
||||||
expect(data[medId].dosesDismissed).toBe(1);
|
expect(data[medId].dosesSkipped).toBe(1);
|
||||||
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
expect(data[medId].refills).toHaveLength(1);
|
expect(data[medId].refills).toHaveLength(1);
|
||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 2,
|
packsAdded: 2,
|
||||||
loosePillsAdded: 5,
|
loosePillsAdded: 5,
|
||||||
|
quantityAdded: 7,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -375,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 0,
|
loosePillsAdded: 0,
|
||||||
|
quantityAdded: 1,
|
||||||
usedPrescription: false,
|
usedPrescription: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1959,7 +1962,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const refillResponse = await app.inject({
|
const refillResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/medications/${medId}/refill`,
|
url: `/medications/${medId}/refill`,
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
@@ -2333,10 +2336,9 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
|
it.each([
|
||||||
const createResponse = await app.inject({
|
{
|
||||||
method: "POST",
|
label: "bottle",
|
||||||
url: "/medications",
|
|
||||||
payload: {
|
payload: {
|
||||||
name: "Bottle Zero Reset Med",
|
name: "Bottle Zero Reset Med",
|
||||||
packageType: "bottle",
|
packageType: "bottle",
|
||||||
@@ -2347,6 +2349,40 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
looseTablets: 20,
|
looseTablets: 20,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "inhaler",
|
||||||
|
payload: {
|
||||||
|
name: "Inhaler Zero Reset Med",
|
||||||
|
packageType: "inhaler",
|
||||||
|
doseUnit: "puffs",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 200,
|
||||||
|
looseTablets: 40,
|
||||||
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "injection",
|
||||||
|
payload: {
|
||||||
|
name: "Injection Zero Reset Med",
|
||||||
|
packageType: "injection",
|
||||||
|
doseUnit: "injections",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 12,
|
||||||
|
looseTablets: 4,
|
||||||
|
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])("should persist $label zero reset with packCount 0 and zero totals", async ({ payload }) => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
});
|
});
|
||||||
expect(createResponse.statusCode).toBe(200);
|
expect(createResponse.statusCode).toBe(200);
|
||||||
const medId = createResponse.json().id;
|
const medId = createResponse.json().id;
|
||||||
@@ -2442,6 +2478,81 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Liquid Stale Client Stock Correction",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 7,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 1050,
|
||||||
|
looseTablets: 1050,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 1,
|
||||||
|
totalPills: 150,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterCorrectionResponse.statusCode).toBe(200);
|
||||||
|
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(correctedMed).toBeTruthy();
|
||||||
|
expect(correctedMed.packCount).toBe(1);
|
||||||
|
expect(correctedMed.totalPills).toBe(150);
|
||||||
|
expect(correctedMed.looseTablets).toBe(150);
|
||||||
|
expect(correctedMed.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 150, quantityAdded: 150 },
|
||||||
|
});
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(150);
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(300);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(300);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0].quantityAdded).toBe(150);
|
||||||
|
|
||||||
|
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterRefillResponse.statusCode).toBe(200);
|
||||||
|
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(refilledMed).toBeTruthy();
|
||||||
|
expect(refilledMed.packCount).toBe(2);
|
||||||
|
expect(refilledMed.totalPills).toBe(300);
|
||||||
|
expect(refilledMed.looseTablets).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
it("should persist stockAdjustment in GET /medications", async () => {
|
it("should persist stockAdjustment in GET /medications", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3047,6 +3158,80 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discreteContainerMedications = [
|
||||||
|
{
|
||||||
|
label: "inhaler",
|
||||||
|
payload: {
|
||||||
|
name: "Asthma Inhaler",
|
||||||
|
packageType: "inhaler",
|
||||||
|
doseUnit: "puffs",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 200,
|
||||||
|
looseTablets: 200,
|
||||||
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
expectedDoseUnit: "puffs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "injection",
|
||||||
|
payload: {
|
||||||
|
name: "B12 Injection",
|
||||||
|
packageType: "injection",
|
||||||
|
doseUnit: "injections",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 12,
|
||||||
|
looseTablets: 12,
|
||||||
|
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
expectedDoseUnit: "injections",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
}: {
|
||||||
|
medId: number;
|
||||||
|
refillData: {
|
||||||
|
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
|
||||||
|
newStock: { packCount: number; totalPills: number; looseTablets: number };
|
||||||
|
};
|
||||||
|
visibleStockBeforeRefill: number;
|
||||||
|
expectedQuantityAdded: number;
|
||||||
|
expectedPacksAdded: number;
|
||||||
|
expectedAmountPerPackage?: number;
|
||||||
|
}) {
|
||||||
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
|
packsAdded: expectedPacksAdded,
|
||||||
|
quantityAdded: expectedQuantityAdded,
|
||||||
|
totalPillsAdded: expectedQuantityAdded,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedAmountPerPackage) {
|
||||||
|
expect(refillData.newStock.packCount).toBe(
|
||||||
|
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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",
|
||||||
@@ -3106,6 +3291,23 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.looseTablets).toBe(180);
|
expect(data.looseTablets).toBe(180);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(discreteContainerMedications)("should create and return $label type medication", async ({
|
||||||
|
payload,
|
||||||
|
expectedDoseUnit,
|
||||||
|
}) => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.packageType).toBe(payload.packageType);
|
||||||
|
expect(data.doseUnit).toBe(expectedDoseUnit);
|
||||||
|
expect(data.looseTablets).toBe(payload.looseTablets);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3240,6 +3442,196 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "bottle",
|
||||||
|
payload: {
|
||||||
|
...bottleMedication,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 10,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
|
||||||
|
expectedVisibleStockBeforeRefill: 10,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 0,
|
||||||
|
expectedPackCount: 0,
|
||||||
|
expectedLooseTablets: 110,
|
||||||
|
expectedTotalPills: 110,
|
||||||
|
expectedPersistedTotalPills: 100,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blister",
|
||||||
|
payload: {
|
||||||
|
...blisterMedication,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
expectedVisibleStockBeforeRefill: 10,
|
||||||
|
expectedQuantityAdded: 10,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 0,
|
||||||
|
expectedTotalPills: 20,
|
||||||
|
expectedPersistedTotalPills: null,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "liquid_container",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 100,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 100, quantityAdded: 100 },
|
||||||
|
expectedVisibleStockBeforeRefill: 10,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 100,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 110,
|
||||||
|
expectedTotalPills: 110,
|
||||||
|
expectedPersistedTotalPills: 110,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
])("should refill from the persisted stock baseline after prior consumption for $name", async ({
|
||||||
|
payload,
|
||||||
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
expectedPackCount,
|
||||||
|
expectedLooseTablets,
|
||||||
|
expectedTotalPills,
|
||||||
|
expectedPersistedTotalPills,
|
||||||
|
expectedStockAdjustment,
|
||||||
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
for (let day = 1; day <= 6; day += 1) {
|
||||||
|
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
|
||||||
|
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: refillPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded: expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(expectedPackCount);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(expectedPackCount);
|
||||||
|
expect(med.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(med.totalPills).toBe(expectedPersistedTotalPills);
|
||||||
|
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refill tube stock from the corrected visible baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...tubeMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: -6,
|
||||||
|
looseTablets: 10,
|
||||||
|
totalPills: 10,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 80, quantityAdded: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 80,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 80,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(84);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(84);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(2);
|
||||||
|
expect(med.looseTablets).toBe(84);
|
||||||
|
expect(med.totalPills).toBe(84);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3271,6 +3663,11 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3288,14 +3685,20 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const refillResponse = await app.inject({
|
const refillResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/medications/${medId}/refill`,
|
url: `/medications/${medId}/refill`,
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 180,
|
||||||
|
expectedQuantityAdded: 180,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(180);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(360);
|
expect(refillData.newStock.totalPills).toBe(360);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
@@ -3306,6 +3709,54 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.looseTablets).toBe(360);
|
expect(med.looseTablets).toBe(360);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 0,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
totalPills: 300,
|
||||||
|
looseTablets: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 5, loosePillsAdded: 750, quantityAdded: 750 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 300,
|
||||||
|
expectedQuantityAdded: 750,
|
||||||
|
expectedPacksAdded: 5,
|
||||||
|
expectedAmountPerPackage: 150,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(7);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(1050);
|
||||||
|
|
||||||
|
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.packCount).toBe(7);
|
||||||
|
expect(med.totalPills).toBe(1050);
|
||||||
|
expect(med.looseTablets).toBe(1050);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "liquid_container",
|
name: "liquid_container",
|
||||||
@@ -3321,11 +3772,13 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionRemainingRefills: 2,
|
prescriptionRemainingRefills: 2,
|
||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 180,
|
||||||
expectedPacksAdded: 1,
|
expectedPacksAdded: 1,
|
||||||
expectedLooseAdded: 180,
|
expectedLooseAdded: 180,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 360,
|
expectedTotalPills: 360,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tube",
|
name: "tube",
|
||||||
@@ -3336,20 +3789,29 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionRemainingRefills: 3,
|
prescriptionRemainingRefills: 3,
|
||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 80, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 80,
|
||||||
expectedPacksAdded: 2,
|
expectedPacksAdded: 2,
|
||||||
expectedLooseAdded: 80,
|
expectedLooseAdded: 80,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 160,
|
expectedTotalPills: 160,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
},
|
},
|
||||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||||
payload,
|
payload,
|
||||||
refillPayload,
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
expectedPacksAdded,
|
expectedPacksAdded,
|
||||||
expectedLooseAdded,
|
expectedLooseAdded,
|
||||||
expectedRemainingRefills,
|
expectedRemainingRefills,
|
||||||
expectedTotalPills,
|
expectedTotalPills,
|
||||||
|
expectedAmountPerPackage,
|
||||||
}) => {
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3366,8 +3828,17 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded: expectedLooseAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.prescription.used).toBe(true);
|
expect(refillData.prescription.used).toBe(true);
|
||||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||||
@@ -3381,6 +3852,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(historyResponse.json()[0]).toMatchObject({
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
packsAdded: expectedPacksAdded,
|
packsAdded: expectedPacksAdded,
|
||||||
loosePillsAdded: expectedLooseAdded,
|
loosePillsAdded: expectedLooseAdded,
|
||||||
|
quantityAdded: expectedLooseAdded,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3397,14 +3869,20 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const refillResponse = await app.inject({
|
const refillResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/medications/${medId}/refill`,
|
url: `/medications/${medId}/refill`,
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 80,
|
||||||
|
expectedQuantityAdded: 40,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(40);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(120);
|
expect(refillData.newStock.totalPills).toBe(120);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
|||||||
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default("3000"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
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"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
REFRESH_SECRET: z.string().min(10).optional(),
|
REFRESH_SECRET: z.string().min(10).optional(),
|
||||||
COOKIE_SECRET: z.string().min(10).optional(),
|
COOKIE_SECRET: z.string().min(10).optional(),
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default("15"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default("7"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -44,8 +45,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||||
});
|
});
|
||||||
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
|
|||||||
expect(result.PORT).toBe(3000);
|
expect(result.PORT).toBe(3000);
|
||||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||||
expect(result.LOG_LEVEL).toBe("info");
|
expect(result.LOG_LEVEL).toBe("info");
|
||||||
|
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
||||||
expect(result.AUTH_ENABLED).toBe(false);
|
expect(result.AUTH_ENABLED).toBe(false);
|
||||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||||
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OIDC URL validation", () => {
|
describe("OIDC URL validation", () => {
|
||||||
|
it("should accept valid PUBLIC_APP_URL", () => {
|
||||||
|
const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" });
|
||||||
|
expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid PUBLIC_APP_URL", () => {
|
||||||
|
expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ describe("Export/Import API", () => {
|
|||||||
expect(data.settings.notificationEmail).toBe("test@example.com");
|
expect(data.settings.notificationEmail).toBe("test@example.com");
|
||||||
expect(data.settings.language).toBe("de");
|
expect(data.settings.language).toBe("de");
|
||||||
expect(data.settings.lowStockDays).toBe(14);
|
expect(data.settings.lowStockDays).toBe(14);
|
||||||
|
expect(data.settings.shareStockStatus).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should exclude sensitive data by default", async () => {
|
it("should exclude sensitive data by default", async () => {
|
||||||
@@ -557,6 +558,45 @@ describe("Export/Import API", () => {
|
|||||||
expect(result.rows[0].loose_tablets).toBe(5);
|
expect(result.rows[0].loose_tablets).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts legacy shareStockStatus in imported settings but does not export or use it", async () => {
|
||||||
|
const importData = {
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
medications: [],
|
||||||
|
doseHistory: [],
|
||||||
|
refillHistory: [],
|
||||||
|
settings: {
|
||||||
|
language: "de",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: false,
|
||||||
|
},
|
||||||
|
shareLinks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const importResponse = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: importData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const exportResponse = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportResponse.statusCode).toBe(200);
|
||||||
|
expect(exportResponse.json().settings.shareStockStatus).toBeUndefined();
|
||||||
|
|
||||||
|
const settingsRow = await ctx.client.execute({
|
||||||
|
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(settingsRow.rows[0].share_medication_overview).toBe(0);
|
||||||
|
expect(settingsRow.rows[0].share_stock_status).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("should replace existing data on import", async () => {
|
it("should replace existing data on import", async () => {
|
||||||
// Create existing medication
|
// Create existing medication
|
||||||
await createTestMedication(ctx.client, {
|
await createTestMedication(ctx.client, {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
getThumbFilename,
|
||||||
|
MAX_IMAGE_UPLOAD_BYTES,
|
||||||
|
removeImageFiles,
|
||||||
|
streamToBuffer,
|
||||||
|
writeOptimizedImageSet,
|
||||||
|
} from "../utils/image-upload";
|
||||||
|
|
||||||
|
describe("image-upload utils", () => {
|
||||||
|
const MOCK_TIMESTAMP_MS = 1_700_000_000_000;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds thumb filename with and without extension", () => {
|
||||||
|
expect(getThumbFilename("avatar.png")).toBe("avatar-thumb.webp");
|
||||||
|
expect(getThumbFilename("avatar")).toBe("avatar-thumb.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes original and thumb files when they exist", () => {
|
||||||
|
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
|
||||||
|
tempDirs.push(imagesDir);
|
||||||
|
|
||||||
|
const imageFilename = "profile.webp";
|
||||||
|
const imagePath = join(imagesDir, imageFilename);
|
||||||
|
const thumbPath = join(imagesDir, getThumbFilename(imageFilename));
|
||||||
|
writeFileSync(imagePath, Buffer.from("image"));
|
||||||
|
writeFileSync(thumbPath, Buffer.from("thumb"));
|
||||||
|
|
||||||
|
removeImageFiles(imagesDir, imageFilename);
|
||||||
|
|
||||||
|
expect(() => readFileSync(imagePath)).toThrow();
|
||||||
|
expect(() => readFileSync(thumbPath)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers stream chunks and rejects payloads above max size", async () => {
|
||||||
|
const stream = Readable.from([Buffer.from("hello"), Buffer.from("world")]);
|
||||||
|
await expect(streamToBuffer(stream)).resolves.toEqual(Buffer.from("helloworld"));
|
||||||
|
|
||||||
|
const oversized = Readable.from([Buffer.alloc(MAX_IMAGE_UPLOAD_BYTES + 1)]);
|
||||||
|
await expect(streamToBuffer(oversized)).rejects.toThrow("IMAGE_TOO_LARGE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes optimized full and thumbnail webp variants", async () => {
|
||||||
|
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
|
||||||
|
tempDirs.push(imagesDir);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(MOCK_TIMESTAMP_MS);
|
||||||
|
|
||||||
|
const uploadBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 64,
|
||||||
|
height: 48,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 255, g: 0, b: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const result = await writeOptimizedImageSet(imagesDir, "med-42", uploadBuffer, {
|
||||||
|
maxEdgePx: 32,
|
||||||
|
thumbSizePx: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.filename).toBe("med-42-1700000000000.webp");
|
||||||
|
expect(result.thumbFilename).toBe("med-42-1700000000000-thumb.webp");
|
||||||
|
|
||||||
|
const optimizedMeta = await sharp(join(imagesDir, result.filename)).metadata();
|
||||||
|
const thumbMeta = await sharp(join(imagesDir, result.thumbFilename)).metadata();
|
||||||
|
expect(optimizedMeta.format).toBe("webp");
|
||||||
|
expect(thumbMeta.format).toBe("webp");
|
||||||
|
expect(Math.max(optimizedMeta.width ?? 0, optimizedMeta.height ?? 0)).toBeLessThanOrEqual(32);
|
||||||
|
expect(thumbMeta.width).toBe(16);
|
||||||
|
expect(thumbMeta.height).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockedEnv,
|
||||||
|
createNotificationActionContextMock,
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock,
|
||||||
|
sendPushNotificationMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockedEnv: {
|
||||||
|
PUBLIC_APP_URL: undefined as string | undefined,
|
||||||
|
CORS_ORIGINS: "http://localhost:5173" as string,
|
||||||
|
},
|
||||||
|
createNotificationActionContextMock: vi.fn(),
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock: vi.fn(),
|
||||||
|
sendPushNotificationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
existsSync: () => false,
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../db/path-utils.js", () => ({
|
||||||
|
getDataDir: () => "/tmp",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
},
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../services/notification-actions-service.js", () => ({
|
||||||
|
createNotificationActionContext: createNotificationActionContextMock,
|
||||||
|
storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/notifications/delivery.js", () => ({
|
||||||
|
getSmtpConfig: vi.fn(() => null),
|
||||||
|
sendEmailNotification: vi.fn(),
|
||||||
|
sendPushNotification: sendPushNotificationMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/notifications/state.js", () => ({
|
||||||
|
updateReminderSentTime: vi.fn(),
|
||||||
|
updateUserReminderSentTime: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils/scheduler-utils.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../utils/scheduler-utils.js")>("../utils/scheduler-utils.js");
|
||||||
|
const candidate = {
|
||||||
|
medName: "Calcium",
|
||||||
|
intakeTime: new Date("2026-01-05T11:15:00.000Z"),
|
||||||
|
intakeTimeStr: "11:15",
|
||||||
|
usage: 1,
|
||||||
|
takenBy: null,
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
getDateLocale: () => "en-US",
|
||||||
|
parseTakenByJson: () => [],
|
||||||
|
parseIntakesJson: () => [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2026-01-05T10:45:00.000Z",
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getTodaysIntakes: () => [candidate],
|
||||||
|
getUpcomingIntakes: () => [candidate],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
return {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSelectWhere<T>(result: T) {
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: async () => result,
|
||||||
|
}),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("intake reminder scheduler action wiring", () => {
|
||||||
|
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;
|
||||||
|
mockedEnv.PUBLIC_APP_URL = undefined;
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173";
|
||||||
|
createNotificationActionContextMock.mockReset();
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock.mockReset();
|
||||||
|
sendPushNotificationMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalTz === undefined) {
|
||||||
|
delete process.env.TZ;
|
||||||
|
} else {
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 11,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 41,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 11,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 11,
|
||||||
|
publicAppUrl: "https://app.example.com",
|
||||||
|
language: "en",
|
||||||
|
actionMode: "full",
|
||||||
|
doseIds: [expect.stringMatching(/^7-0-/)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1");
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses view-only actions for grouped intake reminders", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 13,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
userId: 13,
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 13,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 13,
|
||||||
|
publicAppUrl: "https://app.example.com",
|
||||||
|
language: "en",
|
||||||
|
actionMode: "view-only",
|
||||||
|
doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||||
|
sequenceId: undefined,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => {
|
||||||
|
createNotificationActionContextMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 12,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 12,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 12,
|
||||||
|
publicAppUrl: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: undefined,
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: undefined,
|
||||||
|
clickUrl: undefined,
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to push delivery without actions when action context generation fails", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 15,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed"));
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 15,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||||
|
"ntfy://ntfy.sh/medassist",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: undefined,
|
||||||
|
respondUrl: undefined,
|
||||||
|
viewUrl: undefined,
|
||||||
|
clickUrl: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed"));
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Sending intake reminders without actions after action context failure")
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs enriched push delivery failures with action context metadata", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 16,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 52,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" });
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 16,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy"));
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full"));
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => {
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 17,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
createNotificationActionContextMock.mockResolvedValue({
|
||||||
|
groupId: 77,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Taken",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" });
|
||||||
|
storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed"));
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 17,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77");
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id"));
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send intake reminders for reminder-enabled medications with empty stock", async () => {
|
||||||
|
const selectMock = vi.mocked(mockedDb.select);
|
||||||
|
selectMock
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }]))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
mockSelectWhere([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 14,
|
||||||
|
name: "Calcium",
|
||||||
|
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: true,
|
||||||
|
intakesJson: "[]",
|
||||||
|
usageJson: "[]",
|
||||||
|
everyJson: "[]",
|
||||||
|
startJson: "[]",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||||
|
|
||||||
|
const logger = createLogger();
|
||||||
|
|
||||||
|
await checkAndSendIntakeRemindersForUser(
|
||||||
|
{
|
||||||
|
userId: 14,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "manual",
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
} as never,
|
||||||
|
logger as never
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createNotificationActionContextMock).not.toHaveBeenCalled();
|
||||||
|
expect(sendPushNotificationMock).not.toHaveBeenCalled();
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Skipping reminder-enabled medications with empty stock")
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("No reminder-eligible medications with stock remaining")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import sensible from "@fastify/sensible";
|
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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
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
|
||||||
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
@@ -208,7 +209,7 @@ describe("Integration Tests", () => {
|
|||||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
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(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -705,4 +705,39 @@ describe("medication enrichment", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps split module exports aligned with the canonical enrichment service", async () => {
|
||||||
|
const indexExports = await import("../services/medication-enrichment/index.js");
|
||||||
|
const searchExports = await import("../services/medication-enrichment/search.js");
|
||||||
|
const adapterExports = await import("../services/medication-enrichment/adapters.js");
|
||||||
|
const canonical = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
expect(indexExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment);
|
||||||
|
expect(indexExports.enrichMedicationSelection).toBe(canonical.enrichMedicationSelection);
|
||||||
|
expect(searchExports.searchMedicationEnrichment).toBe(canonical.searchMedicationEnrichment);
|
||||||
|
expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT).toBe(
|
||||||
|
canonical.MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT
|
||||||
|
);
|
||||||
|
expect(adapterExports.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT).toBe(
|
||||||
|
canonical.MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns transport-safe 503 payload when search lookup fails unexpectedly", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
fetchMock.mockRejectedValue(new Error("network unavailable"));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/medication-enrichment/search?q=aspirin&limit=1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "Medication enrichment is temporarily unavailable.",
|
||||||
|
code: "MEDICATION_ENRICHMENT_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getNotificationActionLabels,
|
||||||
|
isNtfyNotificationUrl,
|
||||||
|
type PushNotificationAction,
|
||||||
|
renderNotificationActionPayload,
|
||||||
|
} from "../services/notifications/action-renderer.js";
|
||||||
|
|
||||||
|
function decodeRfc2047Base64(value: string): string {
|
||||||
|
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(match[1], "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: PushNotificationAction[] = [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Take",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: "Skip",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("notification action renderer", () => {
|
||||||
|
it("builds ntfy native actions without duplicate click headers", () => {
|
||||||
|
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||||
|
actions,
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
tags: ["pill"],
|
||||||
|
priority: 4,
|
||||||
|
sequenceId: "medassist-sequence",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.message).toBe("Body");
|
||||||
|
expect(result.headers).toMatchObject({
|
||||||
|
Tags: "pill",
|
||||||
|
Priority: "4",
|
||||||
|
"X-Sequence-ID": "medassist-sequence",
|
||||||
|
});
|
||||||
|
expect(result.headers.Click).toBeUndefined();
|
||||||
|
|
||||||
|
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
|
||||||
|
expect(parsedActions).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Skip",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ntfy click header when there are no native actions", () => {
|
||||||
|
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions,
|
||||||
|
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
|
||||||
|
expect(result.message).toBe("Body");
|
||||||
|
expect(result.headers.Actions).toBeTruthy();
|
||||||
|
expect(result.message).not.toContain("Respond:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "taken",
|
||||||
|
label: "Take",
|
||||||
|
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "skip",
|
||||||
|
label: "Überspringen",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "Öffnen",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
|
||||||
|
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Überspringen",
|
||||||
|
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "Öffnen",
|
||||||
|
url: "https://app.example.com/?date=2026-01-05",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses consistent action-form labels for English and German", () => {
|
||||||
|
expect(getNotificationActionLabels("en")).toEqual({
|
||||||
|
taken: "Take",
|
||||||
|
skip: "Skip",
|
||||||
|
respond: "Respond",
|
||||||
|
view: "View",
|
||||||
|
});
|
||||||
|
expect(getNotificationActionLabels("de")).toEqual({
|
||||||
|
taken: "Einnehmen",
|
||||||
|
skip: "Überspringen",
|
||||||
|
respond: "Antworten",
|
||||||
|
view: "Öffnen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends respond and view fallback links for non-ntfy providers", () => {
|
||||||
|
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
|
||||||
|
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||||
|
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.headers).toEqual({});
|
||||||
|
expect(result.message).toBe(
|
||||||
|
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify 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, fetchMock, mockLogger } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
const logger = {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
silent: vi.fn(),
|
||||||
|
level: "info",
|
||||||
|
child: vi.fn(),
|
||||||
|
};
|
||||||
|
logger.child.mockImplementation(() => logger);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
mockLogger: logger,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: "*",
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
OPENAPI_DOCS_ENABLED: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
const { notificationActionRoutes } = await import("../routes/notification-actions.js");
|
||||||
|
const { createNotificationActionContext } = await import("../services/notification-actions-service.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
function extractToken(url: string): string {
|
||||||
|
return url.split("/").at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||||
|
await testClient.execute("DELETE FROM notification_action_groups");
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
|
||||||
|
const start = "2026-01-05T08:00:00.000Z";
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
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
|
||||||
|
) VALUES (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`,
|
||||||
|
args: [
|
||||||
|
options.id,
|
||||||
|
options.userId,
|
||||||
|
options.packCount ?? 1,
|
||||||
|
options.looseTablets ?? 0,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([start]),
|
||||||
|
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUserSettings(
|
||||||
|
userId: number,
|
||||||
|
stockCalculationMode: "automatic" | "manual" = "automatic",
|
||||||
|
overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {}
|
||||||
|
) {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedContext(options: { userId: number; doseId: string }) {
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId: options.userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: [options.doseId],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
respondToken: extractToken(context!.respondUrl!),
|
||||||
|
takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url),
|
||||||
|
skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url),
|
||||||
|
context: context!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("notification action routes", () => {
|
||||||
|
let app: Awaited<ReturnType<typeof Fastify>>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv });
|
||||||
|
await app.register(notificationActionRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
mockedEnv.NODE_ENV = "test";
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
mockLogger.info.mockClear();
|
||||||
|
mockLogger.warn.mockClear();
|
||||||
|
mockLogger.error.mockClear();
|
||||||
|
mockLogger.debug.mockClear();
|
||||||
|
mockLogger.trace.mockClear();
|
||||||
|
mockLogger.fatal.mockClear();
|
||||||
|
mockLogger.child.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HTML for respond tokens without mutating state", async () => {
|
||||||
|
const userId = await createUser("notification-route-get");
|
||||||
|
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["content-type"]).toContain("text/html");
|
||||||
|
expect(response.body).toContain("Respond to reminder");
|
||||||
|
expect(response.body).toContain("Take your medication now");
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: `SELECT g.resolved_action, t.used_at
|
||||||
|
FROM notification_action_groups g
|
||||||
|
INNER JOIN notification_action_tokens t ON t.group_id = g.id
|
||||||
|
WHERE t.kind = 'respond'`,
|
||||||
|
});
|
||||||
|
expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => {
|
||||||
|
const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" });
|
||||||
|
expect(missing.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const userId = await createUser("notification-route-errors");
|
||||||
|
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const nonRespond = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
expect(nonRespond.statusCode).toBe(405);
|
||||||
|
expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" });
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET expires_at = ?",
|
||||||
|
args: [new Date(0)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` });
|
||||||
|
expect(expired.statusCode).toBe(410);
|
||||||
|
expect(expired.json()).toEqual({ error: "Notification action has expired" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an already-processed HTML state for resolved respond tokens", async () => {
|
||||||
|
const userId = await createUser("notification-route-resolved");
|
||||||
|
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?",
|
||||||
|
args: [new Date()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain("Already processed");
|
||||||
|
expect(response.body).toContain(
|
||||||
|
"This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => {
|
||||||
|
const userId = await createUser("notification-route-skip");
|
||||||
|
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
payload: { action: "skip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId,
|
||||||
|
groupId: expect.any(Number),
|
||||||
|
tokenKind: "respond",
|
||||||
|
doseCount: 1,
|
||||||
|
hasViewUrl: true,
|
||||||
|
requestedAction: "skip",
|
||||||
|
}),
|
||||||
|
"[NotificationActions] Recorded notification action"
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissedRow = await testClient.execute({
|
||||||
|
sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, "5-0-1736064000000"],
|
||||||
|
});
|
||||||
|
expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]);
|
||||||
|
|
||||||
|
const groupRow = await testClient.execute({
|
||||||
|
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||||
|
});
|
||||||
|
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||||
|
|
||||||
|
const conflict = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conflict.statusCode).toBe(200);
|
||||||
|
expect(conflict.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
action: "skip",
|
||||||
|
alreadyProcessed: true,
|
||||||
|
message: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps legacy dismiss respond actions working as a skip alias", async () => {
|
||||||
|
const userId = await createUser("notification-route-dismiss-alias");
|
||||||
|
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
payload: { action: "dismiss" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||||
|
|
||||||
|
const groupRow = await testClient.execute({
|
||||||
|
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||||
|
});
|
||||||
|
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => {
|
||||||
|
const userId = await createUser("notification-route-taken-followup");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const firstResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResponse.statusCode).toBe(200);
|
||||||
|
expect(firstResponse.json()).toEqual({ success: true, action: "taken" });
|
||||||
|
|
||||||
|
const followUpHtml = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(followUpHtml.statusCode).toBe(200);
|
||||||
|
expect(followUpHtml.body).toContain(
|
||||||
|
"This dose is already marked as taken. If you need to change it, open MedAssist and undo it there."
|
||||||
|
);
|
||||||
|
|
||||||
|
const followUpJson = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
payload: { action: "skip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(followUpJson.statusCode).toBe(200);
|
||||||
|
expect(followUpJson.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
action: "taken",
|
||||||
|
alreadyProcessed: true,
|
||||||
|
message: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the original ntfy notification after a successful action with a view-only confirmation", async () => {
|
||||||
|
const userId = await createUser("notification-route-ntfy-delete");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic", {
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
|
});
|
||||||
|
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||||
|
expect(requestInit).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: "Take your medication now\n\n✅ This dose was marked as taken.",
|
||||||
|
redirect: "error",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.stringMatching(/^Basic /),
|
||||||
|
"X-Sequence-ID": context.sequenceId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionHeader = String((requestInit as { headers?: Record<string, string> }).headers?.Actions ?? "[]");
|
||||||
|
expect(JSON.parse(actionHeader)).toEqual([
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "View",
|
||||||
|
url: context.viewUrl,
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => {
|
||||||
|
const userId = await createUser("notification-route-ntfy-skip-delete");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic", {
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
|
});
|
||||||
|
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${skipToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||||
|
expect(requestInit).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: "Take your medication now\n\n⏭️ This intake was marked as skipped.",
|
||||||
|
redirect: "error",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.stringMatching(/^Basic /),
|
||||||
|
"X-Sequence-ID": context.sequenceId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when ntfy replacement, delete, and fallback clear all fail", async () => {
|
||||||
|
const userId = await createUser("notification-route-ntfy-delete-warn");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic", {
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
|
});
|
||||||
|
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(app.log.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestedAction: "taken" }),
|
||||||
|
expect.stringContaining("Failed to replace ntfy notification after resolved action")
|
||||||
|
);
|
||||||
|
expect(app.log.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestedAction: "taken" }),
|
||||||
|
expect.stringContaining("Failed to delete ntfy notification after resolved action")
|
||||||
|
);
|
||||||
|
expect(app.log.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ requestedAction: "taken" }),
|
||||||
|
expect.stringContaining("Failed to clear ntfy notification after delete fallback")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to clear when ntfy replacement and delete both fail", async () => {
|
||||||
|
const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic", {
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
|
});
|
||||||
|
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const [clearUrl, clearInit] = fetchMock.mock.calls[2] ?? [];
|
||||||
|
expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
|
||||||
|
expect(clearInit).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PUT",
|
||||||
|
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows browser-origin CORS requests for public notification action tokens", async () => {
|
||||||
|
const userId = await createUser("notification-route-cors");
|
||||||
|
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const preflight = await app.inject({
|
||||||
|
method: "OPTIONS",
|
||||||
|
url: `/notification-actions/${respondToken}?action=taken`,
|
||||||
|
headers: {
|
||||||
|
origin: "https://ntfy.danielvolz.org",
|
||||||
|
"access-control-request-method": "POST",
|
||||||
|
"access-control-request-headers": "content-type",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preflight.statusCode).toBe(204);
|
||||||
|
expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||||
|
expect(preflight.headers["access-control-allow-methods"]).toContain("POST");
|
||||||
|
expect(preflight.headers["access-control-allow-headers"]).toContain("content-type");
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
headers: {
|
||||||
|
origin: "https://ntfy.danielvolz.org",
|
||||||
|
},
|
||||||
|
payload: { action: "skip" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts standard HTML form posts on respond pages", async () => {
|
||||||
|
const userId = await createUser("notification-route-form-post");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}?action=taken`,
|
||||||
|
headers: {
|
||||||
|
accept: "text/html",
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
payload: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["content-type"]).toContain("text/html");
|
||||||
|
expect(response.body).toContain("Action recorded");
|
||||||
|
expect(response.body).toContain("The dose was marked as taken.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => {
|
||||||
|
const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" });
|
||||||
|
expect(missing.statusCode).toBe(404);
|
||||||
|
|
||||||
|
const expiredUserId = await createUser("notification-route-expired");
|
||||||
|
const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" });
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?",
|
||||||
|
args: [new Date(0), expiredUserId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const expired = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${respondToken}`,
|
||||||
|
payload: { action: "skip" },
|
||||||
|
});
|
||||||
|
expect(expired.statusCode).toBe(410);
|
||||||
|
|
||||||
|
const userId = await createUser("notification-route-stock");
|
||||||
|
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||||
|
await insertUserSettings(userId, "automatic");
|
||||||
|
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
|
||||||
|
const outOfStock = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/notification-actions/${takenToken}`,
|
||||||
|
});
|
||||||
|
expect(outOfStock.statusCode).toBe(409);
|
||||||
|
expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId,
|
||||||
|
groupId: expect.any(Number),
|
||||||
|
tokenKind: "taken",
|
||||||
|
doseCount: 1,
|
||||||
|
hasViewUrl: true,
|
||||||
|
requestedAction: "taken",
|
||||||
|
failedDoseIndex: 0,
|
||||||
|
code: "OUT_OF_STOCK",
|
||||||
|
}),
|
||||||
|
"[NotificationActions] Failed to record taken notification action"
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = await testClient.execute({
|
||||||
|
sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.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: {
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
CORS_ORIGINS: "http://localhost:5173,http://localhost:4173",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import(
|
||||||
|
"../services/notification-actions-service.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
function extractToken(url: string): string {
|
||||||
|
return url.split("/").at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||||
|
await testClient.execute("DELETE FROM notification_action_groups");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("notification-actions-service", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a notification action group with hashed tokens and app/view links", async () => {
|
||||||
|
const userId = await createUser("notify-actions-user");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
sequenceId: expect.stringMatching(/^medassist-/),
|
||||||
|
});
|
||||||
|
expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]);
|
||||||
|
|
||||||
|
const groups = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(1);
|
||||||
|
|
||||||
|
const tokenRows = await testClient.execute({
|
||||||
|
sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC",
|
||||||
|
});
|
||||||
|
expect(tokenRows.rows).toHaveLength(3);
|
||||||
|
|
||||||
|
const respondToken = extractToken(context!.respondUrl!);
|
||||||
|
const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond");
|
||||||
|
expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" }));
|
||||||
|
expect(respondRow?.token_hash).not.toBe(respondToken);
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(respondToken);
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a view-only context without mutation tokens", async () => {
|
||||||
|
const userId = await createUser("notify-actions-view-only");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Grouped reminder",
|
||||||
|
message: "Open the dashboard for details",
|
||||||
|
doseIds: ["9-0-1736064000000", "10-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
actionMode: "view-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toEqual({
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(0);
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an unresolved active group for the same dose set and schedule", async () => {
|
||||||
|
const userId = await createUser("notify-actions-reuse");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const first = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
const second = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second?.sequenceId).toBe(first?.sequenceId);
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups");
|
||||||
|
expect(groups.rows).toHaveLength(1);
|
||||||
|
expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId }));
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
|
||||||
|
const userId = await createUser("notify-actions-mobile");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
mockedEnv.PUBLIC_APP_URL = "http://localhost:5173";
|
||||||
|
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173";
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["9-0-1736064000000"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`,
|
||||||
|
viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!));
|
||||||
|
expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the date view when dose ids do not contain a medication id", async () => {
|
||||||
|
const userId = await createUser("notify-actions-fallback");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|
||||||
|
const context = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds: ["invalid-dose-id"],
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,7 +117,7 @@ describe("OIDC routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(302);
|
expect(res.statusCode).toBe(302);
|
||||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
expect(res.headers.location).toBe("http://localhost:5173");
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ describe("OIDC routes", () => {
|
|||||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||||
|
|
||||||
expect(res.statusCode).toBe(302);
|
expect(res.statusCode).toBe(302);
|
||||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
expect(res.headers.location).toBe("http://localhost:5173");
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ describe("OIDC routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(302);
|
expect(res.statusCode).toBe(302);
|
||||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
expect(res.headers.location).toBe("http://localhost:5173");
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
|||||||
OIDC_ENABLED: false,
|
OIDC_ENABLED: false,
|
||||||
OIDC_PROVIDER_NAME: "SSO",
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
CORS_ORIGINS: "https://app.example.com",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
testClient: client,
|
testClient: client,
|
||||||
@@ -351,7 +353,7 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["X-Sequence-ID"]).toEqual(expect.stringMatching(/^medassist-/));
|
||||||
|
expect(JSON.parse(headers.Actions ?? "[]")).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Skip",
|
||||||
|
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||||
|
method: "POST",
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(1);
|
||||||
|
|
||||||
|
const storedGroup = await testClient.execute(
|
||||||
|
"SELECT ntfy_original_message_id FROM notification_action_groups LIMIT 1"
|
||||||
|
);
|
||||||
|
expect(storedGroup.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-test-message-id" })]);
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||||
@@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-message-id" }) });
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.providerMessageId).toBe("ntfy-message-id");
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
"https://ntfy.sh/mytopic",
|
"https://ntfy.sh/mytopic",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -589,8 +630,39 @@ 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[medId].dosesTaken).toBe(1);
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
expect(body[medId].dosesDismissed).toBe(1);
|
expect(body[medId].dosesSkipped).toBe(1);
|
||||||
expect(body[medId].refills).toHaveLength(1);
|
expect(body[medId].refills).toHaveLength(1);
|
||||||
|
expect(body[medId].refills[0]).toMatchObject({
|
||||||
|
packsAdded: 1,
|
||||||
|
loosePillsAdded: 2,
|
||||||
|
usedPrescription: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => {
|
||||||
|
const medId = await seedMedication("Report Filter Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Alice`, 1700000000, 0],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700001200000-Bob`, 1700001200, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId], takenByFilter: ["Alice"] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
|
expect(body[medId].dosesSkipped).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||||
@@ -621,7 +693,9 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(body.medications).toHaveLength(1);
|
expect(body.medications).toHaveLength(1);
|
||||||
expect(body.doseHistory).toHaveLength(1);
|
expect(body.doseHistory).toHaveLength(1);
|
||||||
expect(body.refillHistory).toHaveLength(1);
|
expect(body.refillHistory).toHaveLength(1);
|
||||||
|
expect(body.refillHistory[0].quantityAdded).toBe(23);
|
||||||
expect(body.settings.language).toBe("de");
|
expect(body.settings.language).toBe("de");
|
||||||
|
expect(body.settings.shareStockStatus).toBeUndefined();
|
||||||
expect(body.shareLinks).toHaveLength(1);
|
expect(body.shareLinks).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -672,7 +746,15 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
doseHistory: [],
|
doseHistory: [],
|
||||||
refillHistory: [],
|
refillHistory: [
|
||||||
|
{
|
||||||
|
medicationRef: "med-1",
|
||||||
|
packsAdded: 0,
|
||||||
|
quantityAdded: 4,
|
||||||
|
usedPrescription: false,
|
||||||
|
refillDate: "2026-01-02T08:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
settings: {
|
settings: {
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
notificationEmail: null,
|
notificationEmail: null,
|
||||||
@@ -708,10 +790,24 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
expect(valid.statusCode).toBe(200);
|
expect(valid.statusCode).toBe(200);
|
||||||
expect(valid.json().imported.medications).toBe(1);
|
expect(valid.json().imported.medications).toBe(1);
|
||||||
|
expect(valid.json().imported.refillHistory).toBe(1);
|
||||||
|
|
||||||
const rows = await testClient.execute({
|
const rows = await testClient.execute({
|
||||||
sql: "SELECT name FROM medications WHERE user_id = 1",
|
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||||
});
|
});
|
||||||
expect(rows.rows[0].name).toBe("Imported Med");
|
expect(rows.rows[0].name).toBe("Imported Med");
|
||||||
|
|
||||||
|
const refillRows = await testClient.execute({
|
||||||
|
sql: "SELECT packs_added, loose_pills_added FROM refill_history WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(refillRows.rows).toHaveLength(1);
|
||||||
|
expect(refillRows.rows[0].packs_added).toBe(0);
|
||||||
|
expect(refillRows.rows[0].loose_pills_added).toBe(4);
|
||||||
|
|
||||||
|
const importedSettings = await testClient.execute({
|
||||||
|
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(importedSettings.rows[0].share_medication_overview).toBe(0);
|
||||||
|
expect(importedSettings.rows[0].share_stock_status).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -244,6 +244,46 @@ describe("Server Bootstrap", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow browser preflight requests on public notification action routes", async () => {
|
||||||
|
const origins = ["https://medtest.danielvolz.org"];
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
await app.register(cors, {
|
||||||
|
delegator: (request, callback) => {
|
||||||
|
if (request.raw.url?.startsWith("/notification-actions/")) {
|
||||||
|
callback(null, {
|
||||||
|
origin: true,
|
||||||
|
credentials: false,
|
||||||
|
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, { origin: origins, credentials: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/notification-actions/:token", async () => ({ ok: true }));
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "OPTIONS",
|
||||||
|
url: "/notification-actions/demo-token",
|
||||||
|
headers: {
|
||||||
|
origin: "https://ntfy.danielvolz.org",
|
||||||
|
"access-control-request-method": "POST",
|
||||||
|
"access-control-request-headers": "content-type",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(204);
|
||||||
|
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||||
|
expect(response.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||||
|
expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("should register cookie plugin", async () => {
|
it("should register cookie plugin", async () => {
|
||||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
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 { 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 { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
|
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
|
||||||
@@ -78,8 +78,8 @@ async function createUser(username: string) {
|
|||||||
return Number(result.rows[0].id);
|
return Number(result.rows[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
const token = app.jwt.sign({ sub: userId, username });
|
const token = await app.jwt.sign({ sub: userId, username });
|
||||||
return `access_token=${token}`;
|
return `access_token=${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
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(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
@@ -157,7 +157,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
|
headers: { cookie: await buildSessionCookie(app, userId, "settings-session-user") },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
@@ -267,7 +267,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
|
|
||||||
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
|
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
|
||||||
const userId = await createUser("api-key-session-user");
|
const userId = await createUser("api-key-session-user");
|
||||||
const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user");
|
const cookieHeader = await buildSessionCookie(app, userId, "api-key-session-user");
|
||||||
|
|
||||||
const firstCreate = await app.inject({
|
const firstCreate = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -331,7 +331,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
it("returns 404 when deleting an API key owned by a different user", async () => {
|
it("returns 404 when deleting an API key owned by a different user", async () => {
|
||||||
const ownerUserId = await createUser("api-key-owner");
|
const ownerUserId = await createUser("api-key-owner");
|
||||||
const otherUserId = await createUser("api-key-other-user");
|
const otherUserId = await createUser("api-key-other-user");
|
||||||
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
|
const otherCookieHeader = await buildSessionCookie(app, otherUserId, "api-key-other-user");
|
||||||
|
|
||||||
const keyId = await insertApiKey({
|
const keyId = await insertApiKey({
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
@@ -363,7 +363,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/settings/test-email",
|
url: "/settings/test-email",
|
||||||
headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") },
|
headers: { cookie: await buildSessionCookie(app, userId, "settings-email-recipient-user") },
|
||||||
payload: { email: "missing@example.com" },
|
payload: { email: "missing@example.com" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ describe("Settings and API key security contracts", () => {
|
|||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/settings/test-email",
|
url: "/settings/test-email",
|
||||||
headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
|
headers: { cookie: await buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
|
||||||
payload: { email: "person@example.com" },
|
payload: { email: "person@example.com" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { type Client, createClient } from "@libsql/client";
|
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 { afterEach } from "vitest";
|
||||||
|
import { jwtPlugin } from "../plugins/jwt.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Get migrations folder path
|
// Get migrations folder path
|
||||||
@@ -49,7 +50,7 @@ export async function buildTestApp(): Promise<TestContext> {
|
|||||||
|
|
||||||
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(jwtPlugin, {
|
||||||
secret: "test-jwt-secret",
|
secret: "test-jwt-secret",
|
||||||
cookie: { cookieName: "access_token", signed: false },
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
});
|
});
|
||||||
@@ -315,5 +316,13 @@ export async function clearTestData(client: Client): Promise<void> {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Set test environment
|
// Set test environment
|
||||||
|
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||||
process.env.AUTH_ENABLED = "false";
|
process.env.AUTH_ENABLED = "false";
|
||||||
|
process.env.OIDC_ENABLED = "false";
|
||||||
process.env.NODE_ENV = "test";
|
process.env.NODE_ENV = "test";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||||
|
process.env.AUTH_ENABLED = "false";
|
||||||
|
process.env.OIDC_ENABLED = "false";
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
|
|||||||
|
|
||||||
async function createMedication(options: {
|
async function createMedication(options: {
|
||||||
name: string;
|
name: string;
|
||||||
|
genericName?: string | null;
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
@@ -80,6 +81,7 @@ async function createMedication(options: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
genericName = null,
|
||||||
packCount = 1,
|
packCount = 1,
|
||||||
blistersPerPack = 1,
|
blistersPerPack = 1,
|
||||||
pillsPerBlister = 10,
|
pillsPerBlister = 10,
|
||||||
@@ -106,16 +108,17 @@ async function createMedication(options: {
|
|||||||
|
|
||||||
const result = await testClient.execute({
|
const result = await testClient.execute({
|
||||||
sql: `INSERT INTO medications (
|
sql: `INSERT INTO medications (
|
||||||
user_id, name, taken_by_json, package_type,
|
user_id, name, generic_name, taken_by_json, package_type,
|
||||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
stock_adjustment, last_stock_correction_at,
|
stock_adjustment, last_stock_correction_at,
|
||||||
usage_json, every_json, start_json, intakes_json,
|
usage_json, every_json, start_json, intakes_json,
|
||||||
is_obsolete, intake_reminders_enabled
|
is_obsolete, intake_reminders_enabled
|
||||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
args: [
|
args: [
|
||||||
1,
|
1,
|
||||||
name,
|
name,
|
||||||
|
genericName,
|
||||||
JSON.stringify(takenBy),
|
JSON.stringify(takenBy),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
|||||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
await createMedication({
|
||||||
|
name: "",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getLiquidReminderThresholds", () => {
|
describe("getLiquidReminderThresholds", () => {
|
||||||
|
|||||||
Vendored
+6
-9
@@ -1,5 +1,5 @@
|
|||||||
import "fastify";
|
import "fastify";
|
||||||
import "@fastify/jwt";
|
import type { JwtSignOptions, JwtVerifyOptions } from "../plugins/jwt.js";
|
||||||
|
|
||||||
// User type for authenticated requests
|
// User type for authenticated requests
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -23,19 +23,16 @@ declare module "fastify" {
|
|||||||
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||||
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||||
};
|
};
|
||||||
|
jwt: {
|
||||||
|
sign(payload: Record<string, unknown>, options?: JwtSignOptions): Promise<string>;
|
||||||
|
verify<T extends Record<string, unknown>>(token: string, options?: JwtVerifyOptions): Promise<T>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
user?: AuthUser | null;
|
user?: AuthUser | null;
|
||||||
authContext?: AuthContext;
|
authContext?: AuthContext;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
}
|
jwtVerify<T extends Record<string, unknown>>(options?: JwtVerifyOptions): Promise<T>;
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@fastify/jwt" {
|
|
||||||
interface FastifyJWT {
|
|
||||||
// Allow flexible payload for access and refresh tokens
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
user: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ export const log = {
|
|||||||
export type ServiceLogger = {
|
export type ServiceLogger = {
|
||||||
info: (msg: string) => void;
|
info: (msg: string) => void;
|
||||||
debug: (msg: string) => void;
|
debug: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
error: (msg: string) => void;
|
error: (msg: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
|
||||||
|
|
||||||
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||||
|
|
||||||
@@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
|
|||||||
return normalizePackageType(packageType) === "liquid_container";
|
return normalizePackageType(packageType) === "liquid_container";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
export function isPackageAmountPackageType(packageType?: string | null): boolean {
|
||||||
const normalized = normalizePackageType(packageType);
|
const normalized = normalizePackageType(packageType);
|
||||||
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
|
return normalized === "tube" || normalized === "liquid_container";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
|
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||||
|
return isPackageAmountPackageType(packageType) || isDiscreteCountPackageType(packageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" | "puffs" | "injections" {
|
||||||
const normalized = normalizePackageType(packageType);
|
const normalized = normalizePackageType(packageType);
|
||||||
if (normalized === "tube") return "units";
|
if (normalized === "tube") return "units";
|
||||||
if (normalized === "liquid_container") return "ml";
|
if (normalized === "liquid_container") return "ml";
|
||||||
|
if (normalized === "inhaler") return "puffs";
|
||||||
|
if (normalized === "injection") return "injections";
|
||||||
return "pills";
|
return "pills";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
|
|||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalDateOrdinal(date: Date): number {
|
||||||
|
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocalCalendarDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDateOnlyTimestamp(date: Date): number {
|
export function getDateOnlyTimestamp(date: Date): number {
|
||||||
return toDateOnly(date).getTime();
|
return toDateOnly(date).getTime();
|
||||||
}
|
}
|
||||||
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
|
|||||||
|
|
||||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
if (startTime >= lowerBound) {
|
if (startTime >= lowerBound) {
|
||||||
return startTime;
|
return startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
const lowerBoundDate = new Date(lowerBound);
|
||||||
return startTime + intervals * period;
|
const startOrdinal = getLocalDateOrdinal(startDate);
|
||||||
|
const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
|
||||||
|
const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
|
||||||
|
const wholeIntervals = Math.floor(daysBetween / intervalDays);
|
||||||
|
|
||||||
|
let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
|
||||||
|
while (candidate.getTime() < lowerBound) {
|
||||||
|
candidate = addLocalCalendarDays(candidate, intervalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateStart = Math.max(lowerBound, startTime);
|
const candidateStart = Math.max(lowerBound, startTime);
|
||||||
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
let occurrenceMs = startTime;
|
let occurrence = new Date(startDate);
|
||||||
if (occurrenceMs < rangeStartMs) {
|
if (occurrence.getTime() < rangeStartMs) {
|
||||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
const rangeStartDate = new Date(rangeStartMs);
|
||||||
occurrenceMs += intervals * period;
|
const startOrdinal = getLocalDateOrdinal(startDate);
|
||||||
|
const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
|
||||||
|
const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
|
||||||
|
const wholeIntervals = Math.floor(daysBetween / intervalDays);
|
||||||
|
occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
|
||||||
|
|
||||||
|
while (occurrence.getTime() < rangeStartMs) {
|
||||||
|
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
|
for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
|
||||||
if (occurrenceMs >= rangeStartMs) {
|
if (occurrenceMs >= rangeStartMs) {
|
||||||
callback(occurrenceMs);
|
callback(occurrenceMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||||
|
occurrenceMs = occurrence.getTime();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,6 +379,23 @@ export function getTimezone(): string {
|
|||||||
return process.env.TZ || "UTC";
|
return process.env.TZ || "UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidTimezone(value: string): boolean {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveTimezone(override?: string | null): string {
|
||||||
|
const normalized = override?.trim() ?? "";
|
||||||
|
if (normalized && isValidTimezone(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
/** Format a date in the configured timezone */
|
/** Format a date in the configured timezone */
|
||||||
export function formatInTimezone(date: Date, tz?: string): string {
|
export function formatInTimezone(date: Date, tz?: string): string {
|
||||||
return date.toLocaleString("de-DE", {
|
return date.toLocaleString("de-DE", {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { existsSync, mkdirSync } from "node:fs";
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import type { CookieSerializeOptions } from "@fastify/cookie";
|
import type { CookieSerializeOptions } from "@fastify/cookie";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/path-utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse comma-separated CORS origins string
|
* Parse comma-separated CORS origins string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
Configure MedAssist with environment variables in `.env`. Start from `.env.example`.
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PUID` | `1000` | User ID for container file permissions |
|
||||||
|
| `PGID` | `1000` | Group ID for container file permissions |
|
||||||
|
| `PORT` | `3000` | Backend API port |
|
||||||
|
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||||
|
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
|
||||||
|
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
|
||||||
|
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
|
||||||
|
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
|
||||||
|
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
|
||||||
|
|
||||||
|
API docs behavior:
|
||||||
|
|
||||||
|
- If `OPENAPI_DOCS_ENABLED` is unset, docs are enabled outside production and disabled in production.
|
||||||
|
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
|
||||||
|
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `AUTH_ENABLED` | `false` | Enable user authentication |
|
||||||
|
| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
|
||||||
|
| `FORM_LOGIN_ENABLED` | `true` | Enable username/password login |
|
||||||
|
| `JWT_SECRET` | — | Access token signing key; required when auth is enabled |
|
||||||
|
| `REFRESH_SECRET` | — | Refresh token signing key; required when auth is enabled |
|
||||||
|
| `COOKIE_SECRET` | — | Cookie signing key; required when auth is enabled |
|
||||||
|
| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
|
||||||
|
| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
|
||||||
|
|
||||||
|
Generate secrets with `openssl rand -hex 32`.
|
||||||
|
|
||||||
|
## API Keys
|
||||||
|
|
||||||
|
When `AUTH_ENABLED=true`, authenticated users can create API keys and call protected endpoints with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer ma_...
|
||||||
|
```
|
||||||
|
|
||||||
|
Available scopes:
|
||||||
|
|
||||||
|
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
|
||||||
|
- `write`: read and write access
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The token is shown only once after creation.
|
||||||
|
- Creating a new key deactivates previously active keys for the same user.
|
||||||
|
- API keys are stored hashed in the database.
|
||||||
|
|
||||||
|
API reference:
|
||||||
|
|
||||||
|
- Interactive docs: `/docs`
|
||||||
|
- OpenAPI JSON: `/docs/json`
|
||||||
|
- Key management endpoints:
|
||||||
|
- `GET /auth/api-keys`
|
||||||
|
- `POST /auth/api-keys`
|
||||||
|
- `DELETE /auth/api-keys/:id`
|
||||||
|
|
||||||
|
## OIDC / SSO
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
|
||||||
|
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
|
||||||
|
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||||
|
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||||
|
| `OIDC_REDIRECT_URI` | — | OIDC callback URL |
|
||||||
|
| `OIDC_SCOPES` | `openid profile email` | Requested scopes |
|
||||||
|
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Username claim |
|
||||||
|
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
|
||||||
|
| `OIDC_PROVIDER_NAME` | `SSO` | Login button label |
|
||||||
|
|
||||||
|
## Email (SMTP)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SMTP_HOST` | — | SMTP server hostname |
|
||||||
|
| `SMTP_PORT` | `587` | SMTP server port |
|
||||||
|
| `SMTP_USER` | — | SMTP username |
|
||||||
|
| `SMTP_PASS` | — | SMTP password |
|
||||||
|
| `SMTP_TOKEN` | — | OAuth2 or app token; takes precedence over `SMTP_PASS` |
|
||||||
|
| `SMTP_FROM` | — | Sender email address |
|
||||||
|
| `SMTP_SECURE` | `false` | Use TLS |
|
||||||
|
|
||||||
|
## Reminders
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
|
||||||
|
| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
|
||||||
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
|
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry warning |
|
||||||
|
|
||||||
|
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
|
||||||
|
|
||||||
|
## Push Notifications
|
||||||
|
|
||||||
|
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
|
||||||
|
|
||||||
|
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
|
||||||
|
|
||||||
|
## Default User Settings
|
||||||
|
|
||||||
|
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
|
||||||
@@ -6,7 +6,9 @@ Scope and behavior:
|
|||||||
|
|
||||||
- These values are applied only when a user's settings are created for the first time.
|
- 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.
|
- 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).
|
- This document only covers settings that have an environment-backed default.
|
||||||
|
- It is not intended to be a full inventory of every setting shown in the UI.
|
||||||
|
- UI-only settings without a `DEFAULT_*` variable, for example the dashboard section order toggle, are intentionally excluded.
|
||||||
|
|
||||||
## Email Defaults
|
## Email Defaults
|
||||||
|
|
||||||
@@ -47,6 +49,6 @@ Scope and behavior:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
||||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
| `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. |
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Start the Development Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Endpoints
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:5173`
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Frontend Dev Server Behind a Proxy
|
||||||
|
|
||||||
|
If the frontend dev server runs behind a reverse proxy or on a remote host, set these frontend-only environment variables before starting Vite:
|
||||||
|
|
||||||
|
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
|
||||||
|
|
||||||
|
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
|
||||||
|
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1`
|
||||||
|
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
|
||||||
|
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
|
||||||
|
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
|
||||||
|
- `VITE_HMR_PORT`: server-side websocket port for the Vite process
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
cd backend && npm run test:run
|
||||||
|
cd frontend && npm run test:run
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Push Notifications
|
||||||
|
|
||||||
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Recommended provider: `ntfy`.
|
||||||
|
|
||||||
|
Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with direct actions such as `Take`, `Skip`, and `View`.
|
||||||
|
|
||||||
|
## Supported URL Schemes
|
||||||
|
|
||||||
|
- `ntfy://`
|
||||||
|
- `discord://`
|
||||||
|
- `pushover://`
|
||||||
|
- `gotify://`
|
||||||
|
- `telegram://`
|
||||||
|
- direct `https://` webhooks
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables.
|
||||||
|
|
||||||
|
Push-related default variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||||
|
| `DEFAULT_SHOUTRRR_URL` | — | 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 |
|
||||||
|
|
||||||
|
For the full default-user-settings reference, see [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
|
||||||
|
|
||||||
|
## URL Examples
|
||||||
|
|
||||||
|
### ntfy
|
||||||
|
|
||||||
|
```text
|
||||||
|
ntfy://ntfy.sh/your-topic
|
||||||
|
ntfy://user:password@your-server.com/topic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pushover
|
||||||
|
|
||||||
|
```text
|
||||||
|
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gotify
|
||||||
|
|
||||||
|
```text
|
||||||
|
gotify://your-server.com/TOKEN
|
||||||
|
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
```text
|
||||||
|
discord://TOKEN@WEBHOOK_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
```text
|
||||||
|
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||||
|
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user