Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eb56885f9 | |||
| c5b08b28c1 | |||
| 1eb7579706 | |||
| e69e46f9fc | |||
| 1f5dd36b5c | |||
| 545793fdd2 | |||
| 2f5fc2d9e9 | |||
| 4212469cd5 | |||
| db602d8360 | |||
| a95c6e3657 | |||
| 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
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# Levels: debug, info, warn, error, silent
|
||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||
# and frontend browser console (via build-time injection)
|
||||
#
|
||||
# Behavior per level:
|
||||
# debug — all app logs + all HTTP request logs (including polling endpoints)
|
||||
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
|
||||
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
|
||||
# warn — only warnings and errors
|
||||
# error — only errors
|
||||
# silent — no logs
|
||||
# Server default timezone for scheduled reminders.
|
||||
# Users can override this in Settings -> Timezone.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Public base URL used for notification action links.
|
||||
# Required for intake reminder action buttons.
|
||||
# PUBLIC_APP_URL=https://medassist.example.com
|
||||
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
||||
|
||||
# Log level: debug, info, warn, error, silent
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Rate limit: max requests per minute per IP (default: 100)
|
||||
# Increase for development/testing environments
|
||||
# RATE_LIMIT_MAX=100
|
||||
|
||||
# API documentation UI + OpenAPI JSON
|
||||
# Default behavior: enabled outside production, disabled in production
|
||||
# When enabled, docs are available on /docs and /docs/json.
|
||||
# Docs are served on /docs and /docs/json.
|
||||
# Default behavior: enabled outside production, disabled in production.
|
||||
# Recommended:
|
||||
# development/staging: OPENAPI_DOCS_ENABLED=true
|
||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||
# development, staging: OPENAPI_DOCS_ENABLED=true
|
||||
# production: leave unset or set OPENAPI_DOCS_ENABLED=false
|
||||
# OPENAPI_DOCS_ENABLED=true
|
||||
|
||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# =============================================================================
|
||||
# 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_PRESCRIPTION_REMINDERS=true
|
||||
|
||||
# Push notifications (ntfy/gotify via Shoutrrr)
|
||||
# Push notifications (Shoutrrr URL)
|
||||
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||
# DEFAULT_SHOUTRRR_URL=
|
||||
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||
@@ -148,6 +144,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||
# UI defaults
|
||||
# DEFAULT_LANGUAGE=en # en or de
|
||||
# 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_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.
|
||||
- **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.
|
||||
- **`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.
|
||||
- **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`.
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
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
|
||||
git checkout main
|
||||
git pull origin main
|
||||
@@ -241,29 +245,10 @@ Apply these rules strictly:
|
||||
|
||||
## Task 3: Execute Release
|
||||
|
||||
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
|
||||
|
||||
```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
|
||||
Use the manual release flow. The repository no longer uses a public release helper script.
|
||||
|
||||
**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)
|
||||
|
||||
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 version link will point to a non-existent GitHub release page
|
||||
|
||||
### Manual Release (if script is not available)
|
||||
### Manual Release
|
||||
|
||||
1. Create release branch:
|
||||
```bash
|
||||
@@ -519,8 +504,7 @@ Ready for release?
|
||||
7. Check current version (git tag + package.json)
|
||||
8. Analyze changes → determine SemVer level
|
||||
9. If minor/major: check README.md for needed updates (Task 5)
|
||||
10. Run ./scripts/release.sh <patch|minor|major>
|
||||
(or manually: branch → version bump → PR → CI → merge → tag)
|
||||
10. Run the manual release flow: branch → version bump → PR → CI → merge → tag
|
||||
↓
|
||||
11. Write release notes (mandatory for minor/major)
|
||||
12. Publish GitHub release
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
# 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.
|
||||
- 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.
|
||||
- If `doku/report.md` is missing, create it immediately.
|
||||
- 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
|
||||
|
||||
1. Read `AGENTS.md` first.
|
||||
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
||||
1. Read `AGENTS.md` first when it exists in the workspace.
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v1.0.2
|
||||
- uses: actions/add-to-project@v2.0.0
|
||||
with:
|
||||
project-url: ${{ vars.PROJECT_URL }}
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- name: Read Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.current_tag.outputs.value }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||
steps:
|
||||
- name: Move project item to Done
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
script: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync fields
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
script: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- name: Build weekly summary
|
||||
id: summary
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
core.setOutput('body', body);
|
||||
|
||||
- name: Publish report issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
+36
-8
@@ -67,12 +67,12 @@ Thumbs.db
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
/.vscode/settings.json
|
||||
|
||||
# Keep shared VS Code settings
|
||||
# .vscode/ is NOT ignored - settings.json is useful for the team
|
||||
# Keep shared VS Code workspace files, but ignore personal editor settings.
|
||||
|
||||
# ===================
|
||||
# Misc
|
||||
# Local-only workspace artifacts (never upstream)
|
||||
# ===================
|
||||
*.local
|
||||
.cache/
|
||||
@@ -82,9 +82,37 @@ Thumbs.db
|
||||
.claude/
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
doku/
|
||||
doku/memory_notes.md
|
||||
doku/report.md
|
||||
plan/
|
||||
/doku/
|
||||
/plan/
|
||||
/.planning/
|
||||
.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",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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 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/Frontend_Tests-875%2F875-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-697%2F697-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -120,19 +120,19 @@ Share your medication schedule with others via a public link.
|
||||
</details>
|
||||
|
||||
### 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
|
||||
- Explicit review-and-apply flow with low-risk suggestions only
|
||||
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
||||
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
||||
- Optional medication lookup in the editor on desktop and mobile
|
||||
- Supports `RxNorm`, `openFDA`, and `EMA` with source labels
|
||||
- Review-and-apply flow with package-size suggestions when available
|
||||
- Manual entry remains available
|
||||
|
||||
### 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
|
||||
- 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
|
||||
- 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
|
||||
- Automatic stock updates after each refill
|
||||
|
||||
@@ -148,7 +148,6 @@ Share your medication schedule with others via a public link.
|
||||
|
||||
### Trip Planner
|
||||
- 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
|
||||
|
||||
### Reports
|
||||
@@ -168,7 +167,7 @@ Share your medication schedule with others via a public link.
|
||||
### Notifications
|
||||
- Email via SMTP
|
||||
- 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
|
||||
- Fully self-hosted
|
||||
@@ -191,197 +190,39 @@ Open `http://localhost:4174` and start tracking your medications.
|
||||
|
||||
# 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 |
|
||||
|----------|---------|-------------|
|
||||
| `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 |
|
||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
||||
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
|
||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed frontend origins |
|
||||
| `TZ` | `Europe/Berlin` | Default timezone for reminders |
|
||||
|
||||
Recommended values for API docs by environment:
|
||||
|
||||
| Environment | Recommendation |
|
||||
|-------------|----------------|
|
||||
| Development | `OPENAPI_DOCS_ENABLED=true` |
|
||||
| Staging/Test | `OPENAPI_DOCS_ENABLED=true` |
|
||||
| Production | leave it unset, or set `OPENAPI_DOCS_ENABLED=false` |
|
||||
|
||||
Notes:
|
||||
|
||||
- If `OPENAPI_DOCS_ENABLED` is not set, docs are enabled outside production and disabled in production.
|
||||
- If `OPENAPI_DOCS_ENABLED=true`, docs are available on `/docs` and `/docs/json`.
|
||||
- If `OPENAPI_DOCS_ENABLED=false`, only the docs are disabled. The API still works normally.
|
||||
|
||||
### Authentication
|
||||
Optional but commonly needed:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AUTH_ENABLED` | `false` | Enable user authentication |
|
||||
| `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 |
|
||||
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
|
||||
|
||||
Generate secrets with: `openssl rand -hex 32`
|
||||
Detailed configuration references:
|
||||
|
||||
### API Keys (Programmatic API Access)
|
||||
|
||||
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer ma_...
|
||||
```
|
||||
|
||||
Available scopes:
|
||||
|
||||
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
|
||||
- `write`: read + write access
|
||||
|
||||
Essential notes:
|
||||
|
||||
- Create keys in the app when authentication is enabled.
|
||||
- The token is shown only once after creation.
|
||||
- Creating a new key automatically deactivates previously active keys for the same user.
|
||||
- API keys are stored hashed in the database.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/settings \
|
||||
-H "Authorization: Bearer ma_..."
|
||||
```
|
||||
|
||||
API reference:
|
||||
|
||||
- Interactive docs: `/docs`
|
||||
- OpenAPI JSON: `/docs/json`
|
||||
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
|
||||
- Key management endpoints for authenticated users:
|
||||
- `GET /auth/api-keys`
|
||||
- `POST /auth/api-keys`
|
||||
- `DELETE /auth/api-keys/:id`
|
||||
|
||||
### OIDC / SSO
|
||||
|
||||
| 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/).
|
||||
- Full configuration reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
- Push notifications: [docs/PUSH_NOTIFICATIONS.md](docs/PUSH_NOTIFICATIONS.md)
|
||||
- Default user settings: [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||
```
|
||||
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||
|
||||
- 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:
|
||||
For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
cd backend && npm run test:run
|
||||
cd frontend && npm run test:run
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
# 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,
|
||||
"tag": "0013_add_share_medication_overview",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1775849300000,
|
||||
"tag": "0014_add_user_settings_timezone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+441
-1643
File diff suppressed because it is too large
Load Diff
+25
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.22.0",
|
||||
"version": "1.25.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -19,35 +19,42 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"@libsql/client": "^0.17.2",
|
||||
"@fastify/swagger-ui": "^5.2.6",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"argon2": "^0.44.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.8.2",
|
||||
"nodemailer": "^8.0.3",
|
||||
"openid-client": "^6.8.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.2.3",
|
||||
"nodemailer": "^8.0.7",
|
||||
"openid-client": "^6.8.4",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
"tsx": "^4.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"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 { drizzle } from "drizzle-orm/libsql";
|
||||
import { log } from "../utils/logger.js";
|
||||
// Import utilities from db-utils (side-effect-free)
|
||||
import {
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
runAlterMigrations,
|
||||
runDrizzleMigrations,
|
||||
} from "./db-utils.js";
|
||||
import { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
|
||||
// Import utilities from focused DB modules (side-effect-free)
|
||||
import { ensureDataDirectory, getDbPaths } from "./path-utils.js";
|
||||
import { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
|
||||
|
||||
// Re-export all utilities so existing imports from client.ts keep working
|
||||
export {
|
||||
|
||||
+9
-428
@@ -1,431 +1,12 @@
|
||||
/**
|
||||
* Pure utility functions for database operations.
|
||||
* Separated from client.ts to allow importing without triggering
|
||||
* top-level database initialization side effects.
|
||||
* Compatibility barrel for DB utilities.
|
||||
*
|
||||
* 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";
|
||||
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";
|
||||
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 };
|
||||
}
|
||||
export { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
|
||||
export { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from "./path-utils.js";
|
||||
export { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
|
||||
|
||||
@@ -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,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
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),
|
||||
// UI preferences
|
||||
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)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||
// 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
|
||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||
// UI timeline visibility preferences
|
||||
@@ -182,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -193,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
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'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -109,6 +109,8 @@ type TranslationKeys = {
|
||||
stockTitle: string;
|
||||
stockTitleMultiple: string;
|
||||
intakeTitle: string;
|
||||
intakeTakenConfirmation: string;
|
||||
intakeSkippedConfirmation: string;
|
||||
pillsLeft: string;
|
||||
daysLeft: string;
|
||||
pillsAt: string;
|
||||
@@ -179,6 +181,8 @@ type TranslationKeys = {
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
puffs: string;
|
||||
injections: string;
|
||||
units: string;
|
||||
ml: string;
|
||||
blister: string;
|
||||
@@ -209,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "The following medications are running low and should be reordered soon:",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
pills: "Pills",
|
||||
pills: "Available",
|
||||
days: "Days",
|
||||
runsOut: "Runs Out",
|
||||
},
|
||||
@@ -234,6 +238,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
|
||||
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
|
||||
intakeTakenConfirmation: "✅ This dose was marked as taken.",
|
||||
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
|
||||
pillsLeft: "{count} pills",
|
||||
daysLeft: "{count} days left",
|
||||
pillsAt: "{count} pills at {time}",
|
||||
@@ -301,6 +307,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
puffs: "puffs",
|
||||
injections: "injections",
|
||||
units: "units",
|
||||
ml: "ml",
|
||||
blister: "blister",
|
||||
@@ -329,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
pills: "Tabletten",
|
||||
pills: "Verfuegbar",
|
||||
days: "Tage",
|
||||
runsOut: "Aufgebraucht",
|
||||
},
|
||||
@@ -355,6 +363,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
|
||||
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
|
||||
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
|
||||
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
|
||||
pillsLeft: "{count} Tabletten",
|
||||
daysLeft: "{count} Tage übrig",
|
||||
pillsAt: "{count} Tabletten um {time}",
|
||||
@@ -424,6 +434,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
puffs: "Hübe",
|
||||
injections: "Injektionen",
|
||||
units: "Einheiten",
|
||||
ml: "ml",
|
||||
blister: "Blister",
|
||||
|
||||
+63
-8
@@ -5,7 +5,6 @@ import { resolve } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import sensible from "@fastify/sensible";
|
||||
@@ -16,6 +15,7 @@ import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { migrationsReady } from "./db/client.js";
|
||||
import { getDataDir } from "./db/db-utils.js";
|
||||
import { env } from "./plugins/env.js";
|
||||
import { jwtPlugin } from "./plugins/jwt.js";
|
||||
import { apiKeyRoutes } from "./routes/api-keys.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
@@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
@@ -30,7 +31,7 @@ import { reportRoutes } from "./routes/report.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { shareRoutes } from "./routes/share.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 { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||
|
||||
@@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) {
|
||||
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) {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -166,6 +180,7 @@ export async function createApp(options?: {
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -182,14 +197,32 @@ export async function createApp(options?: {
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(opts.isProduction));
|
||||
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(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
// JWT plugin
|
||||
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 registerApiDocs(app, opts.openApiDocsEnabled);
|
||||
@@ -212,6 +245,7 @@ export async function createApp(options?: {
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
@@ -266,8 +300,26 @@ app.decorate("config", {
|
||||
});
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
|
||||
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, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
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
|
||||
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 registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
|
||||
@@ -294,6 +346,7 @@ await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
@@ -309,6 +362,7 @@ const start = async () => {
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
@@ -323,6 +377,7 @@ const start = async () => {
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { and, count, eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../db/client.js";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
import { log } from "../utils/logger.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// =============================================================================
|
||||
@@ -135,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
@@ -180,8 +181,14 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||
if (!keyRow) return;
|
||||
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return;
|
||||
if (!keyRow) {
|
||||
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));
|
||||
if (userByKey?.isActive) {
|
||||
@@ -191,7 +198,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
||||
scope: keyRow.scope === "read" ? "read" : "write",
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -212,9 +222,11 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
||||
method: "session",
|
||||
scope: "write",
|
||||
};
|
||||
log.debug("[Auth] optionalAuth authenticated via session token");
|
||||
}
|
||||
} catch {
|
||||
// Invalid token, continue as anonymous
|
||||
} catch (err: unknown) {
|
||||
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"),
|
||||
PORT: z
|
||||
.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"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
OPENAPI_DOCS_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Disable username/password form login (useful for OIDC-only setups)
|
||||
FORM_LOGIN_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z
|
||||
.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_CLIENT_ID: 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_AUTO_CREATE_USERS: z
|
||||
.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_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",
|
||||
});
|
||||
+12
-10
@@ -5,7 +5,7 @@ import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
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 { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||
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));
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = app.jwt.sign(
|
||||
const accessToken = await app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
@@ -371,7 +371,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
expiresAt: refreshExp,
|
||||
});
|
||||
|
||||
const refreshToken = app.jwt.sign(
|
||||
const refreshToken = await app.jwt.sign(
|
||||
{ sub: user.id, jti: tokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
@@ -425,7 +425,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
try {
|
||||
// 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,
|
||||
});
|
||||
|
||||
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||
}
|
||||
|
||||
@@ -458,12 +458,12 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Generate new tokens
|
||||
const newAccessToken = app.jwt.sign(
|
||||
const newAccessToken = await app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
|
||||
const newRefreshToken = app.jwt.sign(
|
||||
const newRefreshToken = await app.jwt.sign(
|
||||
{ sub: user.id, jti: newTokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
@@ -498,7 +498,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
if (refreshTokenCookie) {
|
||||
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
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||
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 { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -61,6 +62,15 @@ const doseReadResponseSchema = {
|
||||
},
|
||||
} 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
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: getValidationErrorMessage(parsed.error),
|
||||
});
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId,
|
||||
source: "manual",
|
||||
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" };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
);
|
||||
@@ -423,23 +421,22 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: getValidationErrorMessage(parsed.error),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists (taken or dismissed)
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
// Already exists - update to dismissed if not already
|
||||
if (!existing.dismissed) {
|
||||
await db
|
||||
.update(doseTracking)
|
||||
@@ -448,7 +445,6 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Create new dismissed record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
@@ -590,7 +586,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
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 { z } from "zod";
|
||||
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 { getAnonymousUserId, requireAuth } from "../plugins/auth.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)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.4";
|
||||
const EXPORT_VERSION = "1.5";
|
||||
|
||||
// =============================================================================
|
||||
// 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"),
|
||||
inventory: inventorySchema,
|
||||
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([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
medicationEndDate: z.string().nullable().optional(),
|
||||
@@ -96,7 +96,8 @@ const doseHistorySchema = z.object({
|
||||
const refillHistoryExportSchema = z.object({
|
||||
medicationRef: z.string(), // References _exportId
|
||||
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),
|
||||
refillDate: z.string(), // ISO datetime
|
||||
});
|
||||
@@ -108,37 +109,42 @@ const shareLinkSchema = z.object({
|
||||
regenerateToken: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const settingsExportSchema = z
|
||||
.object({
|
||||
// Email notifications
|
||||
emailEnabled: z.boolean().default(false),
|
||||
notificationEmail: z.string().nullable().optional(),
|
||||
emailStockReminders: z.boolean().default(true),
|
||||
emailIntakeReminders: z.boolean().default(true),
|
||||
emailPrescriptionReminders: z.boolean().default(true),
|
||||
// Push notifications
|
||||
shoutrrrEnabled: z.boolean().optional(),
|
||||
shoutrrrUrl: z.string().nullable().optional(),
|
||||
shoutrrrStockReminders: z.boolean().default(true),
|
||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: z.number().int().default(7),
|
||||
repeatDailyReminders: z.boolean().default(false),
|
||||
skipRemindersForTakenDoses: z.boolean().default(false),
|
||||
repeatRemindersEnabled: z.boolean().default(false),
|
||||
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
||||
maxNaggingReminders: z.number().int().default(5),
|
||||
// Stock thresholds
|
||||
lowStockDays: z.number().int().default(30),
|
||||
normalStockDays: z.number().int().default(90),
|
||||
highStockDays: z.number().int().default(180),
|
||||
expiryWarningDays: z.number().int().default(90),
|
||||
// UI preferences
|
||||
language: z.string().default("en"),
|
||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
shareStockStatus: z.boolean().default(true),
|
||||
shareMedicationOverview: z.boolean().default(false),
|
||||
const settingsSchemaBase = z.object({
|
||||
// Email notifications
|
||||
emailEnabled: z.boolean().default(false),
|
||||
notificationEmail: z.string().nullable().optional(),
|
||||
emailStockReminders: z.boolean().default(true),
|
||||
emailIntakeReminders: z.boolean().default(true),
|
||||
emailPrescriptionReminders: z.boolean().default(true),
|
||||
// Push notifications
|
||||
shoutrrrEnabled: z.boolean().optional(),
|
||||
shoutrrrUrl: z.string().nullable().optional(),
|
||||
shoutrrrStockReminders: z.boolean().default(true),
|
||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: z.number().int().default(7),
|
||||
repeatDailyReminders: z.boolean().default(false),
|
||||
skipRemindersForTakenDoses: z.boolean().default(false),
|
||||
repeatRemindersEnabled: z.boolean().default(false),
|
||||
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
||||
maxNaggingReminders: z.number().int().default(5),
|
||||
// Stock thresholds
|
||||
lowStockDays: z.number().int().default(30),
|
||||
normalStockDays: z.number().int().default(90),
|
||||
highStockDays: z.number().int().default(180),
|
||||
expiryWarningDays: z.number().int().default(90),
|
||||
// UI preferences
|
||||
language: z.string().default("en"),
|
||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
shareMedicationOverview: z.boolean().default(false),
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -149,7 +155,7 @@ const importDataSchema = z.object({
|
||||
medications: z.array(medicationExportSchema).default([]),
|
||||
doseHistory: z.array(doseHistorySchema).default([]),
|
||||
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||
settings: settingsExportSchema,
|
||||
settings: importSettingsSchema,
|
||||
shareLinks: z.array(shareLinkSchema).default([]),
|
||||
});
|
||||
|
||||
@@ -210,7 +216,7 @@ const importBodyOpenApiSchema = {
|
||||
},
|
||||
],
|
||||
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" },
|
||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||
},
|
||||
@@ -289,7 +295,7 @@ function imageToBase64(imageUrl: string | null): string | null {
|
||||
|
||||
// Save base64 image to file and return filename
|
||||
function base64ToImage(base64: string, medicationId: number): string | null {
|
||||
if (!base64 || !base64.startsWith("data:")) return null;
|
||||
if (!base64.startsWith("data:")) return null;
|
||||
|
||||
try {
|
||||
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||
@@ -370,6 +376,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
// 1. Load all medications
|
||||
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
|
||||
const medIdToExportId = new Map<number, string>();
|
||||
@@ -509,7 +516,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryWarningDays: settings.expiryWarningDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareStockStatus: settings.shareStockStatus,
|
||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||
}
|
||||
: undefined;
|
||||
@@ -548,6 +554,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
.map((refill) => {
|
||||
const exportId = medIdToExportId.get(refill.medicationId);
|
||||
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
|
||||
let refillDateIso: string;
|
||||
@@ -568,6 +585,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
medicationRef: exportId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
quantityAdded,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: refillDateIso,
|
||||
};
|
||||
@@ -778,6 +796,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
// 5. Import 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({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
@@ -802,7 +822,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||
});
|
||||
}
|
||||
@@ -830,7 +849,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type MedicationEnrichmentEnrichRequest,
|
||||
MedicationEnrichmentServiceError,
|
||||
searchMedicationEnrichment,
|
||||
} from "../services/medication-enrichment.js";
|
||||
} from "../services/medication-enrichment/index.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
|
||||
label: { type: "string" },
|
||||
pillWeightMg: { type: "number", nullable: true },
|
||||
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;
|
||||
@@ -80,7 +83,7 @@ const packageOptionSchema = {
|
||||
properties: {
|
||||
label: { 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 },
|
||||
blistersPerPack: { 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 { z } from "zod";
|
||||
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 { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { calculateUsageInRange, normalizeDateTime, parseIntakesWithUnits } from "../services/medications-service.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import {
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
@@ -37,70 +39,12 @@ import {
|
||||
type Intake,
|
||||
normalizeIntake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseTakenByJson,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
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
|
||||
const intakeSchema = z.object({
|
||||
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 pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||
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
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
@@ -321,7 +265,7 @@ const medicationBodyOpenApiSchema = {
|
||||
totalPills: { type: ["integer", "null"], minimum: 1 },
|
||||
looseTablets: { type: "integer", 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: {
|
||||
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 allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||
const allowsBottleCapacityUpdate = packageType === "bottle";
|
||||
const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
|
||||
if (allowsAmountBaseUpdate) {
|
||||
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
||||
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
||||
const normalizedAmountBase = looseTablets ?? totalPills;
|
||||
if (normalizedAmountBase !== undefined) {
|
||||
updateFields.totalPills = normalizedAmountBase;
|
||||
updateFields.looseTablets = normalizedAmountBase;
|
||||
}
|
||||
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||
}
|
||||
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
||||
if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
|
||||
updateFields.totalPills = totalPills;
|
||||
}
|
||||
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||
if (looseTablets !== undefined) {
|
||||
if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
|
||||
updateFields.looseTablets = looseTablets;
|
||||
}
|
||||
|
||||
@@ -1711,7 +1658,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
async (req, reply) => {
|
||||
const parsed = dismissUntilSchema.safeParse(req.body);
|
||||
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);
|
||||
@@ -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,670 @@
|
||||
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<{ replaced: boolean; providerMessageId?: string }> {
|
||||
const normalizedSequenceId = options.sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return { replaced: 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 { replaced: false };
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return { replaced: 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 { replaced: true, providerMessageId: result.providerMessageId };
|
||||
}
|
||||
|
||||
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;
|
||||
const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
|
||||
|
||||
try {
|
||||
const replacementResult = await replaceNtfyNotificationSequence({
|
||||
userId: record.group.userId,
|
||||
sequenceId: record.group.sequenceId,
|
||||
language,
|
||||
title: record.group.title,
|
||||
originalMessage: record.group.message,
|
||||
action,
|
||||
viewUrl: record.viewUrl,
|
||||
});
|
||||
replacedNtfyNotification = replacementResult.replaced;
|
||||
|
||||
if (replacementResult.providerMessageId) {
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
}
|
||||
|
||||
if (
|
||||
replacementResult.replaced &&
|
||||
previousNtfyMessageId.length > 0 &&
|
||||
previousNtfyMessageId !== replacementResult.providerMessageId
|
||||
) {
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
originalMessageId: previousNtfyMessageId,
|
||||
error,
|
||||
}),
|
||||
"[NotificationActions] Failed to delete original ntfy notification after replacement"
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
} catch (err: unknown) {
|
||||
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
|
||||
if (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) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
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);
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Clean cookies
|
||||
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||
|
||||
if (!user) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: unknown) {
|
||||
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)
|
||||
// =============================================================================
|
||||
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(
|
||||
@@ -322,7 +322,7 @@ async function generateRefreshToken(
|
||||
const tokenId = randomBytes(32).toString("hex");
|
||||
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" },
|
||||
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
||||
);
|
||||
|
||||
+61
-175
@@ -1,6 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import {
|
||||
@@ -13,6 +12,14 @@ import {
|
||||
} from "../i18n/translations.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.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 type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
@@ -20,56 +27,9 @@ import {
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import {
|
||||
getPlannerUnitKind,
|
||||
isAmountBasedPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
} from "../utils/package-profiles.js";
|
||||
import { isTubePackageType, normalizePackageType } from "../utils/package-profiles.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 = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
@@ -83,17 +43,6 @@ type PlannerRow = {
|
||||
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 = {
|
||||
email: string;
|
||||
from: string;
|
||||
@@ -105,6 +54,7 @@ type SendEmailBody = {
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical?: boolean;
|
||||
@@ -478,19 +428,9 @@ ${getFooterPlain(language)}`;
|
||||
`;
|
||||
|
||||
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");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
const mailResult = await sendEmailNotification({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||
@@ -498,9 +438,8 @@ ${getFooterPlain(language)}`;
|
||||
html,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Failed to send demand email");
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
@@ -629,11 +568,10 @@ ${getFooterPlain(language)}`;
|
||||
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
||||
.filter(([name]) => name.length > 0)
|
||||
);
|
||||
const filteredLowStock = lowStock.filter((item) => {
|
||||
const filteredLowStock = lowStock.flatMap((item) => {
|
||||
const packageType = activeMedicationByName.get(item.name);
|
||||
if (!packageType) return false;
|
||||
if (isTubePackageType(packageType)) return false;
|
||||
return true;
|
||||
if (!packageType || isTubePackageType(packageType)) return [];
|
||||
return [{ ...item, packageType }];
|
||||
});
|
||||
if (filteredLowStock.length === 0) {
|
||||
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||
@@ -682,7 +620,6 @@ ${getFooterPlain(language)}`;
|
||||
if (lowStockMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
}
|
||||
const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
// Build description text
|
||||
let descriptionText: string;
|
||||
@@ -707,7 +644,7 @@ ${getFooterPlain(language)}`;
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((r) =>
|
||||
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}:`);
|
||||
lowStockMeds.forEach((r) =>
|
||||
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
|
||||
if (notificationSettings.emailEnabled && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
hasSmtpPass: Boolean(smtp.pass),
|
||||
smtpPort: smtp.port,
|
||||
smtpSecure: smtp.secure,
|
||||
hasSmtpFrom: Boolean(smtp.from),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Stock email path selected"
|
||||
);
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
if (smtp.host && smtp.user) {
|
||||
// Build subject line from shared title parts
|
||||
const subjectText = titleParts.join(", ");
|
||||
|
||||
@@ -802,12 +734,13 @@ ${getFooterPlain(language)}`;
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const safeName = escapeHtml(row.name);
|
||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||
const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
|
||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
|
||||
return `
|
||||
<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; 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;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
|
||||
</tr>`;
|
||||
@@ -847,29 +780,18 @@ ${getFooterPlain(language)}`;
|
||||
const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
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");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: email,
|
||||
subject: `MedAssist-ng: ${subjectText}`,
|
||||
text: plainText,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Unknown error");
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
@@ -886,8 +808,8 @@ ${getFooterPlain(language)}`;
|
||||
request.log.warn(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
||||
@@ -902,13 +824,13 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const pushPayload = buildStockReminderPushNotification(filteredLowStock as SharedStockReminderItem[], language);
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(
|
||||
const pushResult = await sendPushNotification(
|
||||
notificationSettings.shoutrrrUrl,
|
||||
notificationTitle,
|
||||
message
|
||||
pushPayload.title,
|
||||
pushPayload.message
|
||||
);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
@@ -1046,39 +968,24 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
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;
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
hasSmtpPass: Boolean(smtp.pass),
|
||||
smtpPort: smtp.port,
|
||||
smtpSecure: smtp.secure,
|
||||
hasSmtpFrom: Boolean(smtp.from),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Prescription email path selected"
|
||||
);
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
if (smtp.host && smtp.user) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const subject =
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
@@ -1152,17 +1059,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Unknown error");
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
@@ -1182,8 +1088,8 @@ ${getFooterPlain(language)}`;
|
||||
request.log.warn(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
||||
@@ -1201,37 +1107,17 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const pushPayload = buildPrescriptionReminderPushNotification(
|
||||
filteredPrescriptionLow as SharedPrescriptionReminderItem[],
|
||||
language
|
||||
);
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
||||
const pushResult = await sendPushNotification(
|
||||
userSettings.shoutrrrUrl,
|
||||
pushPayload.title,
|
||||
pushPayload.message
|
||||
);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
} else {
|
||||
|
||||
@@ -12,16 +12,22 @@ import {
|
||||
idParamsSchema,
|
||||
validationErrorSchema,
|
||||
} 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
|
||||
.object({
|
||||
packsAdded: 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),
|
||||
})
|
||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||
message: "Must add at least one pack or some loose pills",
|
||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
|
||||
message: "Must add at least one pack or some quantity",
|
||||
});
|
||||
|
||||
const refillBodyOpenApiSchema = {
|
||||
@@ -29,12 +35,14 @@ const refillBodyOpenApiSchema = {
|
||||
properties: {
|
||||
packsAdded: { 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 },
|
||||
},
|
||||
description: "Provide at least one pack or some loose pills.",
|
||||
description: "Provide at least one pack or some quantity.",
|
||||
example: {
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 4,
|
||||
quantityAdded: 4,
|
||||
usePrescription: true,
|
||||
},
|
||||
} as const;
|
||||
@@ -49,6 +57,7 @@ const refillResponseSchema = {
|
||||
id: { type: "number" },
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "number" },
|
||||
totalPillsAdded: { type: "number" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
@@ -80,6 +89,7 @@ const refillHistoryItemSchema = {
|
||||
id: { type: "number" },
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "number" },
|
||||
totalPillsAdded: { type: "number" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
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)));
|
||||
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 isBottle = packageType === "bottle";
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(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 fallbackAmountPerPackage = Math.max(
|
||||
@@ -153,19 +164,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
: fallbackAmountPerPackage;
|
||||
|
||||
const requestedPackAdds = Math.max(0, packsAdded);
|
||||
const requestedAmountAdds = Math.max(0, loosePillsAdded);
|
||||
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
||||
|
||||
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||
let effectivePacksAdded = requestedPackAdds;
|
||||
if (isBottle) {
|
||||
if (isDiscreteCountPackage) {
|
||||
effectivePacksAdded = 0;
|
||||
} else if (isCountBasedAmountPackage) {
|
||||
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
|
||||
}
|
||||
const effectiveLoosePillsAdded = isCountBasedAmountPackage
|
||||
? effectivePacksAdded * amountPerPackage
|
||||
: requestedAmountAdds;
|
||||
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
const totalPillsAdded = isAmountBased
|
||||
? effectiveLoosePillsAdded
|
||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||
|
||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||
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) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + effectivePacksAdded;
|
||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
const previousAmountBase = med.totalPills ?? med.looseTablets;
|
||||
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
|
||||
const refillBaselineAt = new Date();
|
||||
const baselineStockBeforeRefill = isAmountBased
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
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;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||
consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
|
||||
const refillBaselineAt = new Date();
|
||||
const updatePayload: {
|
||||
packCount: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment: number;
|
||||
totalPills?: number;
|
||||
packageAmountValue?: number;
|
||||
prescriptionRemainingRefills: number | null;
|
||||
@@ -209,12 +239,13 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
} = {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
stockAdjustment: newStockAdjustment,
|
||||
prescriptionRemainingRefills: newRemainingRefills,
|
||||
lastStockCorrectionAt: refillBaselineAt,
|
||||
updatedAt: refillBaselineAt,
|
||||
};
|
||||
|
||||
if (isCountBasedAmountPackage) {
|
||||
if (isPackageAmountPackage) {
|
||||
updatePayload.totalPills = newTotalAmount;
|
||||
updatePayload.packageAmountValue = amountPerPackage;
|
||||
}
|
||||
@@ -236,31 +267,20 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.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 {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
quantityAdded: totalPillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
newStock: {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: newTotalPills,
|
||||
totalPills: targetCurrentStock,
|
||||
},
|
||||
prescription: {
|
||||
used: usePrescription,
|
||||
@@ -308,14 +328,15 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isBottle = packageType === "bottle";
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(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) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const reportDataSchema = z.object({
|
||||
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 = {
|
||||
@@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = {
|
||||
maxItems: 100,
|
||||
items: { type: "integer", minimum: 1 },
|
||||
},
|
||||
takenByFilter: {
|
||||
type: "array",
|
||||
maxItems: 50,
|
||||
items: { type: "string", minLength: 1, maxLength: 100 },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
medicationIds: [1, 3, 5],
|
||||
takenByFilter: ["Daniel"],
|
||||
},
|
||||
} 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 = {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
@@ -39,7 +55,7 @@ const reportDataResponseSchema = {
|
||||
properties: {
|
||||
dosesTaken: { type: "integer" },
|
||||
automaticDosesTaken: { type: "integer" },
|
||||
dosesDismissed: { type: "integer" },
|
||||
dosesSkipped: { type: "integer" },
|
||||
firstDoseAt: { type: "string" },
|
||||
lastDoseAt: { type: "string" },
|
||||
refills: {
|
||||
@@ -49,6 +65,7 @@ const reportDataResponseSchema = {
|
||||
properties: {
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "integer" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
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());
|
||||
|
||||
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
|
||||
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));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
@@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
for (const dose of allDoses) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
@@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: 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 takenDoses = doses.filter((d) => !d.dismissed);
|
||||
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 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.
|
||||
const refills = await db
|
||||
@@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
automaticDosesTaken: automaticTakenDoses.length,
|
||||
dosesDismissed: dismissedDoses.length,
|
||||
dosesSkipped: skippedDoses.length,
|
||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||
refills: refills.map((r) => ({
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
|
||||
+110
-379
@@ -1,55 +1,37 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.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 { 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";
|
||||
|
||||
// Exported type for use in schedulers
|
||||
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;
|
||||
};
|
||||
export type { UserSettings } from "../services/settings-service.js";
|
||||
|
||||
type SettingsBody = {
|
||||
timezone: string;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
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 {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
@@ -189,54 +86,28 @@ function envInt(key: string, defaultVal: number): number {
|
||||
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
|
||||
// Default settings for new users - read from ENV with fallbacks
|
||||
function getDefaultSettings() {
|
||||
function getLanguage(language: string | null | undefined): Language {
|
||||
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 {
|
||||
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,
|
||||
title: t(tr.push.intakeTitle, { minutes: 1 }),
|
||||
message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get or create user settings
|
||||
async function getOrCreateUserSettings(userId: number) {
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings for user (using ENV defaults)
|
||||
[settings] = await db
|
||||
.insert(userSettings)
|
||||
.values({
|
||||
@@ -251,90 +122,12 @@ async function getOrCreateUserSettings(userId: number) {
|
||||
|
||||
// Export for use in reminder scheduler
|
||||
export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
const settings = await getOrCreateUserSettings(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,
|
||||
};
|
||||
return loadUserSettingsFromDb(userId);
|
||||
}
|
||||
|
||||
// Get all users with settings for scheduler
|
||||
export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
const allSettings = await db.select().from(userSettings);
|
||||
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,
|
||||
}));
|
||||
return getAllUserSettingsFromDb();
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
@@ -381,6 +174,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||
|
||||
return reply.send({
|
||||
timezone: settings.timezone ?? "",
|
||||
availableTimezones: getAvailableTimezones(),
|
||||
serverTimezone: process.env.TZ || "UTC",
|
||||
// User notification settings (from DB)
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail ?? "",
|
||||
@@ -448,6 +244,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
type: "object",
|
||||
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
||||
properties: {
|
||||
timezone: { type: "string" },
|
||||
emailEnabled: { type: "boolean" },
|
||||
notificationEmail: { type: "string" },
|
||||
reminderDaysBefore: { type: "number" },
|
||||
@@ -500,6 +297,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
timezone: "",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
@@ -525,6 +323,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const settingsData = {
|
||||
timezone: normalizeSettingsTimezone(body.timezone),
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail || null,
|
||||
emailStockReminders: body.emailStockReminders ?? true,
|
||||
@@ -652,49 +451,34 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
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;
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
to: email,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
hasSmtpPass: Boolean(smtp.pass),
|
||||
hasSmtpFrom: Boolean(smtp.from),
|
||||
smtpPort: smtp.port,
|
||||
smtpSecure: smtp.secure,
|
||||
},
|
||||
"[Settings] Test email request received"
|
||||
);
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
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"
|
||||
);
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
const mailResult = await sendEmailNotification({
|
||||
from: smtp.from,
|
||||
to: 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!",
|
||||
@@ -709,9 +493,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
`,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Failed to send test email");
|
||||
}
|
||||
|
||||
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||
@@ -766,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
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 result = await sendShoutrrrNotification(
|
||||
url,
|
||||
"MedAssist-ng Test",
|
||||
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
|
||||
);
|
||||
const result = await sendShoutrrrNotification(url, title, message, {
|
||||
actions: actionContext?.actions,
|
||||
respondUrl: actionContext?.respondUrl,
|
||||
viewUrl: actionContext?.viewUrl,
|
||||
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||
sequenceId: actionContext?.sequenceId,
|
||||
tags: ["pill"],
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
if (actionContext?.groupId && result.providerMessageId) {
|
||||
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||
}
|
||||
|
||||
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} 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.)
|
||||
export async function sendShoutrrrNotification(
|
||||
urlStr: string,
|
||||
title: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
message: string,
|
||||
options: PushNotificationOptions = {}
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
try {
|
||||
if (urlStr.startsWith("pushover://")) {
|
||||
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
|
||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
|
||||
|
||||
// Remove emojis from title for header compatibility
|
||||
const cleanTitle = title
|
||||
@@ -1091,19 +804,27 @@ export async function sendShoutrrrNotification(
|
||||
// characters (umlauts, accents, etc.) through HTTP headers
|
||||
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
||||
headers = { Title: encodedTitle, Tags: "pill" };
|
||||
body = message;
|
||||
body = renderedPayload.message;
|
||||
|
||||
// Add auth if present (extracted during sanitization)
|
||||
if (auth) {
|
||||
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://")) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
if (isDiscordWebhook) {
|
||||
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
||||
body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
|
||||
} 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 {
|
||||
return {
|
||||
@@ -1128,7 +849,17 @@ export async function sendShoutrrrNotification(
|
||||
});
|
||||
|
||||
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 {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
|
||||
@@ -385,7 +385,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
const parsed = createShareSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 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;
|
||||
}
|
||||
|
||||
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { resolve } from "node:path";
|
||||
import { and, eq, gte, lte } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
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 {
|
||||
getDateLocale,
|
||||
@@ -13,13 +12,15 @@ import {
|
||||
type Language,
|
||||
t,
|
||||
} 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 shared utilities
|
||||
import {
|
||||
cleanOldIntakeReminders,
|
||||
createDefaultIntakeReminderState,
|
||||
getTimezone,
|
||||
getEffectiveTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type IntakeReminderState,
|
||||
@@ -30,20 +31,26 @@ import {
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.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 CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
|
||||
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
|
||||
|
||||
function loadIntakeReminderState(): IntakeReminderState {
|
||||
function loadIntakeReminderState(logger: ServiceLogger): IntakeReminderState {
|
||||
try {
|
||||
if (existsSync(intakeReminderStateFile)) {
|
||||
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[IntakeReminder] Failed to load reminder state file=${intakeReminderStateFile}: ${errorMessage}`);
|
||||
}
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
@@ -52,36 +59,6 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function normalizeRecipients(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||
const accepted = normalizeRecipients(info.accepted);
|
||||
const rejected = normalizeRecipients(info.rejected);
|
||||
|
||||
if (accepted.length > 0) return null;
|
||||
if (rejected.length > 0) {
|
||||
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||
}
|
||||
|
||||
if (typeof info.response === "string" && info.response.trim()) {
|
||||
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||
}
|
||||
|
||||
return "SMTP did not confirm accepted recipients.";
|
||||
}
|
||||
|
||||
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||
const intakeDate = intake.intakeTime;
|
||||
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
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(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
@@ -166,7 +178,7 @@ async function autoMarkDueIntakesAsTaken(
|
||||
}
|
||||
|
||||
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({
|
||||
medication: med,
|
||||
doses: trackedDoses,
|
||||
@@ -269,14 +281,9 @@ async function sendIntakeReminderEmail(
|
||||
currentCount?: number,
|
||||
maxCount?: number
|
||||
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
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(", ") })}`
|
||||
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: email,
|
||||
subject: `💊 ${subject}`,
|
||||
text: plainText,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
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 };
|
||||
if (!mailResult.success) {
|
||||
return { success: false, error: mailResult.error ?? "Unknown error" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: mailResult.messageId,
|
||||
smtpResponse: mailResult.smtpResponse,
|
||||
};
|
||||
}
|
||||
|
||||
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 locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
const tz = getEffectiveTimezone(settings.timezone ?? null);
|
||||
|
||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||
if (autoMarkedCount > 0) {
|
||||
@@ -523,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
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 })[] = [];
|
||||
let scheduledIntakesTodayCount = 0;
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -535,10 +557,10 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// 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)
|
||||
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
|
||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||
@@ -841,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
.join("\n") +
|
||||
repeatNote +
|
||||
`\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;
|
||||
if (!result.success) {
|
||||
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 {
|
||||
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(
|
||||
`[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;
|
||||
|
||||
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(() => {
|
||||
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);
|
||||
|
||||
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,380 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
async function resetActionTokens(groupId: number): Promise<void> {
|
||||
await db.delete(notificationActionTokens).where(eq(notificationActionTokens.groupId, groupId));
|
||||
}
|
||||
|
||||
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) {
|
||||
const [existingGroup] = await db
|
||||
.select()
|
||||
.from(notificationActionGroups)
|
||||
.where(eq(notificationActionGroups.groupKey, groupKey));
|
||||
|
||||
if (existingGroup) {
|
||||
await resetActionTokens(existingGroup.id);
|
||||
[group] = await db
|
||||
.update(notificationActionGroups)
|
||||
.set({
|
||||
sequenceId,
|
||||
ntfyOriginalMessageId: "",
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
resolvedAction: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(notificationActionGroups.id, existingGroup.id))
|
||||
.returning();
|
||||
} else {
|
||||
[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 { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getDataDir } from "../db/path-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.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 {
|
||||
isAmountBasedPackageType,
|
||||
@@ -19,10 +18,10 @@ import {
|
||||
type Blister,
|
||||
calculateDepletionInfo,
|
||||
countScheduledOccurrencesInRange,
|
||||
createDefaultReminderState,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getDateOnlyTimestamp,
|
||||
getEffectiveTimezone,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
@@ -31,10 +30,17 @@ import {
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
type ReminderState,
|
||||
} 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 {
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
@@ -47,39 +53,8 @@ function escapeHtml(text: 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.";
|
||||
}
|
||||
|
||||
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 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 = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical: boolean;
|
||||
@@ -232,6 +128,16 @@ type PrescriptionReminderItem = {
|
||||
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(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
@@ -403,8 +309,9 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
medsLeft: currentPills,
|
||||
packageType,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
isCritical,
|
||||
@@ -429,7 +336,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
||||
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
||||
)
|
||||
.map((row) => ({
|
||||
name: row.name,
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
||||
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
||||
expiryDate: row.prescriptionExpiryDate ?? null,
|
||||
@@ -461,14 +368,8 @@ async function sendReminderEmail(
|
||||
language: Language,
|
||||
isRepeatDaily: boolean = false
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
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) {
|
||||
const smtp = getSmtpConfig();
|
||||
if (!smtp.host || !smtp.user) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
@@ -534,10 +435,11 @@ async function sendReminderEmail(
|
||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr);
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; 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;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
|
||||
</tr>`;
|
||||
@@ -581,7 +483,7 @@ async function sendReminderEmail(
|
||||
|
||||
${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}` : ""}`;
|
||||
@@ -590,35 +492,19 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
||||
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
const emailResult = await sendEmailNotification({
|
||||
to: email,
|
||||
subject,
|
||||
text: plainText,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
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 };
|
||||
if (!emailResult.success) {
|
||||
return { success: false, error: emailResult.error ?? "Unknown error" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
||||
@@ -663,7 +549,8 @@ async function checkAndSendReminderForUser(
|
||||
}
|
||||
|
||||
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 userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
||||
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
||||
@@ -703,41 +590,8 @@ async function checkAndSendReminderForUser(
|
||||
}
|
||||
|
||||
if (stockPushEnabled) {
|
||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (criticalMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (lowStockMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowStockMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
const pushPayload = buildStockReminderPushNotification(allLowStock, language);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
|
||||
@@ -824,22 +678,9 @@ async function checkAndSendReminderForUser(
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
if (prescriptionEmailEnabled) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
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;
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
const smtp = getSmtpConfig();
|
||||
if (smtp.host && smtp.user) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||
});
|
||||
|
||||
const subject =
|
||||
allPrescriptionLow.length === 1
|
||||
? 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 mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: settings.notificationEmail!,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Unknown error");
|
||||
}
|
||||
emailSuccess = true;
|
||||
} catch (error) {
|
||||
@@ -939,35 +779,8 @@ async function checkAndSendReminderForUser(
|
||||
}
|
||||
|
||||
if (prescriptionPushEnabled) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
const pushPayload = buildPrescriptionReminderPushNotification(allPrescriptionLow, language);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
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 jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import type { Client } from "@libsql/client";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// 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(cookie, { secret: "test-cookie-secret-12345" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret-12345",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||
@@ -77,8 +77,8 @@ async function createUser(username: string) {
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = app.jwt.sign({ sub: userId, username });
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("Real business route authz contracts", () => {
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
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 () => {
|
||||
const ownerId = await createUser("owner-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: 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 () => {
|
||||
const ownerId = await createUser("owner-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 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 () => {
|
||||
const ownerId = await createUser("owner-dose");
|
||||
const otherId = await createUser("other-dose");
|
||||
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
|
||||
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
|
||||
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-dose");
|
||||
const otherCookie = await buildSessionCookie(app, otherId, "other-dose");
|
||||
|
||||
await seedDose({ userId: ownerId, doseId: "101-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 () => {
|
||||
const ownerId = await createUser("owner-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 });
|
||||
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 () => {
|
||||
const ownerId = await createUser("owner-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: otherId, name: "Anna Med", takenBy: ["Anna"] });
|
||||
|
||||
@@ -248,10 +248,10 @@ describe("Database Client Utilities", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should create .write-test file", () => {
|
||||
it("should not leave .write-test residue", () => {
|
||||
const result = ensureDataDirectory(testDir);
|
||||
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", () => {
|
||||
|
||||
@@ -41,16 +41,22 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
||||
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
||||
|
||||
vi.doMock("../db/db-utils.js", () => ({
|
||||
buildDbUrl: vi.fn(),
|
||||
vi.doMock("../db/path-utils.js", () => ({
|
||||
getDataDir: vi.fn(),
|
||||
buildDbUrl: vi.fn(),
|
||||
ensureDataDirectory,
|
||||
getDbPaths,
|
||||
}));
|
||||
|
||||
vi.doMock("../db/migration-utils.js", () => ({
|
||||
runDrizzleMigrations,
|
||||
runAlterMigrations,
|
||||
ensureDefaultUser,
|
||||
}));
|
||||
|
||||
vi.doMock("../db/repair-utils.js", () => ({
|
||||
repairTrailingHyphenDoseIds,
|
||||
repairOrphanedDoseIds,
|
||||
ensureDefaultUser,
|
||||
}));
|
||||
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
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) {
|
||||
const token = app.jwt.sign({ sub: userId, username });
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("Dose Tracking API", () => {
|
||||
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
@@ -164,7 +164,7 @@ describe("Dose Tracking API", () => {
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
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", () => {
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
*/
|
||||
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import sensible from "@fastify/sensible";
|
||||
import type { Client } from "@libsql/client";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// 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 (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
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(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
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");
|
||||
});
|
||||
|
||||
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"]);
|
||||
|
||||
// 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({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||
VALUES (?, ?, ?, 0)`,
|
||||
@@ -337,13 +338,14 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
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].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||
expect(data[medId].refills).toHaveLength(1);
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 2,
|
||||
loosePillsAdded: 5,
|
||||
quantityAdded: 7,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -375,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 1,
|
||||
usedPrescription: false,
|
||||
});
|
||||
});
|
||||
@@ -1959,7 +1962,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
@@ -2333,10 +2336,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(med.stockAdjustment).toBe(0);
|
||||
});
|
||||
|
||||
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
it.each([
|
||||
{
|
||||
label: "bottle",
|
||||
payload: {
|
||||
name: "Bottle Zero Reset Med",
|
||||
packageType: "bottle",
|
||||
@@ -2347,6 +2349,40 @@ describe("E2E Tests with Real Routes", () => {
|
||||
looseTablets: 20,
|
||||
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);
|
||||
const medId = createResponse.json().id;
|
||||
@@ -2442,6 +2478,81 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3047,6 +3158,80 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3106,6 +3291,23 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
await app.inject({
|
||||
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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3271,6 +3663,11 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3288,14 +3685,20 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
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.totalPillsAdded).toBe(180);
|
||||
expect(refillData.newStock.totalPills).toBe(360);
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
@@ -3306,6 +3709,54 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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([
|
||||
{
|
||||
name: "liquid_container",
|
||||
@@ -3321,11 +3772,13 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionRemainingRefills: 2,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedLooseAdded: 180,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 360,
|
||||
expectedAmountPerPackage: 180,
|
||||
},
|
||||
{
|
||||
name: "tube",
|
||||
@@ -3336,20 +3789,29 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionRemainingRefills: 3,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||
refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 80, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 80,
|
||||
expectedPacksAdded: 2,
|
||||
expectedLooseAdded: 80,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 160,
|
||||
expectedAmountPerPackage: 40,
|
||||
},
|
||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||
payload,
|
||||
refillPayload,
|
||||
expectedVisibleStockBeforeRefill,
|
||||
expectedPacksAdded,
|
||||
expectedLooseAdded,
|
||||
expectedRemainingRefills,
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3366,8 +3828,17 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||
expectedQuantityAdded: expectedLooseAdded,
|
||||
expectedPacksAdded,
|
||||
expectedAmountPerPackage,
|
||||
});
|
||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.prescription.used).toBe(true);
|
||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||
@@ -3381,6 +3852,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(historyResponse.json()[0]).toMatchObject({
|
||||
packsAdded: expectedPacksAdded,
|
||||
loosePillsAdded: expectedLooseAdded,
|
||||
quantityAdded: expectedLooseAdded,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -3397,14 +3869,20 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
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.totalPillsAdded).toBe(40);
|
||||
expect(refillData.newStock.totalPills).toBe(120);
|
||||
|
||||
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"),
|
||||
PORT: z
|
||||
.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"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: 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_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||
expect(result.LOG_LEVEL).toBe("info");
|
||||
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "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.language).toBe("de");
|
||||
expect(data.settings.lowStockDays).toBe(14);
|
||||
expect(data.settings.shareStockStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should exclude sensitive data by default", async () => {
|
||||
@@ -557,6 +558,45 @@ describe("Export/Import API", () => {
|
||||
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 () => {
|
||||
// Create existing medication
|
||||
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 jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import sensible from "@fastify/sensible";
|
||||
import type { Client } from "@libsql/client";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// 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 (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
@@ -208,7 +209,7 @@ describe("Integration Tests", () => {
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
|
||||
@@ -705,4 +705,39 @@ describe("medication enrichment", () => {
|
||||
|
||||
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,627 @@
|
||||
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" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||
args: ["ntfy-msg-1", userId],
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
||||
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(2);
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
|
||||
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-1");
|
||||
expect(deleteInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||
})
|
||||
);
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-2" })]);
|
||||
});
|
||||
|
||||
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" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||
args: ["ntfy-msg-7", userId],
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${skipToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
|
||||
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-7");
|
||||
expect(deleteInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||
})
|
||||
);
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-3" })]);
|
||||
});
|
||||
|
||||
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,321 @@
|
||||
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) ?? "";
|
||||
}
|
||||
|
||||
type ActionTokenRow = {
|
||||
kind: string | null;
|
||||
token_hash: string | null;
|
||||
};
|
||||
|
||||
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("reactivates a resolved group with the same key instead of inserting a duplicate", async () => {
|
||||
const userId = await createUser("notify-actions-reactivate");
|
||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||
const doseIds = ["9-1-1736064000000", "9-0-1736064000000"];
|
||||
|
||||
const first = await createNotificationActionContext({
|
||||
userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds,
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
groupId: expect.any(Number),
|
||||
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||
sequenceId: expect.stringMatching(/^medassist-/),
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
|
||||
const firstGroupId = first!.groupId!;
|
||||
const firstSequenceId = first!.sequenceId!;
|
||||
const firstRespondToken = extractToken(first!.respondUrl!);
|
||||
const firstTokenRows = await testClient.execute({
|
||||
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||
args: [firstGroupId],
|
||||
});
|
||||
expect(firstTokenRows.rows).toHaveLength(3);
|
||||
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET resolved_action = 'taken', resolved_at = ?, ntfy_original_message_id = 'old-message-id' WHERE id = ?",
|
||||
args: [new Date(), firstGroupId],
|
||||
});
|
||||
|
||||
const second = await createNotificationActionContext({
|
||||
userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds,
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(second).toMatchObject({
|
||||
groupId: firstGroupId,
|
||||
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||
sequenceId: expect.stringMatching(/^medassist-/),
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
expect(second?.sequenceId).toBe(firstSequenceId);
|
||||
|
||||
const groups = await testClient.execute(
|
||||
"SELECT id, sequence_id, resolved_action, resolved_at, ntfy_original_message_id FROM notification_action_groups"
|
||||
);
|
||||
expect(groups.rows).toHaveLength(1);
|
||||
expect(groups.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: firstGroupId,
|
||||
sequence_id: second?.sequenceId,
|
||||
resolved_action: null,
|
||||
resolved_at: null,
|
||||
ntfy_original_message_id: "",
|
||||
})
|
||||
);
|
||||
|
||||
const secondTokenRows = await testClient.execute({
|
||||
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||
args: [firstGroupId],
|
||||
});
|
||||
expect(secondTokenRows.rows).toHaveLength(3);
|
||||
expect(secondTokenRows.rows.map((row: ActionTokenRow) => row.kind)).toEqual(["respond", "skip", "taken"]);
|
||||
|
||||
const firstTokenHashes = new Set(firstTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||
const secondTokenHashes = new Set(secondTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||
expect(secondTokenHashes.size).toBe(3);
|
||||
expect([...secondTokenHashes].every((tokenHash) => !firstTokenHashes.has(tokenHash))).toBe(true);
|
||||
|
||||
expect(await getNotificationActionTokenRecord(firstRespondToken)).toBeNull();
|
||||
|
||||
const secondRespondToken = extractToken(second!.respondUrl!);
|
||||
const secondRespondRecord = await getNotificationActionTokenRecord(secondRespondToken);
|
||||
expect(secondRespondRecord).toMatchObject({
|
||||
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
expect(secondRespondRecord?.group.id).toBe(firstGroupId);
|
||||
});
|
||||
|
||||
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.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||
expect(res.headers.location).toBe("http://localhost:5173");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
@@ -129,7 +129,7 @@ describe("OIDC routes", () => {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
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 {
|
||||
await app.close();
|
||||
}
|
||||
@@ -144,7 +144,7 @@ describe("OIDC routes", () => {
|
||||
});
|
||||
|
||||
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 {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -16,6 +16,8 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
PUBLIC_APP_URL: "https://app.example.com",
|
||||
CORS_ORIGINS: "https://app.example.com",
|
||||
};
|
||||
return {
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
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 () => {
|
||||
@@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.providerMessageId).toBe("ntfy-message-id");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/mytopic",
|
||||
expect.objectContaining({
|
||||
@@ -589,8 +630,39 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
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[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 () => {
|
||||
@@ -621,7 +693,9 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(body.medications).toHaveLength(1);
|
||||
expect(body.doseHistory).toHaveLength(1);
|
||||
expect(body.refillHistory).toHaveLength(1);
|
||||
expect(body.refillHistory[0].quantityAdded).toBe(23);
|
||||
expect(body.settings.language).toBe("de");
|
||||
expect(body.settings.shareStockStatus).toBeUndefined();
|
||||
expect(body.shareLinks).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -672,7 +746,15 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
},
|
||||
],
|
||||
doseHistory: [],
|
||||
refillHistory: [],
|
||||
refillHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
packsAdded: 0,
|
||||
quantityAdded: 4,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-01-02T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
@@ -708,10 +790,24 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
});
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(valid.json().imported.medications).toBe(1);
|
||||
expect(valid.json().imported.refillHistory).toBe(1);
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
|
||||
@@ -78,8 +78,8 @@ async function createUser(username: string) {
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = app.jwt.sign({ sub: userId, username });
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("Settings and API key security contracts", () => {
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("Settings and API key security contracts", () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/settings",
|
||||
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
|
||||
headers: { cookie: await buildSessionCookie(app, userId, "settings-session-user") },
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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({
|
||||
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 () => {
|
||||
const ownerUserId = await createUser("api-key-owner");
|
||||
const otherUserId = await createUser("api-key-other-user");
|
||||
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
|
||||
const otherCookieHeader = await buildSessionCookie(app, otherUserId, "api-key-other-user");
|
||||
|
||||
const keyId = await insertApiKey({
|
||||
userId: ownerUserId,
|
||||
@@ -363,7 +363,7 @@ describe("Settings and API key security contracts", () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
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" },
|
||||
});
|
||||
|
||||
@@ -385,7 +385,7 @@ describe("Settings and API key security contracts", () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach } from "vitest";
|
||||
import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// Get migrations folder path
|
||||
@@ -49,7 +50,7 @@ export async function buildTestApp(): Promise<TestContext> {
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
@@ -315,5 +316,13 @@ export async function clearTestData(client: Client): Promise<void> {
|
||||
// =============================================================================
|
||||
|
||||
// Set test environment
|
||||
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||
process.env.AUTH_ENABLED = "false";
|
||||
process.env.OIDC_ENABLED = "false";
|
||||
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: {
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
@@ -80,6 +81,7 @@ async function createMedication(options: {
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
genericName = null,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
@@ -106,16 +108,17 @@ async function createMedication(options: {
|
||||
|
||||
const result = await testClient.execute({
|
||||
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,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
genericName,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
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", () => {
|
||||
|
||||
Vendored
+6
-9
@@ -1,5 +1,5 @@
|
||||
import "fastify";
|
||||
import "@fastify/jwt";
|
||||
import type { JwtSignOptions, JwtVerifyOptions } from "../plugins/jwt.js";
|
||||
|
||||
// User type for authenticated requests
|
||||
export interface AuthUser {
|
||||
@@ -23,19 +23,16 @@ declare module "fastify" {
|
||||
cookieOptions: 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 {
|
||||
user?: AuthUser | null;
|
||||
authContext?: AuthContext;
|
||||
correlationId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
// Allow flexible payload for access and refresh tokens
|
||||
payload: Record<string, unknown>;
|
||||
user: Record<string, unknown>;
|
||||
jwtVerify<T extends Record<string, unknown>>(options?: JwtVerifyOptions): Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,5 +46,6 @@ export const log = {
|
||||
export type ServiceLogger = {
|
||||
info: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
warn: (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];
|
||||
|
||||
@@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
|
||||
return normalizePackageType(packageType) === "liquid_container";
|
||||
}
|
||||
|
||||
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||
export function isPackageAmountPackageType(packageType?: string | null): boolean {
|
||||
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);
|
||||
if (normalized === "tube") return "units";
|
||||
if (normalized === "liquid_container") return "ml";
|
||||
if (normalized === "inhaler") return "puffs";
|
||||
if (normalized === "injection") return "injections";
|
||||
return "pills";
|
||||
}
|
||||
|
||||
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
|
||||
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 {
|
||||
return toDateOnly(date).getTime();
|
||||
}
|
||||
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
|
||||
|
||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
const intervalDays = Math.max(1, schedule.every);
|
||||
if (startTime >= lowerBound) {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
||||
return startTime + intervals * period;
|
||||
const lowerBoundDate = new Date(lowerBound);
|
||||
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);
|
||||
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
|
||||
}
|
||||
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
let occurrenceMs = startTime;
|
||||
if (occurrenceMs < rangeStartMs) {
|
||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
||||
occurrenceMs += intervals * period;
|
||||
const intervalDays = Math.max(1, schedule.every);
|
||||
let occurrence = new Date(startDate);
|
||||
if (occurrence.getTime() < rangeStartMs) {
|
||||
const rangeStartDate = new Date(rangeStartMs);
|
||||
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) {
|
||||
callback(occurrenceMs);
|
||||
}
|
||||
|
||||
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||
occurrenceMs = occurrence.getTime();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -348,6 +379,23 @@ export function getTimezone(): string {
|
||||
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 */
|
||||
export function formatInTimezone(date: Date, tz?: string): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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. Must be reachable by phones, browsers, and notification providers; do not point this to `localhost` or an internal Docker hostname. |
|
||||
| `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.
|
||||
|
||||
Notification action links use `PUBLIC_APP_URL` as their base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
@@ -47,6 +49,6 @@ Scope and behavior:
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
||||
| `DEFAULT_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_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
npm run check
|
||||
npm run build
|
||||
cd backend && npm run test:run
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
|
||||
Recommended local maintenance preflight before opening or updating a PR:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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 actions such as `Take`, `Skip`, and `View`.
|
||||
|
||||
For `ntfy`, MedAssist publishes native action buttons so `Take` and `Skip` are executed directly from the notification. The browser-based confirmation flow remains the fallback path for other Shoutrrr targets that do not support native action buttons.
|
||||
|
||||
When an ntfy intake action succeeds, MedAssist publishes the confirmation as the updated notification state and removes the outdated actionable ntfy entry using the original ntfy message ID when available, so duplicate reminder entries do not accumulate unnecessarily.
|
||||
|
||||
## 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.
|
||||
|
||||
Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach.
|
||||
|
||||
Good examples:
|
||||
|
||||
```text
|
||||
https://med.example.com
|
||||
https://medtest.example.com
|
||||
```
|
||||
|
||||
Bad examples for notification actions:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
http://backend-dev:3000
|
||||
http://192.168.x.x:3000
|
||||
```
|
||||
|
||||
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/).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### ntfy `Take` / `Skip` fails with a connection timeout
|
||||
|
||||
If the ntfy client shows an error such as `failed to connect to ... port 443`, the failure usually happens before MedAssist can process the action token.
|
||||
|
||||
Check these points first:
|
||||
|
||||
1. `PUBLIC_APP_URL` points to your real public MedAssist URL, not to `localhost`, a Docker service name, or another internal-only address.
|
||||
2. The same URL opens from the same phone and network outside the notification flow.
|
||||
3. If the failure only happens on your home Wi-Fi, retry once on mobile data. That strongly helps distinguish an app issue from missing NAT loopback / hairpin routing on the local network.
|
||||
|
||||
### ntfy shows an old actionable entry after a successful action
|
||||
|
||||
MedAssist updates the notification state after a successful ntfy action and removes the stale actionable entry using the original ntfy message ID when available.
|
||||
|
||||
If an outdated actionable entry still remains visible, verify that the action actually reached MedAssist and that your ntfy server accepted both the confirmation publish and the follow-up delete of the original message.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user