Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e85b29549 | |||
| e55e695c88 | |||
| 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 | |||
| f670a6355f | |||
| 3cdb38055d | |||
| 39c19ab2fe | |||
| 8372b7ec27 | |||
| b32ec9b21b | |||
| 60bef957de | |||
| 8e2d7e74d2 | |||
| 5382669ffe | |||
| 7059c25f1c | |||
| 37fc2b8e66 | |||
| d434131d02 | |||
| b796e03bcb | |||
| e1b47e82b2 | |||
| 68ab79c713 | |||
| 29f4c4e48d | |||
| 934519767a | |||
| 9e224c0441 | |||
| a0b0febe85 | |||
| 5138d784cd | |||
| 5b019f942d | |||
| 14e783f111 | |||
| fb62227154 | |||
| 9b95be851c | |||
| 0f9458b7cb | |||
| 01b59e66ca | |||
| 9180783c42 | |||
| cc636eb98b | |||
| 8c77a87bc5 | |||
| 908e4e724f | |||
| ef78e51b4e | |||
| b57dc0fb35 | |||
| 99160c14ed | |||
| 63b07e0da8 | |||
| 8ec7d3ae3d | |||
| c38c6efb6d | |||
| 9d605a1855 | |||
| 0160ef3ddf | |||
| 816888a697 | |||
| e0fb77d494 | |||
| fd3134be24 | |||
| d0837a7281 | |||
| 3fda41e501 | |||
| c13bfad16f | |||
| dd8ddb64e6 | |||
| 75196e5fa8 | |||
| 5264c761cf | |||
| e0a50d01bb | |||
| 4d5edb7c76 | |||
| 07bfa78386 | |||
| 8d37fd0cb5 | |||
| 890449d756 | |||
| 192e611668 | |||
| 4de3b80aba | |||
| fd17288109 | |||
| c59fdfb92b | |||
| c0507c4c4b | |||
| 105eb7bc0d | |||
| 733fe2f38a | |||
| 2db49e427a | |||
| 0e4d7f71e4 | |||
| 8594e175f1 | |||
| 8e29219cd1 | |||
| 0be472bf38 | |||
| e8279bd521 | |||
| 4136252a20 | |||
| 36d50c0736 | |||
| d7d4bf39a0 | |||
| 5b6c6abb69 | |||
| 30c97e2f0d | |||
| de1a508e52 | |||
| 54d26e0241 | |||
| ac47fc001d | |||
| 4936929849 | |||
| 6672fb78c9 | |||
| b349e26833 | |||
| 56d244aa61 | |||
| 1a348c62f5 | |||
| 067a8c166b | |||
| 8fdd79ff33 | |||
| cd8263e607 | |||
| e6a097d81d | |||
| f4723c6f99 | |||
| aad6b143ef | |||
| da004b5c3e | |||
| cd18581bdd | |||
| 508bc764d5 | |||
| 9e8a6315e7 | |||
| 8efd99d738 | |||
| dc98dfda44 | |||
| 8aaeca6b26 | |||
| 7accb2aad6 | |||
| 2f2edfa479 | |||
| b009d9e158 | |||
| 8e4cb5dcd4 | |||
| 7f26dca7a7 | |||
| 46d768dd4e | |||
| c62b6d7893 | |||
| 1668eb935c | |||
| 1ea4919323 | |||
| ba0ab672b9 | |||
| 57c998ba09 | |||
| cc22f80209 | |||
| 6b27d234d9 | |||
| 19ba4bb7d2 | |||
| 8b3901c1e1 | |||
| fd7cc56bb7 | |||
| aabe58d05f | |||
| b35101d339 | |||
| 8420c74a55 | |||
| 872b63f665 | |||
| f599ac45ab | |||
| f36d56c523 | |||
| f0496e8ca5 | |||
| de300ad919 | |||
| 06bf608913 | |||
| a47bde0956 | |||
| d02f16af3a | |||
| dbdf3b61cb | |||
| aa29d1c699 | |||
| bfc9aaaa6d | |||
| 2a9ca39c24 | |||
| 691550fb33 | |||
| 0fded0d42f | |||
| badee6067c | |||
| 6161c14a7b | |||
| 96b2a0c96f | |||
| 7a32b2045e | |||
| 26475fd3d0 | |||
| 63cd9ef19b | |||
| f15c2dd79f | |||
| b0c5d48095 | |||
| 05226cc500 | |||
| 3e4f1440a9 | |||
| d64a833bda | |||
| ba36f67371 | |||
| 2aa6b1f406 | |||
| 3238a22fd6 | |||
| b139660241 |
+28
-3
@@ -11,16 +11,34 @@ PGID=1000
|
||||
|
||||
PORT=3000
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
LOG_LEVEL=info
|
||||
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
|
||||
|
||||
# Rate limit: max requests per minute per IP (default: 100)
|
||||
# Increase for development/testing environments
|
||||
# RATE_LIMIT_MAX=100
|
||||
|
||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||
# API documentation UI + OpenAPI JSON
|
||||
# Default behavior: enabled outside production, disabled in production
|
||||
# When enabled, docs are available on /docs and /docs/json.
|
||||
# Recommended:
|
||||
# development/staging: OPENAPI_DOCS_ENABLED=true
|
||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||
# OPENAPI_DOCS_ENABLED=true
|
||||
|
||||
# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
|
||||
# Users can override this per account in Settings -> Timezone.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# =============================================================================
|
||||
@@ -32,6 +50,9 @@ AUTH_ENABLED=false
|
||||
# Allow new user registrations (auto-enabled when no users exist)
|
||||
# REGISTRATION_ENABLED=false
|
||||
|
||||
# Disable username/password form login (useful for OIDC-only setups)
|
||||
# FORM_LOGIN_ENABLED=true
|
||||
|
||||
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
|
||||
# Generate with: openssl rand -hex 32
|
||||
# JWT_SECRET=
|
||||
@@ -102,12 +123,14 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||
# DEFAULT_NOTIFICATION_EMAIL=
|
||||
# DEFAULT_EMAIL_STOCK_REMINDERS=true
|
||||
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
||||
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
|
||||
|
||||
# Push notifications (ntfy/gotify via Shoutrrr)
|
||||
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||
# DEFAULT_SHOUTRRR_URL=
|
||||
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
|
||||
# DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS=true
|
||||
|
||||
# Repeat/nagging reminders for missed doses
|
||||
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
|
||||
@@ -126,4 +149,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_STOCK_STATUS=true # Show stock status on shared schedule links
|
||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||
@@ -0,0 +1,11 @@
|
||||
# MedAssist ownership
|
||||
# This routes review requests automatically to the maintainer.
|
||||
|
||||
* @DanielVolz
|
||||
|
||||
# Explicit domains for clarity
|
||||
/backend/ @DanielVolz
|
||||
/frontend/ @DanielVolz
|
||||
/.github/ @DanielVolz
|
||||
/doku/ @DanielVolz
|
||||
/docs/ @DanielVolz
|
||||
@@ -1,12 +1,19 @@
|
||||
name: 🐛 Bug Report
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
assignees:
|
||||
- DanielVolz
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
||||
|
||||
Before submitting, please reproduce the issue on the latest released version.
|
||||
Even better: verify it on the current `main` image/tag.
|
||||
The issue may already be fixed in newer builds.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -57,6 +64,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: version_info
|
||||
attributes:
|
||||
label: Version / Image Information
|
||||
description: Provide the app version and, if using Docker, the exact image tag you are running.
|
||||
placeholder: |
|
||||
App version (Settings -> About): vX.Y.Z
|
||||
Docker image tag (if applicable): latest or main
|
||||
Tag guidance: use `latest` for the newest release, or `main` for the newest changes from the main branch (`main` is always as new as or newer than `latest`).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/DanielVolz/medassist-ng/discussions
|
||||
about: Ask questions or share ideas in Discussions
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/DanielVolz/medassist-ng#readme
|
||||
about: Check the README for setup and usage instructions
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: ✨ Feature Request
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
description: 'Provide principal-level software engineering guidance with focus on engineering excellence, technical leadership, and pragmatic implementation.'
|
||||
name: 'Principal software engineer'
|
||||
tools: ['changes', 'search/codebase', 'edit/editFiles', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runTasks', 'runTests', 'search', 'search/searchResults', 'runCommands/terminalLastCommand', 'runCommands/terminalSelection', 'testFailure', 'usages', 'vscodeAPI', 'github']
|
||||
---
|
||||
# Principal software engineer mode instructions
|
||||
|
||||
You are in principal software engineer mode. Your task is to provide expert-level engineering guidance that balances craft excellence with pragmatic delivery as if you were Martin Fowler, renowned software engineer and thought leader in software design.
|
||||
|
||||
## Core Engineering Principles
|
||||
|
||||
You will provide guidance on:
|
||||
|
||||
- **Engineering Fundamentals**: Gang of Four design patterns, SOLID principles, DRY, YAGNI, and KISS - applied pragmatically based on context
|
||||
- **Clean Code Practices**: Readable, maintainable code that tells a story and minimizes cognitive load
|
||||
- **Test Automation**: Comprehensive testing strategy including unit, integration, and end-to-end tests with clear test pyramid implementation
|
||||
- **Quality Attributes**: Balancing testability, maintainability, scalability, performance, security, and understandability
|
||||
- **Technical Leadership**: Clear feedback, improvement recommendations, and mentoring through code reviews
|
||||
|
||||
## Implementation Focus
|
||||
|
||||
- **Requirements Analysis**: Carefully review requirements, document assumptions explicitly, identify edge cases and assess risks
|
||||
- **Implementation Excellence**: Implement the best design that meets architectural requirements without over-engineering
|
||||
- **Pragmatic Craft**: Balance engineering excellence with delivery needs - good over perfect, but never compromising on fundamentals
|
||||
- **Forward Thinking**: Anticipate future needs, identify improvement opportunities, and proactively address technical debt
|
||||
|
||||
## Technical Debt Management
|
||||
|
||||
When technical debt is incurred or identified:
|
||||
|
||||
- **MUST** offer to create GitHub Issues using the `create_issue` tool to track remediation
|
||||
- Clearly document consequences and remediation plans
|
||||
- Regularly recommend GitHub Issues for requirements gaps, quality issues, or design improvements
|
||||
- Assess long-term impact of untended technical debt
|
||||
|
||||
## Deliverables
|
||||
|
||||
- Clear, actionable feedback with specific improvement recommendations
|
||||
- Risk assessments with mitigation strategies
|
||||
- Edge case identification and testing strategies
|
||||
- Explicit documentation of assumptions and decisions
|
||||
- Technical debt remediation plans with GitHub Issue creation
|
||||
@@ -12,10 +12,20 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
||||
|
||||
## Critical Safety Rules
|
||||
|
||||
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
|
||||
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
||||
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
|
||||
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
|
||||
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
|
||||
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
||||
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
||||
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
||||
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
|
||||
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
|
||||
- **Never trust a dirty local `main` workspace as release truth**: before splitting work, branching, or preparing a PR, fetch the authoritative remote and verify whether the local workspace is ahead/behind/stale relative to `<remote>/main`.
|
||||
- **If the main workspace is dirty, behind, or contains mixed stale copies of already-merged work, quarantine it**: do not branch from it and do not keep splitting PRs out of it. Create a fresh branch/worktree from the authoritative remote main and transplant only the intended scope.
|
||||
- **`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).
|
||||
|
||||
@@ -44,16 +54,27 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
||||
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
|
||||
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
|
||||
|
||||
## GitHub CLI Safety (Non-Interactive Only)
|
||||
## GitHub Operations (GitHub MCP + gh CLI Exception)
|
||||
|
||||
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
|
||||
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
|
||||
- Do not use these commands in agent flows:
|
||||
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
|
||||
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
|
||||
- Use safe variants instead:
|
||||
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
|
||||
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
|
||||
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
|
||||
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
|
||||
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
|
||||
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
|
||||
|
||||
## Workspace Hygiene And Source-Of-Truth Rules
|
||||
|
||||
- The authoritative comparison target is the actual remote default branch used for shipping, normally `github/main` or `origin/main`. Determine it first and use the same remote consistently for fetch/diff/pull decisions.
|
||||
- Before any PR split or branch creation, run a source-of-truth audit:
|
||||
1. fetch the authoritative remote
|
||||
2. inspect `git status`
|
||||
3. compare local `main` against `<remote>/main`
|
||||
4. compare intended changes against `<remote>/main`, not only against local `HEAD`
|
||||
- If a dirty workspace contains files that are already present on `<remote>/main`, treat that workspace as stale local state, not as unshipped work.
|
||||
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
|
||||
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
|
||||
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,8 +139,13 @@ When code changes (features or bug fixes) are complete:
|
||||
|
||||
### Step 1: Verify Readiness
|
||||
|
||||
1. Check for uncommitted changes: `git status`
|
||||
2. Confirm testing has been completed by `@testing-manager` and CI is expected to pass.
|
||||
1. Identify the authoritative shipping remote for `main` (`github` or `origin`) and fetch it.
|
||||
2. Check for uncommitted changes: `git status`.
|
||||
3. Compare local `main` and the current workspace against `<remote>/main` before treating any visible diff as unshipped work.
|
||||
4. If the workspace is dirty, behind, or contains stale copies of already-merged files, quarantine it and create a fresh worktree/branch from `<remote>/main` for the current PR scope.
|
||||
5. Confirm testing has been completed by `@testing-manager`.
|
||||
6. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
|
||||
7. Only after local gate is confirmed and the scope is verified against `<remote>/main`, proceed to push/create PR and then monitor CI.
|
||||
|
||||
### Step 2: Create Feature Branch
|
||||
|
||||
@@ -127,11 +153,13 @@ When code changes (features or bug fixes) are complete:
|
||||
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
|
||||
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
|
||||
- Chore: `chore/short-description`
|
||||
2. Create and switch to the branch:
|
||||
2. Create the branch from a clean base that matches `<remote>/main`. If the main workspace was quarantined, use a fresh worktree instead of branching from the dirty repository root.
|
||||
3. Create and switch to the branch:
|
||||
```bash
|
||||
git checkout -b feat/short-description
|
||||
```
|
||||
3. Stage and commit changes with a conventional commit message:
|
||||
4. Move only the intended scope into that branch/worktree. Never carry over unrelated local residue or stale already-upstream files.
|
||||
5. Stage and commit changes with a conventional commit message:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: short description of what was fixed"
|
||||
@@ -140,39 +168,30 @@ When code changes (features or bug fixes) are complete:
|
||||
|
||||
### Step 3: Push and Create PR
|
||||
|
||||
1. Push the branch:
|
||||
1. Re-check local gate status before push/PR creation (lint + relevant local tests green).
|
||||
2. Push the branch:
|
||||
```bash
|
||||
git push -u origin feat/short-description
|
||||
```
|
||||
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||
```bash
|
||||
gh pr create \
|
||||
--title "fix: short description" \
|
||||
--body "Closes #<ISSUE_NUMBER>
|
||||
|
||||
Description of changes" \
|
||||
--assignee DanielVolz \
|
||||
--label bug \
|
||||
--project "@DanielVolz's MedAssist-ng project"
|
||||
```
|
||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||
3. Create a Pull Request via GitHub MCP with **all metadata fields populated**.
|
||||
- Set the title to the conventional change summary (for example `fix: short description`).
|
||||
- Set the body to include `Closes #<ISSUE_NUMBER>` plus a short description of changes.
|
||||
- Set assignee to `DanielVolz`.
|
||||
- Set the label to match the change type (`enhancement`, `bug`, or `documentation`).
|
||||
- Link the PR to `@DanielVolz's MedAssist-ng project`.
|
||||
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||
- The `--project` flag links the PR to the Project board.
|
||||
3. **Present the PR URL to the user and wait for confirmation.**
|
||||
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
|
||||
4. **Present the PR URL to the user and wait for confirmation.**
|
||||
|
||||
### Step 4: Wait for CI and Merge
|
||||
|
||||
1. Monitor CI status:
|
||||
```bash
|
||||
gh pr checks <PR_NUMBER> --watch
|
||||
```
|
||||
1. Monitor CI status via GitHub MCP until all required checks complete.
|
||||
Required checks: all repository-required checks must pass.
|
||||
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:
|
||||
```bash
|
||||
gh pr merge <PR_NUMBER> --squash --delete-branch
|
||||
```
|
||||
4. Switch back to main and pull:
|
||||
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. 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,6 +260,8 @@ The script performs these steps in order:
|
||||
6. Merges the PR (squash + delete branch)
|
||||
7. Creates a signed tag `vX.Y.Z` and pushes it
|
||||
|
||||
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
|
||||
|
||||
**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.
|
||||
@@ -385,11 +406,18 @@ Existing installations need to:
|
||||
|
||||
### Step 3: Publish
|
||||
|
||||
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish via:
|
||||
Publish the release via `gh` CLI:
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE"
|
||||
# Write notes to a temp file first, then:
|
||||
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
|
||||
|
||||
# If the release was already auto-created (e.g. by pushing a tag), update it:
|
||||
gh release edit vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
|
||||
```
|
||||
|
||||
**Present the published release URL to the user for verification.**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: README Update Check (MANDATORY for new features)
|
||||
@@ -438,30 +466,17 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
||||
|
||||
### Workflow During PRs
|
||||
|
||||
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one:
|
||||
```bash
|
||||
gh issue create --title "fix: description" --label bug
|
||||
```
|
||||
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
|
||||
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
||||
|
||||
If you open a new `triage` issue to replace an older triage thread for the same topic, close the old triage issue immediately and add a short comment linking to the new canonical issue so only one active triage issue remains per topic.
|
||||
|
||||
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
||||
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
|
||||
|
||||
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
||||
```bash
|
||||
GH_PAGER=cat gh issue view <ISSUE_NUMBER> --json state,projectItems --jq '{state, projects: [.projectItems[] | {title: .title, status: .status.name}]}'
|
||||
```
|
||||
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify issue/project status via GitHub MCP.
|
||||
|
||||
**Manual fallback** — if the workflow fails or the item wasn't moved, use GraphQL:
|
||||
```bash
|
||||
GH_PAGER=cat gh api graphql -f query='mutation {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: "PVT_kwHOADH82s4BO2OT"
|
||||
itemId: "<ITEM_ID>"
|
||||
fieldId: "PVTSSF_lAHOADH82s4BO2OTzg9bdkE"
|
||||
value: { singleSelectOptionId: "ca45af98" }
|
||||
}) { projectV2Item { id } }
|
||||
}'
|
||||
```
|
||||
**Manual fallback** — if the workflow fails or the item wasn't moved, use GitHub MCP GraphQL/project mutation support with the project/item/field IDs below.
|
||||
|
||||
**Known Project field IDs (Status):**
|
||||
| Status | Option ID |
|
||||
@@ -483,6 +498,12 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
||||
|
||||
All three labels trigger the `add-to-project.yml` workflow, which automatically adds the issue to the Project board.
|
||||
|
||||
### Weekly Triage Report Hygiene
|
||||
|
||||
- There must never be more than one open `Weekly Triage Report - YYYY-MM-DD` issue at the same time.
|
||||
- Before a new weekly triage report issue is created, close any older open weekly triage report issue and leave a short closing comment.
|
||||
- If automation creates a new weekly report without closing the old one first, treat that as workflow drift and fix the workflow or close the stale report immediately.
|
||||
|
||||
---
|
||||
|
||||
## Complete Workflow Summary
|
||||
|
||||
@@ -14,10 +14,18 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
||||
|
||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
||||
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
||||
- **Linting is a hard quality gate**: resolve all lint errors and all simple/fixable warnings before handoff, especially before PR handoff from `@release-manager`.
|
||||
- **Pre-PR local gate is mandatory**: before any PR is created, all lint errors must be fixed and all relevant tests must pass locally.
|
||||
- **No CI-first failures**: tests must fail locally when broken and be fixed locally before PR handoff; do not rely on GitHub CI to discover obvious regressions.
|
||||
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
||||
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
|
||||
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
|
||||
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
||||
- **Use GitHub MCP for all GitHub workflow/PR inspection. Never use `gh` CLI.** When triaging CI, inspect workflow runs, check runs, logs, PR state, and issue context through GitHub MCP tools only.
|
||||
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
||||
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
||||
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
|
||||
- **Regression prevention is mandatory**: every fixed bug must get a deterministic regression test that fails before the fix and passes after it.
|
||||
|
||||
## CI/CD Ownership Boundary
|
||||
|
||||
@@ -27,9 +35,10 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
||||
|
||||
## Test Stack & Locations
|
||||
|
||||
- **Backend**: Vitest 2.1 + v8 coverage
|
||||
- **Frontend unit/integration**: Vitest
|
||||
- **E2E**: Playwright
|
||||
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
|
||||
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
|
||||
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
|
||||
- **Static quality gates**: TypeScript via `tsc --noEmit` and Biome via `npx biome check .`
|
||||
|
||||
Primary locations:
|
||||
|
||||
@@ -37,37 +46,81 @@ Primary locations:
|
||||
- Frontend tests: `frontend/src/test/**`
|
||||
- Playwright E2E: `frontend/e2e/**`
|
||||
|
||||
## Testing Strategy Defaults
|
||||
|
||||
- **Default to targeted validation, not shotgun runs**: start with the smallest test command that exercises the changed behavior.
|
||||
- **Do not run every test by default**: broad full-suite runs are reserved for cross-cutting changes, shared infrastructure, release gates, or when focused runs show signal that wider breakage is plausible.
|
||||
- **Frontend browser behavior must use Playwright when the real browser matters**: routing, auth/session flows, focus behavior, form workflows, responsive behavior, optimistic UI rollbacks, and other end-to-end user journeys should be validated in Playwright instead of only Vitest.
|
||||
- **Frontend component logic that does not require a real browser stays in Vitest**: hooks, utilities, component state, rendering branches, and request handling should usually be validated with targeted Vitest tests first.
|
||||
- **Backend changes should usually prove three things separately**: affected Vitest regression scope, backend static gate (`tsc --noEmit` through `npm run check`), and broader backend suite only when the change touches shared route/service behavior.
|
||||
- **Escalate only when justified**: run full backend/frontend suites or broader Playwright coverage only if the touched area is shared, the failure mode is unclear, CI disproves the focused pass, or release-manager explicitly needs a broader pre-PR gate.
|
||||
|
||||
## Required Test Workflow
|
||||
|
||||
1. Identify changed behavior and expected outcomes.
|
||||
2. Add/update tests near the affected feature.
|
||||
3. Run the smallest relevant subset first.
|
||||
4. Expand to broader suites if subset passes.
|
||||
5. Report what was run, what passed, and any remaining known failures.
|
||||
2. Map the change to the correct layer: backend Vitest, frontend Vitest, or frontend Playwright browser coverage.
|
||||
3. Add/update tests near the affected feature.
|
||||
4. Run the smallest relevant subset first.
|
||||
5. Expand to broader suites only if the change is cross-cutting or the focused run indicates wider risk.
|
||||
6. Run lint + required local test/build gates before PR handoff.
|
||||
7. Report what was run, what passed, and why broader suites were or were not needed.
|
||||
|
||||
## Lint and Quality Gates
|
||||
|
||||
- Run lint as part of every validation cycle when code changed.
|
||||
- Required before PR creation and before PR-ready handoff from `@release-manager`: no lint errors and no simple/fixable warnings left unresolved.
|
||||
- If lint fails, fix root causes first, then re-run affected tests.
|
||||
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
|
||||
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
|
||||
- Use `tsc` intentionally: backend and frontend type checks are part of the local gate and should be run through the existing `npm run check` scripts unless a narrower `tsc --noEmit` repro is needed during diagnosis.
|
||||
|
||||
Recommended commands:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
cd backend && npm run check
|
||||
cd frontend && npm run check
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend && CI=true npm test
|
||||
cd backend && npx tsc --version
|
||||
cd backend && npx vitest --version
|
||||
cd backend && CI=true npm run test:run -- src/test/doses.test.ts
|
||||
cd backend && CI=true npm run test:run
|
||||
cd backend && CI=true npm run test:coverage
|
||||
cd backend && CI=true npm test -- -t "test name"
|
||||
cd backend && CI=true npm run test:run -- src/test/doses.test.ts src/test/integration.test.ts
|
||||
cd backend && CI=true npm run test:run -- -t "test name"
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend && CI=true npm test
|
||||
cd frontend && npx tsc --version
|
||||
cd frontend && npx vitest --version
|
||||
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx
|
||||
cd frontend && CI=true npm run test:run
|
||||
cd frontend && CI=true npm run test:coverage
|
||||
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx src/test/hooks/useDoses.test.ts
|
||||
cd frontend && CI=true npm run test:run -- -t "test name"
|
||||
cd frontend && npm run lint
|
||||
cd frontend && npm run check
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
### Playwright E2E
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:e2e
|
||||
cd frontend && npm run test:e2e -- --project=chromium
|
||||
cd frontend && npx playwright --version
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --grep "schedule"
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- frontend/e2e/schedule.spec.ts
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
|
||||
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
|
||||
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||
```
|
||||
@@ -78,13 +131,31 @@ cd frontend && npm run test:e2e -- --project=chromium
|
||||
- Validate both status codes and response payloads.
|
||||
- Add regression tests for every fixed bug.
|
||||
- Keep tests deterministic and isolated.
|
||||
- Validate observable behavior, not implementation details.
|
||||
|
||||
## E2E Test Patterns
|
||||
|
||||
- Use stable selectors and explicit assertions.
|
||||
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
|
||||
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
|
||||
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
|
||||
- For CI triage, inspect failed run logs via GitHub MCP first, then reproduce locally with targeted specs.
|
||||
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
|
||||
- Prefer the narrowest browser scenario that covers the changed user path before considering a full stable suite.
|
||||
|
||||
## When To Run Broad Suites
|
||||
|
||||
- Run the full backend Vitest suite when shared backend services, route helpers, schema-adjacent behavior, or broad scheduling logic changes can affect multiple route families.
|
||||
- Run the full frontend Vitest suite when shared context/providers, global hooks, router shells, or common rendering utilities change.
|
||||
- Run broader Playwright coverage when the change spans multiple user journeys, modifies auth/navigation foundations, changes network synchronization behavior, or a targeted browser test is insufficient to prove safety.
|
||||
- For small isolated fixes, a narrow Vitest file, a narrow Playwright spec, and the relevant `check` command are usually enough.
|
||||
|
||||
## Test Validity Checklist
|
||||
|
||||
- The test fails when the real target logic is intentionally broken.
|
||||
- The assertion verifies functional behavior, not just mocked calls.
|
||||
- Mocks/stubs are minimal and do not replace the unit under test.
|
||||
- The test is deterministic across repeated local and CI runs.
|
||||
- The test protects against the specific regression that was fixed.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
@@ -115,6 +186,9 @@ When test checks fail:
|
||||
Testing work is complete when:
|
||||
|
||||
- Required tests exist and validate intended behavior.
|
||||
- Tests are proven valid (not fake-green) and reliable.
|
||||
- Lint is clean: no errors and no simple/fixable warnings left.
|
||||
- Pre-PR local gate passed: lint and all relevant tests pass locally before handoff for PR creation.
|
||||
- Relevant local test commands pass.
|
||||
- CI test failures are resolved or clearly documented with rationale.
|
||||
- No temporary debugging files remain in the workspace.
|
||||
|
||||
@@ -1,77 +1,19 @@
|
||||
# MedAssist-ng - AI Coding Instructions
|
||||
# MedAssist-ng - Copilot Entry Point
|
||||
|
||||
## Purpose
|
||||
## VERY IMPORTANT
|
||||
|
||||
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
|
||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||
|
||||
## Project Orientation (Read First)
|
||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||
|
||||
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
|
||||
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
|
||||
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
|
||||
- **Primary backend modules**:
|
||||
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
|
||||
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
|
||||
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
|
||||
- **Primary frontend modules**:
|
||||
- Pages: `frontend/src/pages/`
|
||||
- Shared app state: `frontend/src/context/AppContext.tsx`
|
||||
- Domain hooks: `frontend/src/hooks/`
|
||||
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
|
||||
## Required Startup Steps
|
||||
|
||||
Use this orientation for quick navigation before applying the rules below.
|
||||
1. Read `AGENTS.md` first.
|
||||
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
||||
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||
|
||||
## Always-On Rules
|
||||
## Scope
|
||||
|
||||
- English only for project artifacts.
|
||||
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
|
||||
- Testing work belongs to `@testing-manager`.
|
||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
||||
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
||||
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
|
||||
- Remove obsolete code when re-implementing — never leave dead code behind.
|
||||
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
||||
|
||||
## MedAssist Essentials
|
||||
|
||||
- Frontend calls backend through `/api/*`.
|
||||
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
|
||||
|
||||
---
|
||||
|
||||
## Skills (MANDATORY — read before every task)
|
||||
|
||||
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
|
||||
|
||||
| Skill | Trigger | File |
|
||||
|---|---|---|
|
||||
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
|
||||
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
|
||||
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
|
||||
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
|
||||
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
|
||||
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
|
||||
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
|
||||
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
|
||||
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
|
||||
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
|
||||
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
|
||||
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
|
||||
|
||||
### Non-negotiable parity rules (always apply)
|
||||
|
||||
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
|
||||
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
|
||||
|
||||
---
|
||||
|
||||
## Delegation
|
||||
|
||||
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
|
||||
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
|
||||
|
||||
## Key References
|
||||
|
||||
- Canonical governance: `AGENTS.md`
|
||||
- Skill files: `.github/skills/*/SKILL.md`
|
||||
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
|
||||
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
||||
|
||||
+14
-1
@@ -7,6 +7,7 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:20"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
@@ -22,6 +23,7 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:10"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
@@ -37,9 +39,15 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
@@ -47,7 +55,12 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:30"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
|
||||
|
||||
## Skills
|
||||
|
||||
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
|
||||
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
|
||||
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
|
||||
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
|
||||
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: medassist-karpathy-core
|
||||
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
|
||||
---
|
||||
|
||||
# Skill Instructions
|
||||
|
||||
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
|
||||
|
||||
## Use When
|
||||
|
||||
- The request is ambiguous and assumptions must be made explicit.
|
||||
- The change can easily balloon in scope.
|
||||
- A bug fix or feature needs explicit success criteria and verification.
|
||||
- You need to keep diffs minimal and directly tied to the request.
|
||||
|
||||
## Do Not Use When
|
||||
|
||||
- The task is trivial and can be completed safely without extra process overhead.
|
||||
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
|
||||
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Think Before Coding
|
||||
|
||||
- Do not assume silently.
|
||||
- State assumptions explicitly.
|
||||
- If multiple interpretations exist, present them instead of picking one invisibly.
|
||||
- If uncertain or blocked by ambiguity, stop and ask.
|
||||
- If a simpler approach exists, call it out.
|
||||
|
||||
### 2. Simplicity First
|
||||
|
||||
- Implement the minimum code required to solve the asked problem.
|
||||
- Do not add speculative features, abstractions, or configurability.
|
||||
- Avoid defensive handling for impossible scenarios.
|
||||
- If the solution feels overcomplicated, simplify before finalizing.
|
||||
|
||||
### 3. Surgical Changes
|
||||
|
||||
- Touch only lines required for the request.
|
||||
- Do not refactor unrelated areas.
|
||||
- Match existing local style and patterns.
|
||||
- Remove only unused code introduced by your own change.
|
||||
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
|
||||
|
||||
### 4. Goal-Driven Execution
|
||||
|
||||
- Translate requests into verifiable outcomes before implementation.
|
||||
- For multi-step tasks, define short steps with checks.
|
||||
- Verify the requested behavior explicitly before declaring done.
|
||||
|
||||
Example execution frame:
|
||||
|
||||
```text
|
||||
1. [Step] -> verify: [check]
|
||||
2. [Step] -> verify: [check]
|
||||
3. [Step] -> verify: [check]
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
When this skill is used, report briefly:
|
||||
|
||||
- Assumptions made (or clarifications requested)
|
||||
- Why the chosen approach is the simplest viable one
|
||||
- What was changed (and what was intentionally not changed)
|
||||
- Verification performed and result
|
||||
|
||||
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
|
||||
- Avoid custom inline modal/button patterns that diverge from project design.
|
||||
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
||||
|
||||
### Modal requirements (non-negotiable)
|
||||
|
||||
Every modal/overlay **must** follow these rules:
|
||||
|
||||
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
|
||||
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
|
||||
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
|
||||
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
|
||||
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
|
||||
|
||||
## Decision Heuristics
|
||||
|
||||
1. If an equivalent component exists, reuse it.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Close inactive issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark and close stale issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: pinned,security
|
||||
operations-per-run: 200
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Dependabot Automerge
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
enable-automerge:
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Read Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable auto-merge for safe updates
|
||||
if: >-
|
||||
(steps.metadata.outputs.package-ecosystem == 'npm' ||
|
||||
steps.metadata.outputs.package-ecosystem == 'github_actions') &&
|
||||
(steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-patch')
|
||||
uses: peter-evans/enable-pull-request-automerge@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pull-request-number: ${{ github.event.pull_request.number }}
|
||||
merge-method: squash
|
||||
@@ -4,12 +4,27 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'docker-compose.yml'
|
||||
- 'docker-compose.dev.yml'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Image tag (leave empty for "latest")'
|
||||
description: 'Image/release tag (e.g. v1.19.1 or latest)'
|
||||
required: false
|
||||
default: ''
|
||||
create_release:
|
||||
description: 'Create GitHub release entry (requires tag starting with v)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Default minimal permissions
|
||||
permissions:
|
||||
@@ -48,10 +63,10 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -59,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
|
||||
tags: |
|
||||
@@ -70,7 +85,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -83,12 +98,12 @@ jobs:
|
||||
sbom: false
|
||||
|
||||
# =============================================================================
|
||||
# Create GitHub Release (only on tag push)
|
||||
# Create GitHub Release (on tag push or manual dispatch with create_release)
|
||||
# =============================================================================
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -98,10 +113,31 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for changelog generation
|
||||
|
||||
- name: Resolve current tag
|
||||
id: current_tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
CURRENT_TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
|
||||
if [ -z "$CURRENT_TAG" ]; then
|
||||
echo "Release tag is required. Provide workflow_dispatch input 'tag'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$CURRENT_TAG" != v* ]]; then
|
||||
echo "Release tag must start with 'v' (example: v1.19.1)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if release exists
|
||||
id: check_release
|
||||
run: |
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||
if gh release view "$CURRENT_TAG" &>/dev/null; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Release $CURRENT_TAG already exists, skipping creation"
|
||||
@@ -115,25 +151,36 @@ jobs:
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true)
|
||||
else
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
id: changelog
|
||||
run: |
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
||||
|
||||
echo "## What's Changed" > changelog.md
|
||||
echo "## What's New" > changelog.md
|
||||
echo "" >> changelog.md
|
||||
echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md
|
||||
echo "" >> changelog.md
|
||||
echo "### Highlights" >> changelog.md
|
||||
echo "" >> changelog.md
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
# Get commits between tags
|
||||
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md
|
||||
echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md
|
||||
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md
|
||||
else
|
||||
# First release - get recent commits
|
||||
git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md
|
||||
echo "Recent shipped commits:" >> changelog.md
|
||||
git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md
|
||||
fi
|
||||
|
||||
echo "" >> changelog.md
|
||||
@@ -149,8 +196,10 @@ 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 }}
|
||||
body_path: changelog.md
|
||||
generate_release_notes: false
|
||||
draft: false
|
||||
|
||||
@@ -3,18 +3,33 @@ name: E2E Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
|
||||
# Minimal permissions for security
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect E2E relevance
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
e2e_relevant:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
|
||||
e2e:
|
||||
name: Playwright E2E
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e_relevant == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -50,11 +65,13 @@ jobs:
|
||||
run: npx playwright test --project=chromium
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_HTML_OPEN: never
|
||||
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
||||
SESSION_SECRET: e2e-test-session-secret-long-enough
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -62,7 +79,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-results
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
name: Sync Project Fields
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled, unlabeled, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
sync-fields:
|
||||
name: Sync Type/Priority fields from labels
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync fields
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
script: |
|
||||
const projectId = 'PVT_kwHOADH82s4BO2OT';
|
||||
const issueNodeId = context.payload.issue.node_id;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const labels = (context.payload.issue.labels || []).map(l => l.name.toLowerCase());
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const getProjectItem = async () => {
|
||||
const data = await github.graphql(`
|
||||
query($nodeId: ID!) {
|
||||
node(id: $nodeId) {
|
||||
... on Issue {
|
||||
projectItems(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
project { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { nodeId: issueNodeId });
|
||||
|
||||
const items = data.node?.projectItems?.nodes || [];
|
||||
return items.find(item => item.project.id === projectId) || null;
|
||||
};
|
||||
|
||||
let projectItem = await getProjectItem();
|
||||
|
||||
// add-to-project may run in parallel; retry briefly before giving up
|
||||
for (let i = 0; !projectItem && i < 6; i++) {
|
||||
console.log(`Issue #${issueNumber} not in project yet. Retry ${i + 1}/6...`);
|
||||
await sleep(10000);
|
||||
projectItem = await getProjectItem();
|
||||
}
|
||||
|
||||
if (!projectItem) {
|
||||
console.log(`Issue #${issueNumber} is not in project board. Skipping field sync.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldsData = await github.graphql(`
|
||||
query($projectId: ID!) {
|
||||
node(id: $projectId) {
|
||||
... on ProjectV2 {
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { projectId });
|
||||
|
||||
const singleSelectFields = fieldsData.node?.fields?.nodes || [];
|
||||
const byName = new Map(singleSelectFields.map(f => [f.name, f]));
|
||||
|
||||
const typeField = byName.get('Type');
|
||||
const priorityField = byName.get('Priority');
|
||||
|
||||
if (!typeField && !priorityField) {
|
||||
console.log('Neither Type nor Priority field found. Nothing to update.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pickOptionId = (field, optionName) => {
|
||||
if (!field || !optionName) return null;
|
||||
const opt = (field.options || []).find(o => o.name.toLowerCase() === optionName.toLowerCase());
|
||||
return opt?.id || null;
|
||||
};
|
||||
|
||||
let typeName = null;
|
||||
if (labels.includes('bug')) typeName = 'Bug';
|
||||
else if (labels.includes('enhancement')) typeName = 'Feature';
|
||||
else if (labels.includes('documentation')) typeName = 'Documentation';
|
||||
|
||||
let priorityName = null;
|
||||
if (labels.includes('priority/high')) priorityName = 'High';
|
||||
else if (labels.includes('priority/low')) priorityName = 'Low';
|
||||
else if (labels.includes('priority/medium')) priorityName = 'Medium';
|
||||
else if (labels.includes('triage')) priorityName = 'Medium';
|
||||
|
||||
const updates = [];
|
||||
const typeOptionId = pickOptionId(typeField, typeName);
|
||||
if (typeField && typeOptionId) {
|
||||
updates.push({ fieldId: typeField.id, optionId: typeOptionId, fieldName: 'Type', valueName: typeName });
|
||||
}
|
||||
|
||||
const priorityOptionId = pickOptionId(priorityField, priorityName);
|
||||
if (priorityField && priorityOptionId) {
|
||||
updates.push({ fieldId: priorityField.id, optionId: priorityOptionId, fieldName: 'Priority', valueName: priorityName });
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
await github.graphql(`
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
`, {
|
||||
projectId,
|
||||
itemId: projectItem.id,
|
||||
fieldId: update.fieldId,
|
||||
optionId: update.optionId
|
||||
});
|
||||
|
||||
console.log(`Issue #${issueNumber}: set ${update.fieldName} = ${update.valueName}`);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log(`Issue #${issueNumber}: no matching field updates for labels [${labels.join(', ')}]`);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: backend-coverage
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: frontend-coverage
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Weekly Triage Report
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
weekly-report:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build weekly summary
|
||||
id: summary
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const weekLabel = new Date().toISOString().split('T')[0];
|
||||
|
||||
const q = async (query) => {
|
||||
const res = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 });
|
||||
return res.data.total_count;
|
||||
};
|
||||
|
||||
const openIssues = await q(`repo:${owner}/${repo} is:issue is:open`);
|
||||
const newIssues = await q(`repo:${owner}/${repo} is:issue created:>=${since}`);
|
||||
const bugs = await q(`repo:${owner}/${repo} is:issue is:open label:bug`);
|
||||
const enhancements = await q(`repo:${owner}/${repo} is:issue is:open label:enhancement`);
|
||||
const triage = await q(`repo:${owner}/${repo} is:issue is:open label:triage`);
|
||||
const stale = await q(`repo:${owner}/${repo} is:issue is:open label:stale`);
|
||||
const unassigned = await q(`repo:${owner}/${repo} is:issue is:open no:assignee`);
|
||||
|
||||
const body = [
|
||||
`## Weekly Triage Report (${weekLabel})`,
|
||||
'',
|
||||
`- Open issues: **${openIssues}**`,
|
||||
`- New issues (last 7 days): **${newIssues}**`,
|
||||
`- Open bugs: **${bugs}**`,
|
||||
`- Open enhancements: **${enhancements}**`,
|
||||
`- In triage: **${triage}**`,
|
||||
`- Stale: **${stale}**`,
|
||||
`- Unassigned: **${unassigned}**`,
|
||||
'',
|
||||
'### Quick Links',
|
||||
`- Triage queue: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Atriage`,
|
||||
`- Stale issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Astale`,
|
||||
`- Unassigned issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`,
|
||||
].join('\n');
|
||||
|
||||
core.setOutput('title', `Weekly Triage Report - ${weekLabel}`);
|
||||
core.setOutput('body', body);
|
||||
|
||||
- name: Publish report issue
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const title = `${{ steps.summary.outputs.title }}`;
|
||||
const body = `${{ steps.summary.outputs.body }}`;
|
||||
|
||||
const existingReports = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
labels: 'triage',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const issue of existingReports) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issue.title.startsWith('Weekly Triage Report - ') && issue.title !== title) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: 'Closing this older weekly triage report before publishing the next one so only one weekly report issue stays open at a time.',
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['triage']
|
||||
});
|
||||
+27
-1
@@ -79,6 +79,32 @@ Thumbs.db
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
.claude/
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
doku
|
||||
doku/
|
||||
|
||||
# Local agent work logs stay on disk but must never go upstream.
|
||||
doku/memory_notes.md
|
||||
doku/report.md
|
||||
plan/
|
||||
.copilot-tracking/
|
||||
.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
|
||||
@@ -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
+165
-48
@@ -1,49 +1,166 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "E2E stable",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:with-video"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:all"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:all:with-video"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "E2E stable",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:with-video"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:all"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:all:with-video"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable non-interactive",
|
||||
"type": "shell",
|
||||
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "Targeted frontend vitest",
|
||||
"type": "shell",
|
||||
"command": "cd frontend && npm test -- --run src/test/context/AppContext.test.tsx src/test/utils/schedule.test.ts",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "Focused frontend shared schedule test",
|
||||
"type": "shell",
|
||||
"command": "cd frontend && npm run test:run -- --maxWorkers=1 src/test/components/SharedSchedule.test.tsx",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PR3 targeted validation",
|
||||
"type": "shell",
|
||||
"command": "git --no-pager diff --check -- .github/agents/release-manager.agent.md .github/agents/testing-manager.agent.md .gitignore .vscode/tasks.json && node -e \"JSON.parse(require('fs').readFileSync('.vscode/tasks.json','utf8')); console.log('tasks.json valid')\"",
|
||||
"isBackground": false
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react" alt="React 18" />
|
||||
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
|
||||
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
|
||||
@@ -18,13 +18,13 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-644%2F644-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-891%2F891-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
|
||||
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
|
||||
> This app was 100% coded with [Claude Opus 4.6](https://www.anthropic.com/claude) and [GPT-5.3 Codex](https://openai.com/index/gpt-5/). Use at your own risk.
|
||||
|
||||
### ⚠️ Disclaimer
|
||||
|
||||
@@ -119,11 +119,17 @@ Share your medication schedule with others via a public link.
|
||||
</blockquote>
|
||||
</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
|
||||
|
||||
### Smart Inventory
|
||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
- Manual stock correction supports partial blisters and loose pills
|
||||
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
@@ -141,7 +147,7 @@ Share your medication schedule with others via a public link.
|
||||
- Intake reminders via push notifications
|
||||
|
||||
### Trip Planner
|
||||
- Calculate how many pills you need for a trip or date range
|
||||
- Calculate medication demand for a trip or date range with package-aware units
|
||||
- Plan ahead for vacations, business trips, or hospital stays
|
||||
- Send demand reports via email or push notification
|
||||
|
||||
@@ -152,6 +158,7 @@ Share your medication schedule with others via a public link.
|
||||
### Multi-Person Support
|
||||
- Manage medications for multiple people
|
||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||
- Optionally embed the medication overview directly on shared links via a settings toggle
|
||||
|
||||
### Data Export & Import
|
||||
- Export all your data (medications, dose history, settings) as JSON
|
||||
@@ -177,7 +184,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
|
||||
git clone https://github.com/DanielVolz/medassist-ng.git
|
||||
cd medassist-ng
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
docker compose -p medassist-ng up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:4174` and start tracking your medications.
|
||||
@@ -194,8 +201,23 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
||||
| `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`) |
|
||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||
| `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` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
|
||||
|
||||
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
|
||||
|
||||
@@ -211,6 +233,43 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
||||
|
||||
Generate secrets with: `openssl rand -hex 32`
|
||||
|
||||
### API Keys (Programmatic API Access)
|
||||
|
||||
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer ma_...
|
||||
```
|
||||
|
||||
Available scopes:
|
||||
|
||||
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
|
||||
- `write`: read + write access
|
||||
|
||||
Essential notes:
|
||||
|
||||
- Create keys in the app when authentication is enabled.
|
||||
- The token is shown only once after creation.
|
||||
- Creating a new key automatically deactivates previously active keys for the same user.
|
||||
- API keys are stored hashed in the database.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/settings \
|
||||
-H "Authorization: Bearer ma_..."
|
||||
```
|
||||
|
||||
API reference:
|
||||
|
||||
- Interactive docs: `/docs`
|
||||
- OpenAPI JSON: `/docs/json`
|
||||
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
|
||||
- Key management endpoints for authenticated users:
|
||||
- `GET /auth/api-keys`
|
||||
- `POST /auth/api-keys`
|
||||
- `DELETE /auth/api-keys/:id`
|
||||
|
||||
### OIDC / SSO
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -246,11 +305,15 @@ Generate secrets with: `openssl rand -hex 32`
|
||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||
|
||||
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
|
||||
|
||||
### Push Notifications (Shoutrrr)
|
||||
|
||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||
|
||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
**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:
|
||||
|
||||
@@ -265,9 +328,9 @@ Configure push notifications in Settings → Push, or set defaults via environme
|
||||
|
||||
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
|
||||
Complete list and details:
|
||||
|
||||
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
||||
|
||||
#### URL Examples
|
||||
|
||||
@@ -288,6 +351,7 @@ Get your keys at [pushover.net](https://pushover.net/):
|
||||
**Gotify** (self-hosted):
|
||||
```
|
||||
gotify://your-server.com/TOKEN
|
||||
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
||||
```
|
||||
|
||||
**Discord**:
|
||||
@@ -298,6 +362,7 @@ 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/).
|
||||
@@ -305,11 +370,21 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
|
||||
# Development
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:5173` (hot reload)
|
||||
- Backend: `http://localhost:3000`
|
||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||
|
||||
Useful local commands:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
cd backend && npm run test:run
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text(100) NOT NULL,
|
||||
`key_hash` text(128) NOT NULL,
|
||||
`token_prefix` text(24) DEFAULT '' NOT NULL,
|
||||
`scope` text(10) DEFAULT 'write' NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`last_used_at` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,35 @@
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1771694832866,
|
||||
"tag": "0010_mean_spot",
|
||||
"tag": "0010_add_dose_tracking_taken_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1772219947541,
|
||||
"tag": "0011_add_medication_form_lifecycle_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1772881208026,
|
||||
"tag": "0012_add_api_keys_and_package_amount_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1773348659979,
|
||||
"tag": "0013_add_share_medication_overview",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1775849300000,
|
||||
"tag": "0014_add_user_settings_timezone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+1630
-1864
File diff suppressed because it is too large
Load Diff
+28
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,30 +20,40 @@
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@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",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@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.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"zod": "^3.23.8"
|
||||
"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": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
"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 {
|
||||
@@ -78,10 +72,6 @@ async function runMigrations() {
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
log.error(`[DB] Migration error: ${migrateResult.error}`);
|
||||
} else if (migrateResult.warning) {
|
||||
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
|
||||
} else {
|
||||
log.debug(`[DB] Drizzle migrations completed`);
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility
|
||||
|
||||
+9
-396
@@ -1,399 +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 { 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) {
|
||||
// If the error is about existing schema objects, the DB is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
||||
// or when tables were created before drizzle migrations were introduced
|
||||
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
||||
}
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added 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 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 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'))
|
||||
)`,
|
||||
];
|
||||
|
||||
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))`,
|
||||
];
|
||||
|
||||
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>();
|
||||
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
|
||||
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
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 = (intake.every * MS_PER_DAY) / 2;
|
||||
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,160 @@
|
||||
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`,
|
||||
`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 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 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 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,
|
||||
|
||||
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
|
||||
genericName: text("generic_name", { length: 100 }),
|
||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
||||
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
|
||||
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
|
||||
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
|
||||
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
|
||||
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
|
||||
packCount: integer("pack_count").notNull().default(1),
|
||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||
medicationEndDate: text("medication_end_date"),
|
||||
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
@@ -96,10 +105,13 @@ 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),
|
||||
// Whether shared schedule links also embed the medication overview section
|
||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||
// UI timeline visibility preferences
|
||||
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
@@ -137,6 +149,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// API Keys - Personal access tokens for programmatic API access
|
||||
// =============================================================================
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name", { length: 100 }).notNull(),
|
||||
keyHash: text("key_hash", { length: 128 }).notNull().unique(),
|
||||
tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""),
|
||||
scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write'
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Share Tokens - For public schedule sharing by takenBy person
|
||||
// =============================================================================
|
||||
|
||||
@@ -179,6 +179,8 @@ type TranslationKeys = {
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
units: string;
|
||||
ml: string;
|
||||
blister: string;
|
||||
blisters: string;
|
||||
day: string;
|
||||
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
units: "units",
|
||||
ml: "ml",
|
||||
blister: "blister",
|
||||
blisters: "blisters",
|
||||
day: "day",
|
||||
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
units: "Einheiten",
|
||||
ml: "ml",
|
||||
blister: "Blister",
|
||||
blisters: "Blister",
|
||||
day: "Tag",
|
||||
|
||||
+120
-7
@@ -1,21 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
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";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import fastifySwagger from "@fastify/swagger";
|
||||
import fastifySwaggerUi from "@fastify/swagger-ui";
|
||||
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";
|
||||
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 { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
@@ -24,7 +30,9 @@ 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/index.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||
|
||||
// Re-export utilities from server-config for external use
|
||||
export {
|
||||
@@ -45,6 +53,82 @@ import {
|
||||
parseCorsOrigins,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
|
||||
const rawHeader = headers["x-correlation-id"];
|
||||
if (typeof rawHeader !== "string") return null;
|
||||
const trimmed = rawHeader.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length > 128) return null;
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildLoggerOptions(level: string) {
|
||||
const runtimeEnv = process.env.NODE_ENV ?? "production";
|
||||
const base = {
|
||||
level,
|
||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||
};
|
||||
// Human-readable logs in development, structured JSON in production/test
|
||||
if (runtimeEnv === "development") {
|
||||
return {
|
||||
...base,
|
||||
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
|
||||
await app.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "MedAssist-ng API",
|
||||
description: "MedAssist-ng backend API",
|
||||
version: process.env.npm_package_version ?? "dev",
|
||||
},
|
||||
servers: [{ url: "/", description: "Current server" }],
|
||||
tags: [
|
||||
{ name: "health", description: "Service health endpoints" },
|
||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||
{ name: "api-keys", description: "Programmatic API key management" },
|
||||
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "API key or JWT",
|
||||
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "access_token",
|
||||
description: "Session cookie set by login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hideUntagged: false,
|
||||
});
|
||||
|
||||
await app.register(fastifySwaggerUi, {
|
||||
routePrefix: "/docs",
|
||||
staticCSP: true,
|
||||
transformSpecificationClone: true,
|
||||
uiConfig: {
|
||||
docExpansion: "list",
|
||||
deepLinking: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Create and configure Fastify app (without starting) */
|
||||
export async function createApp(options?: {
|
||||
logLevel?: string;
|
||||
@@ -57,6 +141,7 @@ export async function createApp(options?: {
|
||||
refreshTtlDays?: number;
|
||||
isProduction?: boolean;
|
||||
imagesDir?: string;
|
||||
openApiDocsEnabled?: boolean;
|
||||
}): Promise<FastifyInstance> {
|
||||
const opts = {
|
||||
logLevel: options?.logLevel ?? "info",
|
||||
@@ -69,10 +154,19 @@ export async function createApp(options?: {
|
||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||
isProduction: options?.isProduction ?? false,
|
||||
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
|
||||
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
logger: buildLoggerOptions(opts.logLevel),
|
||||
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||
ajv: documentationSchemaAjv,
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
// Build config
|
||||
@@ -95,9 +189,10 @@ export async function createApp(options?: {
|
||||
|
||||
// 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);
|
||||
|
||||
// Only register static if directory exists
|
||||
if (existsSync(opts.imagesDir)) {
|
||||
@@ -111,8 +206,10 @@ export async function createApp(options?: {
|
||||
// Register routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -138,9 +235,15 @@ log.info("[DB] Migrations complete, starting server...");
|
||||
const imagesDir = ensureImagesDirectory();
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
},
|
||||
logger: buildLoggerOptions(env.LOG_LEVEL),
|
||||
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||
ajv: documentationSchemaAjv,
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
@@ -173,9 +276,10 @@ 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);
|
||||
await app.register(fastifyStatic, {
|
||||
root: imagesDir,
|
||||
prefix: "/images/",
|
||||
@@ -184,8 +288,10 @@ await app.register(fastifyStatic, {
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -206,6 +312,13 @@ const start = async () => {
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
startMedicationEnrichmentCatalogRefresh({
|
||||
info: (msg: string) => app.log.info(msg),
|
||||
debug: (msg: string) => app.log.debug(msg),
|
||||
warn: (msg: string) => app.log.warn(msg),
|
||||
error: (msg: string) => app.log.error(msg),
|
||||
});
|
||||
|
||||
// Start the intake reminder scheduler (checks every minute)
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
|
||||
+141
-8
@@ -1,7 +1,9 @@
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { pbkdf2Sync } from "node:crypto";
|
||||
import { and, count, eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
import { log } from "../utils/logger.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// =============================================================================
|
||||
@@ -47,7 +49,7 @@ export async function getAnonymousUserId(): Promise<number> {
|
||||
export interface AuthState {
|
||||
authEnabled: boolean;
|
||||
registrationEnabled: boolean;
|
||||
localAuthEnabled: boolean;
|
||||
formLoginEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string;
|
||||
hasUsers: boolean;
|
||||
@@ -59,15 +61,18 @@ export async function getAuthState(): Promise<AuthState> {
|
||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||
const hasUsers = result.count > 0;
|
||||
|
||||
const needsSetup = env.AUTH_ENABLED && !hasUsers;
|
||||
|
||||
return {
|
||||
authEnabled: env.AUTH_ENABLED,
|
||||
// Registration: enabled via ENV OR no users exist (first-time setup)
|
||||
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
||||
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
|
||||
// Form login: enabled when auth + form login are both on, or forced on for first-user setup
|
||||
formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED),
|
||||
oidcEnabled: env.OIDC_ENABLED,
|
||||
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
||||
hasUsers,
|
||||
needsSetup: env.AUTH_ENABLED && !hasUsers,
|
||||
needsSetup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +84,84 @@ export interface RequestUser {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
function isMutationMethod(method: string): boolean {
|
||||
return !READ_ONLY_METHODS.has(method.toUpperCase());
|
||||
}
|
||||
|
||||
function getApiKeyPepper(): string {
|
||||
return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper";
|
||||
}
|
||||
|
||||
export function hashApiKeyToken(token: string): string {
|
||||
return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex");
|
||||
}
|
||||
|
||||
function getBearerToken(request: FastifyRequest): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) return null;
|
||||
|
||||
const [scheme, value] = authHeader.split(" ");
|
||||
if (!scheme || !value) return null;
|
||||
if (scheme.toLowerCase() !== "bearer") return null;
|
||||
|
||||
const token = value.trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
const bearerToken = getBearerToken(request);
|
||||
if (!bearerToken) return false;
|
||||
|
||||
if (!bearerToken.startsWith("ma_")) {
|
||||
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
|
||||
throw new Error("INVALID_API_KEY");
|
||||
}
|
||||
|
||||
const keyHash = hashApiKeyToken(bearerToken);
|
||||
const [keyRow] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||
|
||||
if (!keyRow) {
|
||||
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
|
||||
throw new Error("INVALID_API_KEY");
|
||||
}
|
||||
|
||||
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
|
||||
reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" });
|
||||
throw new Error("API_KEY_EXPIRED");
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||
if (!user || !user.isActive) {
|
||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
const scope = keyRow.scope === "read" ? "read" : "write";
|
||||
if (scope === "read" && isMutationMethod(request.method)) {
|
||||
reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||
throw new Error("API_KEY_SCOPE_FORBIDDEN");
|
||||
}
|
||||
|
||||
request.user = { id: user.id, username: user.username };
|
||||
request.authContext = {
|
||||
method: "api_key",
|
||||
scope,
|
||||
apiKeyId: keyRow.id,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Middleware Functions
|
||||
// =============================================================================
|
||||
@@ -91,6 +174,37 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
||||
return;
|
||||
}
|
||||
|
||||
const bearerToken = getBearerToken(request);
|
||||
if (bearerToken?.startsWith("ma_")) {
|
||||
const keyHash = hashApiKeyToken(bearerToken);
|
||||
const [keyRow] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
|
||||
if (!keyRow) {
|
||||
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) {
|
||||
request.user = { id: userByKey.id, username: userByKey.username };
|
||||
request.authContext = {
|
||||
method: "api_key",
|
||||
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;
|
||||
}
|
||||
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
return;
|
||||
@@ -104,9 +218,15 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
request.authContext = {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +238,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryApiKeyAuth(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
|
||||
@@ -142,11 +266,20 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
request.authContext = {
|
||||
method: "session",
|
||||
scope: "write",
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Re-throw our own errors
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
|
||||
(err.message === "AUTH_REQUIRED" ||
|
||||
err.message === "USER_NOT_FOUND" ||
|
||||
err.message === "ACCOUNT_DISABLED" ||
|
||||
err.message === "INVALID_API_KEY" ||
|
||||
err.message === "API_KEY_EXPIRED" ||
|
||||
err.message === "API_KEY_SCOPE_FORBIDDEN")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
+48
-11
@@ -11,9 +11,13 @@ const EnvSchema = z.object({
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("3000"),
|
||||
.default(3000),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
OPENAPI_DOCS_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.optional(),
|
||||
|
||||
// ==========================================================================
|
||||
// Auth Configuration
|
||||
@@ -22,13 +26,17 @@ const EnvSchema = z.object({
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default(false),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
// Disable local auth when using SSO only
|
||||
.default(false),
|
||||
// Disable username/password form login (useful for OIDC-only setups)
|
||||
FORM_LOGIN_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
@@ -39,11 +47,11 @@ const EnvSchema = z.object({
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
.default(15),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
.default(7),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
@@ -51,7 +59,7 @@ const EnvSchema = z.object({
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default(false),
|
||||
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(),
|
||||
@@ -60,15 +68,18 @@ const EnvSchema = z.object({
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
.default(true),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
type ParsedEnv = z.infer<typeof EnvSchema>;
|
||||
export type Env = ParsedEnv & {
|
||||
OPENAPI_DOCS_ENABLED: boolean;
|
||||
};
|
||||
|
||||
// Parse and validate
|
||||
let parsed: z.infer<typeof EnvSchema>;
|
||||
let parsed: ParsedEnv;
|
||||
try {
|
||||
parsed = EnvSchema.parse(process.env);
|
||||
} catch (err) {
|
||||
@@ -128,4 +139,30 @@ if (parsed.OIDC_ENABLED) {
|
||||
}
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
// Validate that at least one login method is available when auth is enabled
|
||||
if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("AUTHENTICATION CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error("AUTH_ENABLED=true but no login method is available.");
|
||||
console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in.");
|
||||
console.error("");
|
||||
console.error("To fix this, either:");
|
||||
console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login");
|
||||
console.error(" 2. Set OIDC_ENABLED=true to allow SSO login");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Warn about ineffective registration when form login is disabled
|
||||
if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
|
||||
console.warn(
|
||||
"[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)"
|
||||
);
|
||||
}
|
||||
|
||||
export const env: Env = {
|
||||
...parsed,
|
||||
// Docs UI/spec are enabled in non-production by default.
|
||||
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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",
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { apiKeys } from "../db/schema.js";
|
||||
import { hashApiKeyToken, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
const createApiKeySchema = z.object({
|
||||
name: z.string().trim().min(3).max(100),
|
||||
scope: z.enum(["read", "write"]).default("write"),
|
||||
expiresInDays: z.number().int().min(1).max(3650).optional(),
|
||||
});
|
||||
|
||||
const idParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
const genericErrorSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
code: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
const apiKeyMetadataSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
name: { type: "string" },
|
||||
tokenPrefix: { type: "string" },
|
||||
scope: { type: "string", enum: ["read", "write"] },
|
||||
isActive: { type: "boolean" },
|
||||
lastUsedAt: { type: ["string", "null"], format: "date-time" },
|
||||
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeDateTime(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function serializeApiKeyMetadata<
|
||||
T extends {
|
||||
id: number;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
scope: string;
|
||||
isActive: boolean;
|
||||
lastUsedAt: unknown;
|
||||
expiresAt: unknown;
|
||||
createdAt: unknown;
|
||||
updatedAt: unknown;
|
||||
},
|
||||
>(key: T) {
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
tokenPrefix: key.tokenPrefix,
|
||||
scope: key.scope,
|
||||
isActive: key.isActive,
|
||||
lastUsedAt: normalizeDateTime(key.lastUsedAt),
|
||||
expiresAt: normalizeDateTime(key.expiresAt),
|
||||
createdAt: normalizeDateTime(key.createdAt),
|
||||
updatedAt: normalizeDateTime(key.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiKeyRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
app.get(
|
||||
"/auth/api-keys",
|
||||
{
|
||||
schema: {
|
||||
tags: ["api-keys"],
|
||||
summary: "List API keys for the current user",
|
||||
description: "Returns API key metadata. Raw API key tokens are never returned.",
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
keys: {
|
||||
type: "array",
|
||||
items: apiKeyMetadataSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
400: genericErrorSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
}
|
||||
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
scope: apiKeys.scope,
|
||||
isActive: apiKeys.isActive,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
createdAt: apiKeys.createdAt,
|
||||
updatedAt: apiKeys.updatedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, authUser.id))
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return { keys: keys.map(serializeApiKeyMetadata) };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof createApiKeySchema> }>(
|
||||
"/auth/api-keys",
|
||||
{
|
||||
schema: {
|
||||
tags: ["api-keys"],
|
||||
summary: "Create and rotate API key",
|
||||
description:
|
||||
"Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.",
|
||||
security: protectedEndpointSecurity,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string", minLength: 3, maxLength: 100 },
|
||||
scope: { type: "string", enum: ["read", "write"], default: "write" },
|
||||
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
|
||||
},
|
||||
example: {
|
||||
name: "Home Assistant integration",
|
||||
scope: "write",
|
||||
expiresInDays: 365,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: apiKeyMetadataSchema,
|
||||
token: { type: "string" },
|
||||
note: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, { type: "object" }] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||
}
|
||||
|
||||
const parsed = createApiKeySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send(parsed.error.format());
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
}
|
||||
|
||||
const { name, scope, expiresInDays } = parsed.data;
|
||||
const rawToken = `ma_${randomBytes(32).toString("hex")}`;
|
||||
const tokenPrefix = `${rawToken.slice(0, 12)}...`;
|
||||
const keyHash = hashApiKeyToken(rawToken);
|
||||
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
|
||||
|
||||
// Keep a single active key per user: creating a new key invalidates old ones.
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true)));
|
||||
|
||||
const [created] = await db
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
userId: authUser.id,
|
||||
name,
|
||||
keyHash,
|
||||
tokenPrefix,
|
||||
scope,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
scope: apiKeys.scope,
|
||||
isActive: apiKeys.isActive,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
createdAt: apiKeys.createdAt,
|
||||
updatedAt: apiKeys.updatedAt,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
key: serializeApiKeyMetadata(created),
|
||||
token: rawToken,
|
||||
note: "Store this token now. It cannot be retrieved again.",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/auth/api-keys/:id",
|
||||
{
|
||||
schema: {
|
||||
tags: ["api-keys"],
|
||||
summary: "Deactivate API key",
|
||||
description: "Deactivates one API key belonging to the current user.",
|
||||
security: protectedEndpointSecurity,
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string", pattern: "^\\d+$" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: { type: "null" },
|
||||
400: { anyOf: [genericErrorSchema, { type: "object" }] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
|
||||
}
|
||||
|
||||
const parsedParams = idParamSchema.safeParse(request.params);
|
||||
if (!parsedParams.success) {
|
||||
return reply.status(400).send(parsedParams.error.format());
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
}
|
||||
|
||||
const keyId = Number(parsedParams.data.id);
|
||||
const [existing] = await db
|
||||
.select({ id: apiKeys.id, userId: apiKeys.userId })
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
+313
-71
@@ -1,13 +1,20 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import argon2 from "argon2";
|
||||
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";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
|
||||
// =============================================================================
|
||||
// Argon2id Configuration - State of the Art Password Hashing
|
||||
@@ -78,10 +85,44 @@ const updateProfileSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
|
||||
const authErrorSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
code: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeDateTime(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Routes
|
||||
// =============================================================================
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
@@ -90,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// GET /auth/state - Public auth state (needed before login)
|
||||
// Exempt from rate limit - lightweight state check called frequently
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
|
||||
return getAuthState();
|
||||
});
|
||||
app.get(
|
||||
"/auth/state",
|
||||
{
|
||||
config: { rateLimit: false },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Get authentication state",
|
||||
description: "Returns auth and login mode state before user login.",
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
authEnabled: { type: "boolean" },
|
||||
registrationEnabled: { type: "boolean" },
|
||||
formLoginEnabled: { type: "boolean" },
|
||||
oidcEnabled: { type: "boolean" },
|
||||
hasUsers: { type: "boolean" },
|
||||
oidcProviderName: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return getAuthState();
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/register - User registration
|
||||
@@ -101,6 +166,40 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
"/auth/register",
|
||||
{
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Register local user",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["username", "password"],
|
||||
properties: {
|
||||
username: { type: "string", minLength: 3, maxLength: 50 },
|
||||
password: { type: "string", minLength: 8, maxLength: 128 },
|
||||
},
|
||||
example: {
|
||||
username: "daniel",
|
||||
password: "correct-horse-battery-staple",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
username: { type: "string" },
|
||||
},
|
||||
},
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: authErrorSchema,
|
||||
409: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
// Check auth state
|
||||
@@ -114,15 +213,15 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
||||
}
|
||||
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
if (!state.formLoginEnabled) {
|
||||
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||
}
|
||||
|
||||
// Validate input
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -148,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
app.log.info(`User registered: ${username}`);
|
||||
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
|
||||
|
||||
return reply.status(201).send({
|
||||
ok: true,
|
||||
@@ -168,6 +267,42 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
"/auth/login",
|
||||
{
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Login with username and password",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["username", "password"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string" },
|
||||
rememberMe: { type: "boolean" },
|
||||
},
|
||||
example: {
|
||||
username: "daniel",
|
||||
password: "correct-horse-battery-staple",
|
||||
rememberMe: true,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
username: { type: "string" },
|
||||
avatarUrl: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: authErrorSchema,
|
||||
401: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const state = await getAuthState();
|
||||
@@ -176,8 +311,8 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
if (!state.formLoginEnabled) {
|
||||
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(request.body);
|
||||
@@ -222,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` }
|
||||
);
|
||||
@@ -236,12 +371,12 @@ 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 }
|
||||
);
|
||||
|
||||
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
|
||||
app.log.info(`[Auth] Login succeeded: username=${user.username}, userId=${user.id}, rememberMe=${rememberMe}`);
|
||||
|
||||
// Cookie options: with maxAge for "remember me", without for session cookie
|
||||
const accessCookieOptions = rememberMe
|
||||
@@ -272,6 +407,15 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
"/auth/refresh",
|
||||
{
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Refresh access token",
|
||||
description: "Requires refresh token cookie context.",
|
||||
response: {
|
||||
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
401: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
@@ -281,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,
|
||||
});
|
||||
|
||||
@@ -314,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 }
|
||||
);
|
||||
@@ -341,13 +485,22 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
"/auth/logout",
|
||||
{
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Logout and clear auth cookies",
|
||||
response: {
|
||||
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
|
||||
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));
|
||||
@@ -366,26 +519,56 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/me - Get current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
app.get(
|
||||
"/auth/me",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Get current user profile",
|
||||
security: authEndpointSecurity,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
username: { type: "string" },
|
||||
avatarUrl: { type: ["string", "null"] },
|
||||
authProvider: { type: "string" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
lastLoginAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
401: authErrorSchema,
|
||||
404: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
});
|
||||
const createdAt =
|
||||
normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString();
|
||||
const lastLoginAt = normalizeDateTime(user.lastLoginAt);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider ?? "local",
|
||||
createdAt,
|
||||
lastLoginAt,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /auth/me - Update current user profile
|
||||
@@ -395,6 +578,34 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Update current user profile",
|
||||
security: authEndpointSecurity,
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
currentPassword: { type: "string" },
|
||||
newPassword: { type: "string", minLength: 8, maxLength: 128 },
|
||||
},
|
||||
example: {
|
||||
currentPassword: "current-password",
|
||||
newPassword: "new-strong-password",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: authErrorSchema,
|
||||
401: authErrorSchema,
|
||||
404: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -405,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",
|
||||
});
|
||||
}
|
||||
@@ -453,6 +664,24 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Upload user avatar",
|
||||
description: "Uploads and optimizes a profile image using multipart/form-data.",
|
||||
security: authEndpointSecurity,
|
||||
consumes: ["multipart/form-data"],
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
avatarUrl: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: authErrorSchema,
|
||||
401: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -462,36 +691,35 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
let uploadBuffer: Buffer;
|
||||
try {
|
||||
uploadBuffer = await streamToBuffer(data.file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Save file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const imagesDir = path.join(getDataDir(), "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Update user
|
||||
@@ -509,6 +737,16 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Delete user avatar",
|
||||
security: authEndpointSecurity,
|
||||
response: {
|
||||
200: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
401: authErrorSchema,
|
||||
404: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -522,13 +760,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
@@ -545,6 +777,22 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
schema: {
|
||||
tags: ["auth"],
|
||||
summary: "Delete current user account",
|
||||
description: "Deletes the current account and related data (cascade delete).",
|
||||
security: authEndpointSecurity,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
401: authErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
@@ -555,19 +803,13 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// Delete avatar file if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Delete user - cascade delete handles all related data
|
||||
await db.delete(users).where(eq(users.id, authUser.id));
|
||||
|
||||
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
|
||||
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
|
||||
|
||||
// Clear auth cookies
|
||||
return reply
|
||||
|
||||
+505
-85
@@ -2,10 +2,23 @@ import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
tokenParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import {
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseTakenByJson,
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
@@ -22,6 +35,41 @@ const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
const doseReadResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
doses: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doseId: { type: "string" },
|
||||
takenAt: { type: "number" },
|
||||
markedBy: { type: ["string", "null"] },
|
||||
takenSource: { type: "string" },
|
||||
dismissed: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function 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> {
|
||||
@@ -38,43 +86,231 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) return null;
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
async function getActiveShareToken(token: string): Promise<{
|
||||
share: typeof shareTokens.$inferSelect | null;
|
||||
reason: "not_found" | "expired" | "ok";
|
||||
}> {
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) return { share: null, reason: "not_found" };
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
return { share: null, reason: "expired" };
|
||||
}
|
||||
|
||||
return { share, reason: "ok" };
|
||||
}
|
||||
|
||||
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const medTakenBy = parseTakenByJson(medication.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
if (!intake) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
|
||||
if (expectedPersons.length === 0) {
|
||||
return parsedDose.personSuffix === null;
|
||||
}
|
||||
|
||||
if (!parsedDose.personSuffix) {
|
||||
return intake.takenBy === null;
|
||||
}
|
||||
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
}): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(options.doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
|
||||
const scheduledOccurrenceMs = intake
|
||||
? (() => {
|
||||
const doseDate = new Date(parsedDose.timestampMs);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
return new Date(
|
||||
doseDate.getFullYear(),
|
||||
doseDate.getMonth(),
|
||||
doseDate.getDate(),
|
||||
intakeStart.getHours(),
|
||||
intakeStart.getMinutes(),
|
||||
intakeStart.getSeconds(),
|
||||
intakeStart.getMilliseconds()
|
||||
).getTime();
|
||||
})()
|
||||
: parsedDose.timestampMs;
|
||||
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
|
||||
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
|
||||
return (
|
||||
computeMedicationCurrentStock({
|
||||
medication,
|
||||
doses,
|
||||
stockCalculationMode: options.stockCalculationMode,
|
||||
nowMs: stockBeforeDoseMs,
|
||||
}) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
export async function doseRoutes(app: FastifyInstance) {
|
||||
applyOpenApiRouteStandards(app, {
|
||||
tag: "doses",
|
||||
protectedByDefault: false,
|
||||
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
||||
// Suppress request logs — polled every 5s by frontend
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
app.get(
|
||||
"/doses/taken",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
logLevel: "warn",
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: doseReadResponseSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/taken - PROTECTED: Mark a dose as taken
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doseId: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
409: genericErrorSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +326,16 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
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,
|
||||
@@ -107,7 +353,24 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/doses/taken/:doseId",
|
||||
{ preHandler: requireAuth },
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
@@ -136,14 +399,40 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doseIds: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
example: {
|
||||
doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"],
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
dismissedCount: { type: "integer" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
error: getValidationErrorMessage(parsed.error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,6 +462,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
@@ -186,78 +476,149 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
app.delete(
|
||||
"/doses/dismiss",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["doses"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
clearedCount: { type: "integer" },
|
||||
},
|
||||
},
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
|
||||
|
||||
for (const d of dismissed) {
|
||||
if (d.markedBy !== null || d.takenAt) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
|
||||
for (const d of dismissed) {
|
||||
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt);
|
||||
|
||||
if (d.markedBy !== null || hasRealTakenTimestamp) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
});
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||
// Suppress request logs — polled every 5s by SharedSchedule
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/share/:token/doses",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
response: {
|
||||
200: doseReadResponseSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
logLevel: "warn",
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 60,
|
||||
timeWindow: "1 minute",
|
||||
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||
"/share/:token/doses",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
doseId: { type: "string" },
|
||||
},
|
||||
example: {
|
||||
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
409: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -265,17 +626,40 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
request.log.debug(
|
||||
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record - marked by the takenBy person
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
const outOfStock = await isDoseOutOfStock({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
});
|
||||
if (outOfStock) {
|
||||
request.log.info(
|
||||
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
|
||||
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
|
||||
const parsedShareDose = parseDoseId(doseId);
|
||||
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
markedBy,
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -283,28 +667,64 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/doses/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
request.log.debug(
|
||||
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db
|
||||
.delete(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
request.log.info(
|
||||
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
+424
-262
@@ -5,19 +5,25 @@ 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";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.1";
|
||||
const EXPORT_VERSION = "1.5";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -27,6 +33,9 @@ const scheduleSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string(), // ISO datetime string
|
||||
scheduleMode: z.unknown().optional(),
|
||||
weekdays: z.unknown().optional(),
|
||||
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||
remind: z.boolean().optional().default(false),
|
||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||
});
|
||||
@@ -38,7 +47,9 @@ const inventorySchema = z.object({
|
||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
||||
packageType: z.enum(PACKAGE_TYPES).default("blister"),
|
||||
packageAmountValue: z.number().int().min(0).default(0),
|
||||
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||
});
|
||||
|
||||
const medicationExportSchema = z.object({
|
||||
@@ -46,11 +57,16 @@ const medicationExportSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
genericName: z.string().nullable().optional(),
|
||||
takenBy: z.array(z.string()).default([]),
|
||||
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
|
||||
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
|
||||
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
||||
inventory: inventorySchema,
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
medicationEndDate: z.string().nullable().optional(),
|
||||
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
@@ -80,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
|
||||
});
|
||||
@@ -92,36 +109,44 @@ 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),
|
||||
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 exportSettingsSchema = settingsSchemaBase.optional();
|
||||
|
||||
const importSettingsSchema = settingsSchemaBase
|
||||
.extend({
|
||||
// Accept the removed field from legacy exports so old backups still import,
|
||||
// but do not map it back into current runtime settings.
|
||||
shareStockStatus: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -132,10 +157,73 @@ 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([]),
|
||||
});
|
||||
|
||||
const exportQuerystringSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeSensitive: { type: "string", enum: ["true", "false"] },
|
||||
includeImages: { type: "string", enum: ["true", "false"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const exportResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
version: { type: "string" },
|
||||
exportedAt: { type: "string", format: "date-time" },
|
||||
includeSensitiveData: { type: "boolean" },
|
||||
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
settings: { type: "object", additionalProperties: true },
|
||||
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const importBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["version", "exportedAt"],
|
||||
properties: {
|
||||
version: { type: "string" },
|
||||
exportedAt: { type: "string", format: "date-time" },
|
||||
includeSensitiveData: { type: "boolean" },
|
||||
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
settings: { type: "object", additionalProperties: true },
|
||||
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
example: {
|
||||
version: "1.8.0",
|
||||
exportedAt: "2026-03-11T10:15:00.000Z",
|
||||
includeSensitiveData: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Ibuprofen 400",
|
||||
packageType: "box",
|
||||
packCount: 1,
|
||||
looseTablets: 8,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 8,
|
||||
start: "2026-03-11T08:00:00.000Z",
|
||||
takenBy: "Daniel",
|
||||
remind: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
||||
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
@@ -155,9 +243,16 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
||||
}
|
||||
|
||||
// Parse intakes from DB format to export format (with per-intake takenBy)
|
||||
function parseIntakesForExport(
|
||||
row: typeof medications.$inferSelect
|
||||
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
|
||||
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode: "interval" | "weekdays";
|
||||
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||
remind: boolean;
|
||||
takenBy: string | null;
|
||||
}> {
|
||||
// Use the new parseIntakesJson which falls back to legacy format
|
||||
const intakes = parseIntakesJson(
|
||||
row.intakesJson,
|
||||
@@ -169,6 +264,9 @@ function parseIntakesForExport(
|
||||
usage: intake.usage,
|
||||
every: intake.every,
|
||||
start: intake.start,
|
||||
scheduleMode: intake.scheduleMode ?? "interval",
|
||||
weekdays: intake.weekdays ?? [],
|
||||
intakeUnit: intake.intakeUnit ?? null,
|
||||
remind: intake.intakeRemindersEnabled,
|
||||
takenBy: intake.takenBy, // Per-intake takenBy
|
||||
}));
|
||||
@@ -257,236 +355,265 @@ function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: nu
|
||||
export async function exportRoutes(app: FastifyInstance) {
|
||||
// All export routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "export", protectedByDefault: true });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /export - Export all user data
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const includeSensitive = request.query.includeSensitive === "true";
|
||||
const includeImages = request.query.includeImages !== "false"; // Default to true
|
||||
|
||||
// 1. Load all medications
|
||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
|
||||
// Build medication ID to export ID mapping
|
||||
const medIdToExportId = new Map<number, string>();
|
||||
const exportMedications = meds.map((med, index) => {
|
||||
const exportId = `med-${index + 1}`;
|
||||
medIdToExportId.set(med.id, exportId);
|
||||
|
||||
// Safely convert lastStockCorrectionAt to ISO string
|
||||
let lastStockCorrectionAtIso: string | null = null;
|
||||
if (med.lastStockCorrectionAt) {
|
||||
try {
|
||||
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
|
||||
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
|
||||
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
|
||||
const d = new Date(med.lastStockCorrectionAt);
|
||||
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
}
|
||||
} catch {
|
||||
lastStockCorrectionAtIso = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
_exportId: exportId,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
takenBy: parseTakenByJson(med.takenByJson),
|
||||
inventory: {
|
||||
packCount: med.packCount ?? 1,
|
||||
blistersPerPack: med.blistersPerPack ?? 1,
|
||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||
totalPills: med.totalPills ?? null,
|
||||
looseTablets: med.looseTablets ?? 0,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
packageType: med.packageType ?? "blister",
|
||||
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
|
||||
"/export",
|
||||
{
|
||||
schema: {
|
||||
querystring: exportQuerystringSchema,
|
||||
response: {
|
||||
200: exportResponseSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||
dismissedUntil: med.dismissedUntil ?? null,
|
||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const includeSensitive = request.query.includeSensitive === "true";
|
||||
const includeImages = request.query.includeImages !== "false"; // Default to true
|
||||
|
||||
// 2. Load all dose tracking entries
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
// 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]));
|
||||
|
||||
const exportDoseHistory = doses
|
||||
.map((dose) => {
|
||||
const parsed = parseDoseId(dose.doseId);
|
||||
if (!parsed) return null;
|
||||
// Build medication ID to export ID mapping
|
||||
const medIdToExportId = new Map<number, string>();
|
||||
const exportMedications = meds.map((med, index) => {
|
||||
const exportId = `med-${index + 1}`;
|
||||
medIdToExportId.set(med.id, exportId);
|
||||
|
||||
const exportId = medIdToExportId.get(parsed.medicationId);
|
||||
if (!exportId) return null; // Orphaned dose, skip
|
||||
// Safely convert lastStockCorrectionAt to ISO string
|
||||
let lastStockCorrectionAtIso: string | null = null;
|
||||
if (med.lastStockCorrectionAt) {
|
||||
try {
|
||||
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
|
||||
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
|
||||
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
|
||||
const d = new Date(med.lastStockCorrectionAt);
|
||||
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
}
|
||||
} catch {
|
||||
lastStockCorrectionAtIso = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Safely convert takenAt to ISO string
|
||||
let takenAtIso: string;
|
||||
try {
|
||||
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
|
||||
takenAtIso = dose.takenAt.toISOString();
|
||||
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
||||
const d = new Date(dose.takenAt);
|
||||
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
return {
|
||||
_exportId: exportId,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
takenBy: parseTakenByJson(med.takenByJson),
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm ?? null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
inventory: {
|
||||
packCount: med.packCount ?? 1,
|
||||
blistersPerPack: med.blistersPerPack ?? 1,
|
||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||
totalPills: med.totalPills ?? null,
|
||||
looseTablets: med.looseTablets ?? 0,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
packageType: normalizePackageType(med.packageType),
|
||||
packageAmountValue: med.packageAmountValue ?? 0,
|
||||
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||
},
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||
dismissedUntil: med.dismissedUntil ?? null,
|
||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Load all dose tracking entries
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
|
||||
const exportDoseHistory = doses
|
||||
.map((dose) => {
|
||||
const parsed = parseDoseId(dose.doseId);
|
||||
if (!parsed) return null;
|
||||
|
||||
const exportId = medIdToExportId.get(parsed.medicationId);
|
||||
if (!exportId) return null; // Orphaned dose, skip
|
||||
|
||||
// Safely convert takenAt to ISO string
|
||||
let takenAtIso: string;
|
||||
try {
|
||||
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
|
||||
takenAtIso = dose.takenAt.toISOString();
|
||||
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
||||
const d = new Date(dose.takenAt);
|
||||
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
takenAtIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
takenAtIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
takenAtIso = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Safely convert scheduled time
|
||||
let scheduledTimeIso: string;
|
||||
try {
|
||||
const d = new Date(parsed.timestampMs);
|
||||
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} catch {
|
||||
scheduledTimeIso = new Date().toISOString();
|
||||
// Safely convert scheduled time
|
||||
let scheduledTimeIso: string;
|
||||
try {
|
||||
const d = new Date(parsed.timestampMs);
|
||||
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} catch {
|
||||
scheduledTimeIso = new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
scheduleIndex: parsed.blisterIndex,
|
||||
scheduledTime: scheduledTimeIso,
|
||||
takenAt: takenAtIso,
|
||||
markedBy: dose.markedBy,
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
};
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
|
||||
// 3. Load user settings
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const exportSettings = settings
|
||||
? {
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
// Only include sensitive data if requested
|
||||
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
||||
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
expiryWarningDays: settings.expiryWarningDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 4. Load share links
|
||||
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
|
||||
const exportShareLinks = shares.map((share) => {
|
||||
// Safely convert expiresAt to ISO string
|
||||
let expiresAtIso: string | null = null;
|
||||
if (share.expiresAt) {
|
||||
try {
|
||||
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
|
||||
expiresAtIso = share.expiresAt.toISOString();
|
||||
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
||||
const d = new Date(share.expiresAt);
|
||||
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
}
|
||||
} catch {
|
||||
expiresAtIso = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
scheduleIndex: parsed.blisterIndex,
|
||||
scheduledTime: scheduledTimeIso,
|
||||
takenAt: takenAtIso,
|
||||
markedBy: dose.markedBy,
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
expiresAt: expiresAtIso,
|
||||
regenerateToken: true, // Always regenerate tokens on import for security
|
||||
};
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
});
|
||||
|
||||
// 3. Load user settings
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
// 5. Load refill history
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
|
||||
const exportSettings = settings
|
||||
? {
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
// Only include sensitive data if requested
|
||||
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
||||
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
expiryWarningDays: settings.expiryWarningDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareStockStatus: settings.shareStockStatus,
|
||||
}
|
||||
: undefined;
|
||||
const exportRefillHistory = refills
|
||||
.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 === "tube" || packageType === "liquid_container"
|
||||
? (refill.loosePillsAdded ?? 0)
|
||||
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
|
||||
|
||||
// 4. Load share links
|
||||
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
|
||||
const exportShareLinks = shares.map((share) => {
|
||||
// Safely convert expiresAt to ISO string
|
||||
let expiresAtIso: string | null = null;
|
||||
if (share.expiresAt) {
|
||||
try {
|
||||
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
|
||||
expiresAtIso = share.expiresAt.toISOString();
|
||||
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
||||
const d = new Date(share.expiresAt);
|
||||
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
}
|
||||
} catch {
|
||||
expiresAtIso = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
expiresAt: expiresAtIso,
|
||||
regenerateToken: true, // Always regenerate tokens on import for security
|
||||
};
|
||||
});
|
||||
|
||||
// 5. Load refill history
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
|
||||
const exportRefillHistory = refills
|
||||
.map((refill) => {
|
||||
const exportId = medIdToExportId.get(refill.medicationId);
|
||||
if (!exportId) return null; // Orphaned refill, skip
|
||||
|
||||
// Safely convert refillDate to ISO string
|
||||
let refillDateIso: string;
|
||||
try {
|
||||
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||
refillDateIso = refill.refillDate.toISOString();
|
||||
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||
const d = new Date(refill.refillDate);
|
||||
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
// Safely convert refillDate to ISO string
|
||||
let refillDateIso: string;
|
||||
try {
|
||||
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||
refillDateIso = refill.refillDate.toISOString();
|
||||
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||
const d = new Date(refill.refillDate);
|
||||
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: refillDateIso,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
quantityAdded,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: refillDateIso,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
// Build export object
|
||||
const exportData = {
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications: exportMedications,
|
||||
doseHistory: exportDoseHistory,
|
||||
refillHistory: exportRefillHistory,
|
||||
settings: exportSettings,
|
||||
shareLinks: exportShareLinks,
|
||||
};
|
||||
// Build export object
|
||||
const exportData = {
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications: exportMedications,
|
||||
doseHistory: exportDoseHistory,
|
||||
refillHistory: exportRefillHistory,
|
||||
settings: exportSettings,
|
||||
shareLinks: exportShareLinks,
|
||||
};
|
||||
|
||||
// Set download headers
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||
reply.header("Content-Type", "application/json");
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
// Set download headers
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||
reply.header("Content-Type", "application/json");
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
|
||||
return exportData;
|
||||
});
|
||||
return exportData;
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /import - Import user data (replaces all existing data!)
|
||||
@@ -499,6 +626,29 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
rawBody: true,
|
||||
},
|
||||
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
||||
schema: {
|
||||
body: importBodyOpenApiSchema,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
imported: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medications: { type: "integer" },
|
||||
doseHistory: { type: "integer" },
|
||||
refillHistory: { type: "integer" },
|
||||
settings: { type: "integer" },
|
||||
shareLinks: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
@@ -543,25 +693,28 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
// Convert schedules to both legacy and new formats
|
||||
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
||||
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
|
||||
// Build intakesJson array (new unified format with per-intake takenBy)
|
||||
const intakesJson = JSON.stringify(
|
||||
med.schedules.map((s) => ({
|
||||
usage: s.usage,
|
||||
every: s.every,
|
||||
start: s.start,
|
||||
takenBy: s.takenBy || null,
|
||||
intakeRemindersEnabled: s.remind ?? false,
|
||||
}))
|
||||
);
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(medications)
|
||||
@@ -570,7 +723,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
packageType: med.inventory.packageType ?? "blister",
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm || null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: normalizePackageType(med.inventory.packageType),
|
||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
@@ -581,6 +739,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -634,6 +794,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,
|
||||
@@ -658,7 +820,7 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -685,7 +847,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),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
|
||||
|
||||
// Read version from package.json at startup
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -10,11 +11,31 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const backendVersion = packageJson.version || "unknown";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
// Exempt from rate limit - lightweight health check
|
||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||
status: "ok",
|
||||
version: backendVersion,
|
||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||
}));
|
||||
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
|
||||
|
||||
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
|
||||
app.get(
|
||||
"/health",
|
||||
{
|
||||
config: { rateLimit: false },
|
||||
logLevel: "warn",
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ok"] },
|
||||
version: { type: "string" },
|
||||
smtpConfigured: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => ({
|
||||
status: "ok",
|
||||
version: backendVersion,
|
||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import {
|
||||
enrichMedicationSelection,
|
||||
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
type MedicationEnrichmentEnrichRequest,
|
||||
MedicationEnrichmentServiceError,
|
||||
searchMedicationEnrichment,
|
||||
} from "../services/medication-enrichment/index.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1).max(120),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
|
||||
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
|
||||
});
|
||||
|
||||
const enrichBodySchema = z.object({
|
||||
query: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(140),
|
||||
genericName: z.string().trim().max(140).nullable().optional(),
|
||||
code: z.string().trim().min(1).max(160).nullable().optional(),
|
||||
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
|
||||
});
|
||||
|
||||
const searchQueryOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["q"],
|
||||
properties: {
|
||||
q: { type: "string", minLength: 1, maxLength: 120 },
|
||||
limit: {
|
||||
anyOf: [
|
||||
{ type: "string", pattern: "^[0-9]+$" },
|
||||
{
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["query", "name"],
|
||||
properties: {
|
||||
query: { type: "string", minLength: 1, maxLength: 120 },
|
||||
name: { type: "string", minLength: 1, maxLength: 140 },
|
||||
genericName: { type: "string", nullable: true, maxLength: 140 },
|
||||
code: { type: "string", nullable: true, maxLength: 160 },
|
||||
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const strengthOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
pillWeightMg: { type: "number", nullable: true },
|
||||
doseUnit: {
|
||||
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const packageOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
description: { type: "string" },
|
||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
|
||||
packCount: { type: "integer", minimum: 1 },
|
||||
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||
totalPills: { type: "integer", minimum: 0, nullable: true },
|
||||
looseTablets: { type: "integer", minimum: 0, nullable: true },
|
||||
packageAmountValue: { type: "integer", minimum: 1, nullable: true },
|
||||
packageAmountUnit: {
|
||||
anyOf: [{ type: "string", enum: ["ml", "g"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const searchResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
normalizedQuery: { type: "string" },
|
||||
hasMore: { type: "boolean" },
|
||||
results: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
authorisationHolder: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
matchType: { type: "string", enum: ["brand", "ingredient"] },
|
||||
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||
authorisationDate: { type: "string", nullable: true },
|
||||
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||
packageOptions: { type: "array", items: packageOptionSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
selection: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
indication: { type: "string", nullable: true },
|
||||
atcCode: { type: "string", nullable: true },
|
||||
source: {
|
||||
type: "string",
|
||||
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
medicationForm: {
|
||||
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||
},
|
||||
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||
packageOptions: { type: "array", items: packageOptionSchema },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
type: "object",
|
||||
properties: {
|
||||
rxNormMatched: { type: "boolean" },
|
||||
openFdaMatched: { type: "boolean" },
|
||||
partial: { type: "boolean" },
|
||||
note: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function sendServiceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof MedicationEnrichmentServiceError) {
|
||||
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
|
||||
}
|
||||
|
||||
return reply.status(503).send({
|
||||
error: "Medication enrichment request failed.",
|
||||
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
|
||||
|
||||
app.get(
|
||||
"/medication-enrichment/search",
|
||||
{
|
||||
schema: {
|
||||
querystring: searchQueryOpenApiSchema,
|
||||
response: {
|
||||
200: searchResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = searchQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Search request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
|
||||
"/medication-enrichment/enrich",
|
||||
{
|
||||
schema: {
|
||||
body: enrichBodyOpenApiSchema,
|
||||
response: {
|
||||
200: enrichResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = enrichBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await enrichMedicationSelection(parsed.data, request.log);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Enrich request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
+1431
-817
File diff suppressed because it is too large
Load Diff
+84
-51
@@ -5,6 +5,7 @@ import * as client from "openid-client";
|
||||
import { db } from "../db/client.js";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||
|
||||
// =============================================================================
|
||||
// OIDC Configuration Cache
|
||||
@@ -49,12 +50,14 @@ function getFrontendUrl(): string {
|
||||
// OIDC Routes
|
||||
// =============================================================================
|
||||
export async function oidcRoutes(app: FastifyInstance) {
|
||||
applyOpenApiRouteStandards(app, { tag: "auth", protectedByDefault: false });
|
||||
|
||||
if (!env.OIDC_ENABLED) {
|
||||
// Register a disabled route that returns an error
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
app.get("/auth/oidc/callback", async (_request, reply) => {
|
||||
app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
return;
|
||||
@@ -63,64 +66,91 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/login - Initiates OIDC flow
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
app.get(
|
||||
"/auth/oidc/login",
|
||||
{
|
||||
schema: {
|
||||
response: {
|
||||
302: { type: "null", description: "Redirect to OIDC provider" },
|
||||
500: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
// Generate PKCE values
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
// Generate PKCE values
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
// Store PKCE verifier and state in signed cookies (short-lived)
|
||||
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600, // 10 minutes
|
||||
signed: true,
|
||||
});
|
||||
// Store PKCE verifier and state in signed cookies (short-lived)
|
||||
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600, // 10 minutes
|
||||
signed: true,
|
||||
});
|
||||
|
||||
reply.setCookie("oidc_state", state, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
signed: true,
|
||||
});
|
||||
reply.setCookie("oidc_state", state, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
signed: true,
|
||||
});
|
||||
|
||||
// Build authorization URL
|
||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
const scope = env.OIDC_SCOPES;
|
||||
// Build authorization URL
|
||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
const scope = env.OIDC_SCOPES;
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
||||
"/auth/oidc/callback",
|
||||
{
|
||||
schema: {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
state: { type: "string" },
|
||||
error: { type: "string" },
|
||||
error_description: { type: "string" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
302: { type: "null", description: "Redirect back to frontend" },
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { code, state, error, error_description } = request.query;
|
||||
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
@@ -131,14 +161,14 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
@@ -159,7 +189,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
request.log.error("[OIDC] Missing sub claim in token response");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
@@ -174,7 +204,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
||||
request.log.error(
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
@@ -205,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
// Set cookies (use app's centralized cookie options)
|
||||
request.log.debug(
|
||||
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
|
||||
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
|
||||
);
|
||||
setAuthCookies(app, reply, accessToken, refreshToken);
|
||||
|
||||
@@ -214,7 +247,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +288,7 @@ async function findOrCreateOIDCUser(
|
||||
|
||||
// Check if auto-create is enabled
|
||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
||||
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
||||
// No logger is available in this helper, route-level logs already capture callback failures.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -279,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(
|
||||
@@ -289,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` }
|
||||
);
|
||||
|
||||
+829
-526
File diff suppressed because it is too large
Load Diff
+286
-115
@@ -6,20 +6,95 @@ import { medications, refillHistory } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
idParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||
|
||||
const refillSchema = z
|
||||
.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 = {
|
||||
type: "object",
|
||||
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 quantity.",
|
||||
example: {
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 4,
|
||||
quantityAdded: 4,
|
||||
usePrescription: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const refillResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
refill: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "number" },
|
||||
totalPillsAdded: { type: "number" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
newStock: {
|
||||
type: "object",
|
||||
properties: {
|
||||
packCount: { type: "integer" },
|
||||
looseTablets: { type: "integer" },
|
||||
totalPills: { type: "number" },
|
||||
},
|
||||
},
|
||||
prescription: {
|
||||
type: "object",
|
||||
properties: {
|
||||
used: { type: "boolean" },
|
||||
remainingRefills: { type: "integer" },
|
||||
authorizedRefills: { type: "integer" },
|
||||
lowRefillThreshold: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const refillHistoryItemSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "number" },
|
||||
totalPillsAdded: { type: "number" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function refillRoutes(app: FastifyInstance) {
|
||||
// All refill routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true });
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -35,142 +110,238 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// POST /medications/:id/refill - Add stock to medication
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
||||
const parsed = refillSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/medications/:id/refill",
|
||||
{
|
||||
schema: {
|
||||
params: idParamsSchema,
|
||||
body: refillBodyOpenApiSchema,
|
||||
response: {
|
||||
200: refillResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
409: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const parsed = refillSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
||||
const effectiveLoosePillsAdded = loosePillsAdded;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isBottle = packageType === "bottle";
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||
}
|
||||
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
||||
const fallbackAmountPerPackage = Math.max(
|
||||
1,
|
||||
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
|
||||
);
|
||||
const amountPerPackage =
|
||||
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
|
||||
? configuredAmountPerPackage
|
||||
: fallbackAmountPerPackage;
|
||||
|
||||
if (usePrescription) {
|
||||
if (!(med.prescriptionEnabled ?? false)) {
|
||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||
const requestedPackAdds = Math.max(0, packsAdded);
|
||||
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
||||
|
||||
let effectivePacksAdded = requestedPackAdds;
|
||||
if (isBottle) {
|
||||
effectivePacksAdded = 0;
|
||||
} else if (isCountBasedAmountPackage) {
|
||||
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
|
||||
}
|
||||
if (remainingPrescriptionRefills <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
const effectiveLoosePillsAdded = isCountBasedAmountPackage
|
||||
? effectivePacksAdded * amountPerPackage
|
||||
: requestedAmountAdds;
|
||||
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" });
|
||||
}
|
||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
|
||||
if (usePrescription) {
|
||||
if (!(med.prescriptionEnabled ?? false)) {
|
||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||
}
|
||||
if (remainingPrescriptionRefills <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
}
|
||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + effectivePacksAdded;
|
||||
const newLooseTablets = med.looseTablets + 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;
|
||||
|
||||
let consumedRefills = 0;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
// 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;
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
.set({
|
||||
if (isBottle) {
|
||||
newLooseTablets = targetCurrentStock;
|
||||
newStockAdjustment = 0;
|
||||
} else if (isCountBasedAmountPackage) {
|
||||
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;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
|
||||
const updatePayload: {
|
||||
packCount: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment: number;
|
||||
totalPills?: number;
|
||||
packageAmountValue?: number;
|
||||
prescriptionRemainingRefills: number | null;
|
||||
lastStockCorrectionAt: Date;
|
||||
updatedAt: Date;
|
||||
} = {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
stockAdjustment: newStockAdjustment,
|
||||
prescriptionRemainingRefills: newRemainingRefills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
lastStockCorrectionAt: refillBaselineAt,
|
||||
updatedAt: refillBaselineAt,
|
||||
};
|
||||
|
||||
// Create refill history entry
|
||||
const [refill] = await db
|
||||
.insert(refillHistory)
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
usedPrescription: usePrescription,
|
||||
})
|
||||
.returning();
|
||||
if (isCountBasedAmountPackage) {
|
||||
updatePayload.totalPills = newTotalAmount;
|
||||
updatePayload.packageAmountValue = amountPerPackage;
|
||||
}
|
||||
|
||||
// Calculate pills added for response (packageType-aware)
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const totalPillsAdded = isBottle
|
||||
? effectiveLoosePillsAdded
|
||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||
const newTotalPills = isBottle
|
||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||
await db
|
||||
.update(medications)
|
||||
.set(updatePayload)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
newStock: {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: newTotalPills,
|
||||
},
|
||||
prescription: {
|
||||
used: usePrescription,
|
||||
remainingRefills: newRemainingRefills,
|
||||
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
enabled: med.prescriptionEnabled ?? false,
|
||||
},
|
||||
};
|
||||
});
|
||||
// Create refill history entry
|
||||
const [refill] = await db
|
||||
.insert(refillHistory)
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
usedPrescription: usePrescription,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
quantityAdded: totalPillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
newStock: {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: targetCurrentStock,
|
||||
},
|
||||
prescription: {
|
||||
used: usePrescription,
|
||||
remainingRefills: newRemainingRefills,
|
||||
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
enabled: med.prescriptionEnabled ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /medications/:id/refills - Get refill history for a medication
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/medications/:id/refills",
|
||||
{
|
||||
schema: {
|
||||
params: idParamsSchema,
|
||||
response: {
|
||||
200: { type: "array", items: refillHistoryItemSchema },
|
||||
400: genericErrorSchema,
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
// Get refill history, newest first
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(eq(refillHistory.medicationId, medId))
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
// Get refill history, newest first
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isBottle = packageType === "bottle";
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
return refills.map((r) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate,
|
||||
}));
|
||||
});
|
||||
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,
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+157
-72
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -6,13 +6,77 @@ import { doseTracking, medications, refillHistory } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
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 = {
|
||||
type: "object",
|
||||
required: ["medicationIds"],
|
||||
properties: {
|
||||
medicationIds: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dosesTaken: { type: "integer" },
|
||||
automaticDosesTaken: { type: "integer" },
|
||||
dosesSkipped: { type: "integer" },
|
||||
firstDoseAt: { type: "string" },
|
||||
lastDoseAt: { type: "string" },
|
||||
refills: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
|
||||
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
@@ -27,87 +91,108 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
||||
app.post("/medications/report-data", async (req, reply) => {
|
||||
const parsed = reportDataSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
app.post(
|
||||
"/medications/report-data",
|
||||
{
|
||||
schema: {
|
||||
body: reportDataBodyOpenApiSchema,
|
||||
response: {
|
||||
200: reportDataResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
403: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, reply) => {
|
||||
const parsed = reportDataSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const { medicationIds } = parsed.data;
|
||||
const userId = await getUserId(req, reply);
|
||||
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 userMedIds = new Set(userMeds.map((m) => m.id));
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
if (!userMedIds.has(id)) {
|
||||
return reply.status(403).send({ error: "Access denied to medication" });
|
||||
for (const id of medicationIds) {
|
||||
if (!userMedIds.has(id)) {
|
||||
return reply.status(403).send({ error: "Access denied to medication" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch dose tracking for all requested medications
|
||||
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||
const allDoses = await db
|
||||
.select({
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
dismissed: doseTracking.dismissed,
|
||||
takenSource: doseTracking.takenSource,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
// Fetch dose tracking for all requested medications
|
||||
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||
const allDoses = await db
|
||||
.select({
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
dismissed: doseTracking.dismissed,
|
||||
takenSource: doseTracking.takenSource,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
|
||||
// Group doses by medication ID
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||
for (const dose of allDoses) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
dismissed: dose.dismissed,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch refill history for requested medications
|
||||
const result: Record<
|
||||
number,
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
// Group doses by medication ID
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||
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,
|
||||
dismissed: dose.dismissed,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
});
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const medId of medicationIds) {
|
||||
const doses = dosesByMed.get(medId) ?? [];
|
||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||
// Fetch refill history for requested medications
|
||||
const result: Record<
|
||||
number,
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||
for (const medId of medicationIds) {
|
||||
const doses = dosesByMed.get(medId) ?? [];
|
||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||
|
||||
// Get refills for this medication
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
|
||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
automaticDosesTaken: automaticTakenDoses.length,
|
||||
dosesDismissed: dismissedDoses.length,
|
||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||
refills: refills.map((r) => ({
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
};
|
||||
// Get refills for this medication scoped to the authenticated user.
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
automaticDosesTaken: automaticTakenDoses.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,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
+599
-418
File diff suppressed because it is too large
Load Diff
+401
-143
@@ -1,12 +1,20 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { buildSharedMedicationOverview } from "../services/coverage.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
tokenParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
getAllTakenByForMedication,
|
||||
parseIntakesJson,
|
||||
@@ -14,9 +22,6 @@ import {
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Share token validity: 1 year in milliseconds
|
||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
@@ -25,6 +30,72 @@ const createShareSchema = z.object({
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
|
||||
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||
|
||||
const createShareBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||
},
|
||||
example: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 14,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareReadResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
sharedBy: { type: "string" },
|
||||
scheduleDays: { type: "integer" },
|
||||
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
shareMedicationOverview: { type: "boolean" },
|
||||
medicationOverview: {
|
||||
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
|
||||
},
|
||||
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
|
||||
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
||||
upcomingTodayOnly: { type: "boolean" },
|
||||
shareScheduleTodayOnly: { type: "boolean" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareExpiredResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
code: { type: "string" },
|
||||
ownerUsername: { type: "string" },
|
||||
takenBy: { type: "string" },
|
||||
expiredAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareOverviewExpiredResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
expiredAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareOverviewResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
sharedBy: { type: "string" },
|
||||
generatedAt: { type: "string", format: "date-time" },
|
||||
medications: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -45,133 +116,276 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
||||
// Share Routes
|
||||
// =============================================================================
|
||||
export async function shareRoutes(app: FastifyInstance) {
|
||||
applyOpenApiRouteStandards(app, {
|
||||
tag: "share",
|
||||
protectedByDefault: false,
|
||||
protectedPaths: [/^\/share$/, /^\/share\/people$/],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token - PUBLIC: Get shared schedule by token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/share/:token",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
response: {
|
||||
200: shareReadResponseSchema,
|
||||
404: genericErrorSchema,
|
||||
410: shareExpiredResponseSchema,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 60,
|
||||
timeWindow: "1 minute",
|
||||
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
// Get the username of the owner to show in the expired message
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
return reply.status(410).send({
|
||||
error: "Share link has expired",
|
||||
code: "EXPIRED",
|
||||
ownerUsername: owner?.username ?? "the owner",
|
||||
takenBy: share.takenBy,
|
||||
expiredAt: share.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get user settings for stock thresholds
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
|
||||
// Get the username of the owner who created this share link
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
return reply.status(410).send({
|
||||
error: "Share link has expired",
|
||||
code: "EXPIRED",
|
||||
ownerUsername: owner?.username ?? "the owner",
|
||||
takenBy: share.takenBy,
|
||||
expiredAt: share.expiresAt.toISOString(),
|
||||
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// Use SQLite JSON function to check if takenBy is in the array
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
|
||||
|
||||
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||
});
|
||||
}
|
||||
|
||||
// Get user settings for stock thresholds
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
// Parse blisters and build schedule data
|
||||
const medicationsWithBlisters = meds.map((med) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
// Get the username of the owner who created this share link
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
// Convert to legacy blisters format for backward compat
|
||||
const blisters = intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
}));
|
||||
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// Use SQLite JSON function to check if takenBy is in the array
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||
// Parse takenBy JSON array
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
|
||||
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||
});
|
||||
|
||||
// Parse blisters and build schedule data
|
||||
const medicationsWithBlisters = meds.map((med) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
// Convert to legacy blisters format for backward compat
|
||||
const blisters = intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
}));
|
||||
|
||||
// Parse takenBy JSON array
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
|
||||
const totalPills =
|
||||
(med.packageType ?? "blister") === "bottle"
|
||||
const totalPills = isAmountBasedPackageType(med.packageType)
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
imageUrl: med.imageUrl,
|
||||
totalPills,
|
||||
packageType: med.packageType ?? "blister",
|
||||
packCount: med.packCount,
|
||||
blistersPerPack: med.blistersPerPack,
|
||||
looseTablets: med.looseTablets,
|
||||
pillsPerBlister: med.pillsPerBlister,
|
||||
takenBy: takenByArray,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
blisters, // Legacy format for backward compat
|
||||
dismissedUntil: med.dismissedUntil,
|
||||
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
imageUrl: med.imageUrl,
|
||||
totalPills,
|
||||
packageType: normalizePackageType(med.packageType),
|
||||
packCount: med.packCount,
|
||||
blistersPerPack: med.blistersPerPack,
|
||||
looseTablets: med.looseTablets,
|
||||
pillsPerBlister: med.pillsPerBlister,
|
||||
takenBy: takenByArray,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
blisters, // Legacy format for backward compat
|
||||
dismissedUntil: med.dismissedUntil,
|
||||
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
medications: medicationsWithBlisters,
|
||||
stockThresholds: {
|
||||
lowStockDays: settings?.lowStockDays ?? 30,
|
||||
normalStockDays: settings?.normalStockDays ?? 60,
|
||||
highStockDays: settings?.highStockDays ?? 90,
|
||||
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
|
||||
expiryWarningDays: settings?.expiryWarningDays ?? 90,
|
||||
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
|
||||
const medicationOverview = shareMedicationOverview
|
||||
? buildSharedMedicationOverview({
|
||||
medications: meds,
|
||||
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
|
||||
thresholdDays: settings?.lowStockDays ?? 30,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
medications: medicationsWithBlisters,
|
||||
shareMedicationOverview,
|
||||
medicationOverview,
|
||||
stockThresholds: {
|
||||
lowStockDays: settings?.lowStockDays ?? 30,
|
||||
normalStockDays: settings?.normalStockDays ?? 60,
|
||||
highStockDays: settings?.highStockDays ?? 90,
|
||||
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
|
||||
expiryWarningDays: settings?.expiryWarningDays ?? 90,
|
||||
},
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/share/:token/overview",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
response: {
|
||||
200: shareOverviewResponseSchema,
|
||||
404: genericErrorSchema,
|
||||
410: shareOverviewExpiredResponseSchema,
|
||||
},
|
||||
},
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||
};
|
||||
});
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 60,
|
||||
timeWindow: "1 minute",
|
||||
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
|
||||
const { token } = request.params;
|
||||
if (!shareTokenPattern.test(token)) {
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
return reply.status(410).send({
|
||||
error: "expired",
|
||||
expiredAt: share.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||
});
|
||||
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
|
||||
const overview = buildSharedMedicationOverview({
|
||||
medications: meds,
|
||||
doses,
|
||||
thresholdDays: settings?.lowStockDays ?? 30,
|
||||
});
|
||||
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
generatedAt: new Date().toISOString(),
|
||||
medications: overview,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share - PROTECTED: Create a new share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof createShareSchema> }>(
|
||||
"/share",
|
||||
{ preHandler: requireAuth },
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
body: createShareBodyOpenApiSchema,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
reused: { type: "boolean" },
|
||||
token: { type: "string" },
|
||||
shareUrl: { type: "string" },
|
||||
expiresAt: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -179,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
|
||||
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const medsForPerson = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -197,25 +414,47 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique token (8 bytes = 16 hex chars)
|
||||
// Keep exactly one active share link per person/user.
|
||||
// If a link already exists, return the same token and only update settings.
|
||||
const [existingShare] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||
|
||||
if (existingShare) {
|
||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
// Set expiration date (1 year from now)
|
||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
||||
|
||||
// Create share token
|
||||
await db.insert(shareTokens).values({
|
||||
userId: userId,
|
||||
userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt,
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -223,37 +462,56 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
app.get(
|
||||
"/share/people",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
people: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all unique takenBy values for this user (from both medication-level and intake-level)
|
||||
const meds = await db
|
||||
.select({
|
||||
takenByJson: medications.takenByJson,
|
||||
intakesJson: medications.intakesJson,
|
||||
usageJson: medications.usageJson,
|
||||
everyJson: medications.everyJson,
|
||||
startJson: medications.startJson,
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
// Get all unique takenBy values for this user (from both medication-level and intake-level)
|
||||
const meds = await db
|
||||
.select({
|
||||
takenByJson: medications.takenByJson,
|
||||
intakesJson: medications.intakesJson,
|
||||
usageJson: medications.usageJson,
|
||||
everyJson: medications.everyJson,
|
||||
startJson: medications.startJson,
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
|
||||
// Collect all unique person names from medication-level AND intake-level takenBy
|
||||
const allPeople = new Set<string>();
|
||||
for (const med of meds) {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
|
||||
for (const person of allForMed) {
|
||||
if (person) allPeople.add(person);
|
||||
// Collect all unique person names from medication-level AND intake-level takenBy
|
||||
const allPeople = new Set<string>();
|
||||
for (const med of meds) {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
|
||||
for (const person of allForMed) {
|
||||
if (person) allPeople.add(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { people: [...allPeople].sort() };
|
||||
});
|
||||
return { people: [...allPeople].sort() };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
getAverageOccurrencesPerDay,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getTodayInTimezone,
|
||||
type Intake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
type DoseRow = typeof doseTracking.$inferSelect;
|
||||
|
||||
export type SharedMedicationOverviewItem = {
|
||||
name: string;
|
||||
genericName: string | null;
|
||||
imageUrl: string | null;
|
||||
packageType: string;
|
||||
packCount: number;
|
||||
packageAmountValue: number | null;
|
||||
packageAmountUnit: "ml" | "g" | null;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
totalPills: number | null;
|
||||
looseTablets: number;
|
||||
currentStock: number | null;
|
||||
capacity: number | null;
|
||||
daysLeft: number | null;
|
||||
nextIntakeDate: string | null;
|
||||
depletionDate: string | null;
|
||||
priority: "normal" | "high" | "out-of-stock" | null;
|
||||
expiryDate: string | null;
|
||||
medicationStartDate: string | null;
|
||||
prescriptionEnabled: boolean;
|
||||
prescriptionRemainingRefills: number | null;
|
||||
};
|
||||
|
||||
function toDateOnlyString(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseDateOnly(dateOnly: string): Date {
|
||||
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
|
||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function computeCapacity(medication: MedicationRow): number {
|
||||
if (isAmountBasedPackageType(medication.packageType)) {
|
||||
return medication.totalPills ?? medication.looseTablets;
|
||||
}
|
||||
|
||||
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
|
||||
}
|
||||
|
||||
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
|
||||
return intakes.reduce((sum, intake) => {
|
||||
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
||||
const today = parseDateOnly(todayDateOnly);
|
||||
let nextOccurrenceMs: number | null = null;
|
||||
|
||||
for (const intake of intakes) {
|
||||
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
|
||||
if (occurrenceMs === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
|
||||
nextOccurrenceMs = occurrenceMs;
|
||||
}
|
||||
}
|
||||
|
||||
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
|
||||
}
|
||||
|
||||
function computeTakenAmount(
|
||||
medication: MedicationRow,
|
||||
intakes: Intake[],
|
||||
dosesByMedication: Map<number, DoseRow[]>
|
||||
): number {
|
||||
const doseRows = dosesByMedication.get(medication.id) ?? [];
|
||||
if (doseRows.length === 0) return 0;
|
||||
|
||||
const correctionDateOnlyMs = medication.lastStockCorrectionAt
|
||||
? new Date(
|
||||
medication.lastStockCorrectionAt.getFullYear(),
|
||||
medication.lastStockCorrectionAt.getMonth(),
|
||||
medication.lastStockCorrectionAt.getDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
).getTime()
|
||||
: 0;
|
||||
|
||||
let takenAmount = 0;
|
||||
for (const dose of doseRows) {
|
||||
if (dose.dismissed) continue;
|
||||
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
|
||||
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
|
||||
|
||||
const intake = intakes[intakeIndex];
|
||||
if (!intake) continue;
|
||||
|
||||
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
}
|
||||
|
||||
return takenAmount;
|
||||
}
|
||||
|
||||
function toNullableDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function computeOverviewPriority(
|
||||
currentStock: number,
|
||||
daysLeft: number | null,
|
||||
thresholdDays: number
|
||||
): "normal" | "high" | "out-of-stock" {
|
||||
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
|
||||
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
export function buildSharedMedicationOverview(options: {
|
||||
medications: MedicationRow[];
|
||||
doses: DoseRow[];
|
||||
thresholdDays: number;
|
||||
}): SharedMedicationOverviewItem[] {
|
||||
const { medications: medicationRows, doses, thresholdDays } = options;
|
||||
|
||||
const dosesByMedication = new Map<number, DoseRow[]>();
|
||||
for (const dose of doses) {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
if (Number.isNaN(medicationId)) continue;
|
||||
|
||||
const existing = dosesByMedication.get(medicationId) ?? [];
|
||||
existing.push(dose);
|
||||
dosesByMedication.set(medicationId, existing);
|
||||
}
|
||||
|
||||
const todayDateOnly = getTodayInTimezone();
|
||||
const todayDate = parseDateOnly(todayDateOnly);
|
||||
|
||||
return medicationRows.map((medication) => {
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{
|
||||
usageJson: medication.usageJson,
|
||||
everyJson: medication.everyJson,
|
||||
startJson: medication.startJson,
|
||||
},
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
const capacity = computeCapacity(medication);
|
||||
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
|
||||
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
|
||||
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
|
||||
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
|
||||
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
|
||||
const depletionDate =
|
||||
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
|
||||
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
||||
return {
|
||||
name: medication.name,
|
||||
genericName: medication.genericName,
|
||||
imageUrl: medication.imageUrl,
|
||||
packageType: medication.packageType,
|
||||
packCount: medication.packCount,
|
||||
packageAmountValue: medication.packageAmountValue,
|
||||
packageAmountUnit:
|
||||
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
|
||||
? medication.packageAmountUnit
|
||||
: null,
|
||||
blistersPerPack: medication.blistersPerPack,
|
||||
pillsPerBlister: medication.pillsPerBlister,
|
||||
totalPills: medication.totalPills,
|
||||
looseTablets: medication.looseTablets,
|
||||
currentStock,
|
||||
capacity,
|
||||
daysLeft,
|
||||
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
|
||||
depletionDate,
|
||||
priority,
|
||||
expiryDate: toNullableDate(medication.expiryDate),
|
||||
medicationStartDate: toNullableDate(medication.medicationStartDate),
|
||||
prescriptionEnabled: medication.prescriptionEnabled ?? false,
|
||||
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
countScheduledOccurrencesInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getNextScheduledOccurrenceTime,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseTakenByJson,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
type DoseRow = typeof doseTracking.$inferSelect;
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getDoseTakenAtMs(dose: DoseRow): number {
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
if (Number.isFinite(rawTakenAt)) {
|
||||
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||
}
|
||||
|
||||
return new Date(dose.takenAt).getTime();
|
||||
}
|
||||
|
||||
export function computeMedicationCurrentStock(options: {
|
||||
medication: MedicationRow;
|
||||
doses: DoseRow[];
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
nowMs?: number;
|
||||
}): number {
|
||||
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{
|
||||
usageJson: medication.usageJson,
|
||||
everyJson: medication.everyJson,
|
||||
startJson: medication.startJson,
|
||||
},
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
const baseStock = isAmountBasedPackageType(medication.packageType)
|
||||
? medication.looseTablets + (medication.stockAdjustment ?? 0)
|
||||
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
|
||||
medication.looseTablets +
|
||||
(medication.stockAdjustment ?? 0);
|
||||
|
||||
const relevantDoses = doses.filter((dose) => !dose.dismissed);
|
||||
const stockCorrectionCutoff = medication.lastStockCorrectionAt
|
||||
? new Date(medication.lastStockCorrectionAt).getTime()
|
||||
: 0;
|
||||
let consumed = 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
|
||||
|
||||
intakes.forEach((intake, intakeIndex) => {
|
||||
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
||||
if (Number.isNaN(intakeStart)) return;
|
||||
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
|
||||
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
|
||||
: intakeStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intake.takenBy) {
|
||||
peopleForThisIntake = [intake.takenBy];
|
||||
} else if (medicationTakenBy.length > 0) {
|
||||
peopleForThisIntake = medicationTakenBy;
|
||||
} else {
|
||||
peopleForThisIntake = [null];
|
||||
}
|
||||
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
if (effectiveStart <= nowMs) {
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
intake,
|
||||
effectiveStart,
|
||||
nowMs
|
||||
);
|
||||
consumed += occurrences * usage * peopleForThisIntake.length;
|
||||
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
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(parsedMedicationId) ||
|
||||
Number.isNaN(parsedIntakeIndex) ||
|
||||
Number.isNaN(doseDateOnlyMs) ||
|
||||
parsedMedicationId !== medication.id ||
|
||||
parsedIntakeIndex !== intakeIndex
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doseDateOnlyMs > earlyCutoff) {
|
||||
consumed += usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
intakes.forEach((intake, intakeIndex) => {
|
||||
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
const intakeStartDateOnly = new Date(
|
||||
intakeStart.getFullYear(),
|
||||
intakeStart.getMonth(),
|
||||
intakeStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(intakeStartDateOnly)) return;
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||
if (
|
||||
Number.isNaN(parsedMedicationId) ||
|
||||
Number.isNaN(parsedIntakeIndex) ||
|
||||
Number.isNaN(doseDateOnlyMs) ||
|
||||
parsedMedicationId !== medication.id ||
|
||||
parsedIntakeIndex !== intakeIndex
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAtMs = getDoseTakenAtMs(dose);
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
|
||||
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumed += usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(baseStock - consumed));
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { doseTracking, medications } from "../db/schema.js";
|
||||
import { getDataDir } from "../db/path-utils.js";
|
||||
import { doseTracking, medications, users } from "../db/schema.js";
|
||||
import {
|
||||
getDateLocale,
|
||||
getFooterHtml,
|
||||
@@ -13,35 +12,39 @@ import {
|
||||
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 shared utilities
|
||||
import {
|
||||
cleanOldIntakeReminders,
|
||||
createDefaultIntakeReminderState,
|
||||
getTimezone,
|
||||
getEffectiveTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type IntakeReminderState,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
parseTakenByJson,
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
||||
import { computeMedicationCurrentStock } from "./current-stock.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();
|
||||
}
|
||||
@@ -59,6 +62,37 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
}
|
||||
|
||||
async function getUsernameForLog(userId: number): Promise<string> {
|
||||
const user = await db.select({ username: users.username }).from(users).where(eq(users.id, userId));
|
||||
const username = user[0]?.username?.trim();
|
||||
return username && username.length > 0 ? username : `unknown-user-${userId}`;
|
||||
}
|
||||
|
||||
function formatIntakeLog(intake: {
|
||||
medName: string;
|
||||
medicationId: number;
|
||||
blisterIndex: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
usage: number;
|
||||
doseUnit?: string;
|
||||
takenBy?: string | null;
|
||||
}): string {
|
||||
const takenBy = intake.takenBy ? intake.takenBy : "none";
|
||||
const doseUnit = intake.doseUnit ?? "mg";
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function autoMarkDueIntakesAsTaken(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
@@ -67,6 +101,9 @@ async function autoMarkDueIntakesAsTaken(
|
||||
logger: ServiceLogger
|
||||
): Promise<number> {
|
||||
if (settings.stockCalculationMode !== "automatic") {
|
||||
logger.debug(
|
||||
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -88,6 +125,10 @@ async function autoMarkDueIntakesAsTaken(
|
||||
)
|
||||
);
|
||||
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||
const trackedDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
let inserted = 0;
|
||||
|
||||
@@ -106,8 +147,18 @@ async function autoMarkDueIntakesAsTaken(
|
||||
}
|
||||
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||
let remainingStock = computeMedicationCurrentStock({
|
||||
medication: med,
|
||||
doses: trackedDoses,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
nowMs: now.getTime(),
|
||||
});
|
||||
if (remainingStock <= 0) {
|
||||
continue;
|
||||
}
|
||||
const todaysIntakes = getTodaysIntakes(
|
||||
med.name,
|
||||
medDisplayName,
|
||||
intakes,
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
@@ -136,6 +187,14 @@ async function autoMarkDueIntakesAsTaken(
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakeDefinition = intakes[intake.blisterIndex];
|
||||
const usage = intakeDefinition
|
||||
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
|
||||
: 0;
|
||||
if (remainingStock <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
@@ -145,13 +204,38 @@ async function autoMarkDueIntakesAsTaken(
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Auto-marked intake for userId=${settings.userId}: ${formatIntakeLog({
|
||||
medName: intake.medName,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
intakeTime: intake.intakeTime,
|
||||
intakeTimeStr: intake.intakeTimeStr,
|
||||
usage: intake.usage,
|
||||
doseUnit: intake.doseUnit,
|
||||
takenBy: intake.takenBy,
|
||||
})}`
|
||||
);
|
||||
|
||||
existingDoseIds.add(doseId);
|
||||
trackedDoses.push({
|
||||
id: 0,
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
takenAt: intake.intakeTime,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
remainingStock = Math.max(0, remainingStock - usage);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||
if (inserted === 0) {
|
||||
logger.debug(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: no due intakes`);
|
||||
} else {
|
||||
logger.info(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: inserted=${inserted}`);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
@@ -165,15 +249,10 @@ async function sendIntakeReminderEmail(
|
||||
repeatIntervalMinutes?: number,
|
||||
currentCount?: number,
|
||||
maxCount?: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
@@ -298,30 +377,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,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `💊 ${subject}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} 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> {
|
||||
@@ -329,40 +401,55 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
logger.debug(`[IntakeReminder] Scheduler cycle loaded user settings count=${allUserSettings.length}`);
|
||||
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.debug(`[IntakeReminder] No users with settings found`);
|
||||
return; // No users with settings
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Scheduler cycle finished`);
|
||||
}
|
||||
|
||||
async function checkAndSendIntakeRemindersForUser(
|
||||
export async function checkAndSendIntakeRemindersForUser(
|
||||
settings: UserSettings & { userId: number },
|
||||
logger: ServiceLogger
|
||||
): Promise<void> {
|
||||
const username = await getUsernameForLog(settings.userId);
|
||||
logger.info(
|
||||
`[IntakeReminder] Evaluating intake reminders for user=${username} (userId=${settings.userId}, emailEnabled=${settings.emailEnabled}, pushEnabled=${settings.shoutrrrEnabled}, skipTaken=${settings.skipRemindersForTakenDoses}, repeat=${settings.repeatRemindersEnabled}, mode=${settings.stockCalculationMode})`
|
||||
);
|
||||
|
||||
const language = settings.language;
|
||||
const tr = getTranslations(language);
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
.where(and(eq(medications.userId, settings.userId), eq(medications.isObsolete, false)));
|
||||
|
||||
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);
|
||||
|
||||
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||
if (autoMarkedCount > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Auto-mark summary for user=${username} (userId=${settings.userId}): autoMarkedCount=${autoMarkedCount}`
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.stockCalculationMode === "automatic" && settings.skipRemindersForTakenDoses) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminder sending skipped for user=${username} (userId=${settings.userId}) because stockCalculationMode=automatic and skipRemindersForTakenDoses=true`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any intake reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||
@@ -370,29 +457,35 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||
`[IntakeReminder] Notification sending disabled for user=${username} (userId=${settings.userId}): both email and push intake reminders are off`
|
||||
);
|
||||
return; // No intake reminder notifications enabled for this user
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||
);
|
||||
// Build medication entries that have at least one reminder-enabled intake.
|
||||
// Intake-level reminders are the single source of truth.
|
||||
const reminderEntries = activeRows
|
||||
.map((med) => {
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
false
|
||||
);
|
||||
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
|
||||
return { med, intakes, intakesWithReminders };
|
||||
})
|
||||
.filter((entry) => entry.intakesWithReminders.length > 0);
|
||||
|
||||
// Get all medications with intake reminders enabled for this user
|
||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||
|
||||
if (medsWithReminders.length === 0) {
|
||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||
if (reminderEntries.length === 0) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] No reminder-enabled intake definitions for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
return; // No medications have reminders enabled for this user
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
|
||||
);
|
||||
|
||||
const state = loadIntakeReminderState();
|
||||
const state = loadIntakeReminderState(logger);
|
||||
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 }));
|
||||
@@ -401,44 +494,31 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
|
||||
);
|
||||
|
||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||
for (const med of medsWithReminders) {
|
||||
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||
// Medication-level takenBy (for fallback/display purposes)
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
|
||||
);
|
||||
|
||||
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
||||
const intakesWithReminders = intakes.filter((intake, idx) => {
|
||||
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
|
||||
if (!hasReminder) {
|
||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
|
||||
}
|
||||
return hasReminder;
|
||||
});
|
||||
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||
|
||||
// Process each intake separately to track blisterIndex
|
||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
||||
|
||||
const todaysIntakesForThisDefinition = getTodaysIntakes(
|
||||
medDisplayName,
|
||||
[intake],
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
locale,
|
||||
tz,
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
|
||||
|
||||
// Always get upcoming intakes (15 min before) for first reminders
|
||||
const upcomingIntakes = getUpcomingIntakes(
|
||||
med.name,
|
||||
medDisplayName,
|
||||
[intake],
|
||||
REMINDER_MINUTES_BEFORE,
|
||||
medicationTakenBy,
|
||||
@@ -449,9 +529,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
|
||||
);
|
||||
|
||||
// Add upcoming intakes for first reminders
|
||||
allUpcoming.push(
|
||||
@@ -464,25 +541,9 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||
if (settings.repeatRemindersEnabled) {
|
||||
const allTodaysIntakes = getTodaysIntakes(
|
||||
med.name,
|
||||
[intake],
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
locale,
|
||||
tz,
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
|
||||
);
|
||||
const missedIntakes = allTodaysIntakes.filter(
|
||||
const missedIntakes = todaysIntakesForThisDefinition.filter(
|
||||
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
|
||||
);
|
||||
|
||||
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
|
||||
@@ -499,13 +560,17 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
|
||||
|
||||
if (allUpcoming.length === 0) {
|
||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
|
||||
logger.debug(
|
||||
`[IntakeReminder] No upcoming intakes in reminder window for user=${username} (userId=${settings.userId}, scheduledToday=${scheduledIntakesTodayCount})`
|
||||
);
|
||||
return; // No upcoming intakes for today
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Candidate intakes for user=${username} (userId=${settings.userId}): scheduledToday=${scheduledIntakesTodayCount}, candidates=${allUpcoming.length}`
|
||||
);
|
||||
|
||||
// Determine which doses need reminders (new or repeated)
|
||||
const nowMs = Date.now();
|
||||
const maxReminders = settings.maxNaggingReminders ?? 5;
|
||||
@@ -533,9 +598,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
// Recently missed — scheduler likely recovered from sleep/restart.
|
||||
// Send a catch-up reminder (counts as first nagging reminder).
|
||||
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
|
||||
logger.info(
|
||||
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
|
||||
);
|
||||
} else {
|
||||
// Long ago — seed state without notification (user likely already noticed)
|
||||
state.reminders[key] = {
|
||||
@@ -544,16 +606,10 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
sendCount: 0,
|
||||
advanceSent: false,
|
||||
};
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Upcoming - this is advance reminder (no counter)
|
||||
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
|
||||
);
|
||||
}
|
||||
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
||||
// Intake time passed - check if we need to send nagging reminder
|
||||
@@ -565,27 +621,41 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const currentNaggingCount = existingEntry.sendCount;
|
||||
|
||||
if (currentNaggingCount >= maxReminders) {
|
||||
// Max nagging reminders reached - stop
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
|
||||
);
|
||||
} else if (timeSinceLastReminder >= intervalMs) {
|
||||
const nextSendCount = currentNaggingCount + 1;
|
||||
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
||||
}
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] No reminders to send for user=${username} (userId=${settings.userId}) after state/repeat evaluation`
|
||||
);
|
||||
return; // All reminders already sent and no repeats needed
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminders selected for user=${username} (userId=${settings.userId}): count=${remindersToSend.length} :: ${remindersToSend
|
||||
.map((intake) =>
|
||||
formatIntakeLog({
|
||||
medName: intake.medName,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
intakeTime: intake.intakeTime,
|
||||
intakeTimeStr: intake.intakeTimeStr,
|
||||
usage: intake.usage,
|
||||
doseUnit: intake.doseUnit,
|
||||
takenBy: intake.takenBy,
|
||||
})
|
||||
)
|
||||
.join(" | ")}`
|
||||
);
|
||||
|
||||
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
|
||||
if (settings.skipRemindersForTakenDoses) {
|
||||
const beforeFilterCount = remindersToSend.length;
|
||||
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
|
||||
const takenToday = await db
|
||||
.select()
|
||||
@@ -611,33 +681,30 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
// For person-specific intake, check if that person has taken it
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||
const isTaken = takenDoseIds.has(doseId);
|
||||
if (isTaken) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
|
||||
);
|
||||
}
|
||||
return !isTaken;
|
||||
} else {
|
||||
// For non-person-specific intakes
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
const isTaken = takenDoseIds.has(doseId);
|
||||
if (isTaken) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
|
||||
);
|
||||
}
|
||||
return !isTaken;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredOutCount = beforeFilterCount - remindersToSend.length;
|
||||
if (filteredOutCount > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Removed reminders for already taken doses for user=${username} (userId=${settings.userId}): removed=${filteredOutCount}, remaining=${remindersToSend.length}`
|
||||
);
|
||||
}
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
|
||||
logger.info(
|
||||
`[IntakeReminder] All candidate reminders already taken for user=${username} (userId=${settings.userId}); nothing to send`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
|
||||
|
||||
// Determine if this is a repeat reminder:
|
||||
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||
// - OR intake is past even without state entry (missed the 15-min window)
|
||||
@@ -667,10 +734,14 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
hasNaggingReminder ? maxReminderCount : undefined
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Email delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
||||
logger.info(
|
||||
`[IntakeReminder] Email delivered for user=${username} (userId=${settings.userId}, recipient=${settings.notificationEmail}, reminders=${remindersToSend.length}, messageId=${result.messageId ?? "n/a"})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,12 +802,16 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
repeatNote +
|
||||
`\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
|
||||
logger.info(
|
||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,6 +878,13 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const medName = firstReminder?.medName;
|
||||
const takenBy = firstReminder?.takenBy || undefined;
|
||||
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminder state persisted for user=${username} (userId=${settings.userId}, channel=${channel}, reminders=${remindersToSend.length}, firstMed=${medName ?? "n/a"}, firstTakenBy=${takenBy ?? "none"})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[IntakeReminder] No reminder channel succeeded for user=${username} (userId=${settings.userId}, remindersAttempted=${remindersToSend.length})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,109 @@
|
||||
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
|
||||
|
||||
export type StockReminderItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
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}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${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}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${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,137 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { sendShoutrrrNotification } from "../../routes/settings.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
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, title, message);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
} 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,57 @@
|
||||
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 } }
|
||||
): string {
|
||||
const unitKind = getPlannerUnitKind(packageType);
|
||||
if (unitKind === "units") return tr.common.units;
|
||||
if (unitKind === "ml") return tr.common.ml;
|
||||
return tr.common.pills;
|
||||
}
|
||||
@@ -1,30 +1,45 @@
|
||||
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,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
} from "../utils/package-profiles.js";
|
||||
// Import shared utilities
|
||||
import {
|
||||
type Blister,
|
||||
calculateDepletionInfo,
|
||||
createDefaultReminderState,
|
||||
countScheduledOccurrencesInRange,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getDateOnlyTimestamp,
|
||||
getEffectiveTimezone,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
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";
|
||||
|
||||
export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
@@ -39,7 +54,6 @@ function escapeHtml(text: string): string {
|
||||
|
||||
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;
|
||||
|
||||
@@ -91,86 +105,6 @@ 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;
|
||||
@@ -179,6 +113,12 @@ type LowStockItem = {
|
||||
isCritical: boolean;
|
||||
};
|
||||
|
||||
function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||
return { lowDays, criticalDays };
|
||||
}
|
||||
|
||||
type PrescriptionReminderItem = {
|
||||
name: string;
|
||||
remainingRefills: number;
|
||||
@@ -186,6 +126,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,
|
||||
@@ -228,20 +178,29 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const packageType = normalizePackageType(row.packageType);
|
||||
// Tube stock reminders are intentionally disabled:
|
||||
// topical usage in grams cannot be mapped reliably to schedule events.
|
||||
if (isTubePackageType(packageType)) continue;
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
row.intakesJson,
|
||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||
row.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
const blisters: Blister[] = intakes.map((i) => ({
|
||||
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
scheduleMode: i.scheduleMode,
|
||||
weekdays: i.weekdays,
|
||||
}));
|
||||
|
||||
const originalTotalPills =
|
||||
(row.packageType ?? "blister") === "bottle"
|
||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
@@ -253,16 +212,11 @@ async function getMedicationsNeedingReminder(
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
|
||||
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
|
||||
: blisterStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
@@ -280,25 +234,20 @@ async function getMedicationsNeedingReminder(
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
blister,
|
||||
effectiveStart,
|
||||
now
|
||||
);
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
@@ -348,12 +297,17 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
if (daysLeft === null) continue;
|
||||
|
||||
const isCritical = daysLeft <= reminderDaysBefore;
|
||||
const isLow = daysLeft < lowStockDays;
|
||||
const isLiquid = isLiquidContainerPackageType(packageType);
|
||||
const { lowDays, criticalDays } = isLiquid
|
||||
? getLiquidReminderThresholds(reminderDaysBefore)
|
||||
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
||||
|
||||
const isCritical = daysLeft <= criticalDays;
|
||||
const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays;
|
||||
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
medsLeft: currentPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
@@ -379,7 +333,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,
|
||||
@@ -411,14 +365,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" };
|
||||
}
|
||||
|
||||
@@ -540,33 +488,31 @@ ${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,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
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> {
|
||||
// Track stock-scheduler daily execution separately from intake updates.
|
||||
// This prevents intake reminders from suppressing stock catch-up after restarts.
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastStockSchedulerCheckDate: today,
|
||||
});
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
@@ -599,7 +545,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`;
|
||||
@@ -617,12 +564,10 @@ async function checkAndSendReminderForUser(
|
||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||
if (!stockSendLock) {
|
||||
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||
logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send");
|
||||
} else {
|
||||
try {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||
);
|
||||
logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`);
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
@@ -636,49 +581,16 @@ async function checkAndSendReminderForUser(
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
||||
logger.error(`[Reminder] Failed to send stock email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stockPushEnabled) {
|
||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (criticalMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (lowStockMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowStockMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
const pushPayload = buildStockReminderPushNotification(allLowStock, language);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, pushPayload.title, pushPayload.message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
||||
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,6 +601,7 @@ async function checkAndSendReminderForUser(
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "stock",
|
||||
@@ -709,90 +622,99 @@ async function checkAndSendReminderForUser(
|
||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||
if (!prescriptionSendLock) {
|
||||
logger.debug(
|
||||
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||
);
|
||||
logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send");
|
||||
} else {
|
||||
try {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||
);
|
||||
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
|
||||
// This blocks duplicate sends when two reminder checks overlap in time.
|
||||
const lockedState = loadReminderState();
|
||||
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
|
||||
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
|
||||
if (!shouldSend) {
|
||||
logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping");
|
||||
}
|
||||
|
||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||
const lines = allPrescriptionLow.map((m) => {
|
||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||
if (m.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
const preMarkedNotified =
|
||||
!shouldSend || alreadyNotified
|
||||
? lockedState.notifiedMedications
|
||||
: [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])];
|
||||
if (shouldSend && !alreadyNotified) {
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: lockedState.lastAutoEmailSent,
|
||||
lastAutoEmailDate: lockedState.lastAutoEmailDate,
|
||||
lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: preMarkedNotified,
|
||||
nextScheduledCheck: lockedState.nextScheduledCheck,
|
||||
lastNotificationType: lockedState.lastNotificationType,
|
||||
lastNotificationChannel: lockedState.lastNotificationChannel,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldSend) {
|
||||
logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`);
|
||||
|
||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||
const lines = allPrescriptionLow.map((m) => {
|
||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||
if (m.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
name: m.name,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
}
|
||||
return `- ${t(tr.prescriptionReminder.line, {
|
||||
name: m.name,
|
||||
refills: m.remainingRefills,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
}
|
||||
return `- ${t(tr.prescriptionReminder.line, {
|
||||
name: m.name,
|
||||
refills: m.remainingRefills,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
});
|
||||
});
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
let emailSuccess = false;
|
||||
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 (prescriptionEmailEnabled) {
|
||||
const smtp = getSmtpConfig();
|
||||
if (smtp.host && smtp.user) {
|
||||
try {
|
||||
const subject =
|
||||
allPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||
});
|
||||
const bodyText =
|
||||
emptyRx.length > 0
|
||||
? tr.prescriptionReminder.descriptionEmpty
|
||||
: tr.prescriptionReminder.descriptionLow;
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const subject =
|
||||
allPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0
|
||||
? tr.prescriptionReminder.descriptionEmpty
|
||||
: tr.prescriptionReminder.descriptionLow;
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const tableRows = allPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
const safeRefills = Number(item.remainingRefills) || 0;
|
||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
const tableRows = allPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
const safeRefills = Number(item.remainingRefills) || 0;
|
||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const html = `
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
||||
@@ -832,74 +754,66 @@ async function checkAndSendReminderForUser(
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: settings.notificationEmail!,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
emailSuccess = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: settings.notificationEmail!,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Unknown error");
|
||||
}
|
||||
emailSuccess = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prescriptionPushEnabled) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
if (prescriptionPushEnabled) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "prescription",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "prescription",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||
} else if (!alreadyNotified) {
|
||||
// Roll back pre-mark when both channels failed so retries remain possible.
|
||||
const currentState = loadReminderState();
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: currentState.lastAutoEmailSent,
|
||||
lastAutoEmailDate: currentState.lastAutoEmailDate,
|
||||
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: currentState.notifiedMedications.filter(
|
||||
(key) => key !== userPrescriptionNotifiedKey
|
||||
),
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: currentState.lastNotificationType,
|
||||
lastNotificationChannel: currentState.lastNotificationChannel,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseReminderSendLock(prescriptionSendLock);
|
||||
@@ -947,9 +861,10 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
||||
const today = getTodayInTimezone();
|
||||
const currentHour = getCurrentHourInTimezone();
|
||||
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running now...");
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run one catch-up.
|
||||
// This is intentionally a single current-state snapshot (no replay of missed days).
|
||||
if (currentHour >= REMINDER_HOUR && state.lastStockSchedulerCheckDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running one catch-up snapshot (no historical replay)...");
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.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 (url.startsWith("ntfy://")) 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 = urlStr.startsWith("ntfy://");
|
||||
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,12 @@
|
||||
*/
|
||||
|
||||
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
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
@@ -28,7 +29,7 @@ vi.mock("../db/client.js", () => ({
|
||||
vi.mock("../plugins/env.js", () => ({
|
||||
env: {
|
||||
AUTH_ENABLED: true,
|
||||
LOCAL_AUTH_ENABLED: true,
|
||||
FORM_LOGIN_ENABLED: true,
|
||||
REGISTRATION_ENABLED: true,
|
||||
OIDC_ENABLED: false,
|
||||
NODE_ENV: "test",
|
||||
@@ -97,11 +98,11 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
beforeAll(async () => {
|
||||
await createSchema(testClient);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -144,7 +145,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
const data = response.json();
|
||||
expect(data.authEnabled).toBe(true);
|
||||
expect(data.registrationEnabled).toBe(true);
|
||||
expect(data.localAuthEnabled).toBe(true);
|
||||
expect(data.formLoginEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,7 +229,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
expect(response.json().code).toBe("FST_ERR_VALIDATION");
|
||||
});
|
||||
|
||||
it("should reject short username", async () => {
|
||||
@@ -242,7 +243,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
expect(response.json().code).toBe("FST_ERR_VALIDATION");
|
||||
});
|
||||
|
||||
it("should register with trimmed username when input has whitespace", async () => {
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
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(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: true,
|
||||
REGISTRATION_ENABLED: true,
|
||||
FORM_LOGIN_ENABLED: true,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
JWT_SECRET: "test-jwt-secret",
|
||||
REFRESH_SECRET: "test-refresh-secret",
|
||||
COOKIE_SECRET: "test-cookie-secret",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
OPENAPI_DOCS_ENABLED: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { doseRoutes } = await import("../routes/doses.js");
|
||||
const { refillRoutes } = await import("../routes/refills.js");
|
||||
const { shareRoutes } = await import("../routes/share.js");
|
||||
const { reportRoutes } = await import("../routes/report.js");
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
const { hashApiKeyToken } = await import("../plugins/auth.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM api_keys");
|
||||
await testClient.execute("DELETE FROM refresh_tokens");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
async function insertApiKey(options: {
|
||||
userId: number;
|
||||
token: string;
|
||||
scope?: "read" | "write";
|
||||
isActive?: boolean;
|
||||
expiresAt?: Date | null;
|
||||
}) {
|
||||
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
options.userId,
|
||||
"Seeded Key",
|
||||
hashApiKeyToken(options.token),
|
||||
`${options.token.slice(0, 12)}...`,
|
||||
options.scope ?? "write",
|
||||
options.isActive === false ? 0 : 1,
|
||||
expiresAtValue,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMedication(options: {
|
||||
userId: number;
|
||||
name: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
looseTablets?: number;
|
||||
start?: string;
|
||||
}) {
|
||||
const start = options.start ?? "2026-01-01T08:00:00.000Z";
|
||||
const takenBy = options.takenBy ?? ["Daniel"];
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
stock_adjustment, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
options.userId,
|
||||
options.name,
|
||||
`${options.name} Generic`,
|
||||
JSON.stringify(takenBy),
|
||||
"tablet",
|
||||
"blister",
|
||||
options.packCount ?? 1,
|
||||
1,
|
||||
10,
|
||||
options.looseTablets ?? 0,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([start]),
|
||||
JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start,
|
||||
takenBy: takenBy[0] ?? null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]),
|
||||
0,
|
||||
1,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)",
|
||||
args: [options.userId, options.doseId, options.dismissed ? 1 : 0],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedRefill(options: {
|
||||
userId: number;
|
||||
medicationId: number;
|
||||
packsAdded?: number;
|
||||
loosePillsAdded?: number;
|
||||
}) {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription)
|
||||
VALUES (?, ?, ?, ?, 0)`,
|
||||
args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0],
|
||||
});
|
||||
}
|
||||
|
||||
function buildMedicationPayload(name: string) {
|
||||
return {
|
||||
name,
|
||||
genericName: `${name} Generic`,
|
||||
takenBy: ["Daniel"],
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportPayload() {
|
||||
return {
|
||||
version: "1.3",
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: false,
|
||||
medications: [],
|
||||
doseHistory: [],
|
||||
refillHistory: [],
|
||||
settings: {
|
||||
emailEnabled: false,
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
},
|
||||
shareLinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Real business route authz contracts", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(reportRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await clearTables();
|
||||
});
|
||||
|
||||
it("rejects protected business endpoints without authentication", async () => {
|
||||
const endpoints: Array<{
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}> = [
|
||||
{ method: "GET", url: "/medications" },
|
||||
{ method: "GET", url: "/doses/taken" },
|
||||
{ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } },
|
||||
{ method: "GET", url: "/export" },
|
||||
{ method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } },
|
||||
{ method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } },
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload });
|
||||
expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401);
|
||||
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes medication listing and export output to the authenticated user", async () => {
|
||||
const ownerId = await createUser("owner-medications");
|
||||
const otherId = await createUser("other-medications");
|
||||
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-medications");
|
||||
|
||||
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
|
||||
await seedMedication({ userId: otherId, name: "Other User Med" });
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.body).toContain("Owner Only Med");
|
||||
expect(listResponse.body).not.toContain("Other User Med");
|
||||
|
||||
const exportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(exportResponse.statusCode).toBe(200);
|
||||
expect(exportResponse.body).toContain("Owner Only Med");
|
||||
expect(exportResponse.body).not.toContain("Other User Med");
|
||||
});
|
||||
|
||||
it("returns 404 when a user updates or deletes another user's medication", async () => {
|
||||
const ownerId = await createUser("owner-update");
|
||||
const otherId = await createUser("other-update");
|
||||
const otherCookie = await buildSessionCookie(app, otherId, "other-update");
|
||||
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
|
||||
|
||||
const updateResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medicationId}`,
|
||||
headers: { cookie: otherCookie },
|
||||
payload: buildMedicationPayload("Updated By Stranger"),
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(404);
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/medications/${medicationId}`,
|
||||
headers: { cookie: otherCookie },
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(404);
|
||||
|
||||
const dbState = await testClient.execute({
|
||||
sql: "SELECT name FROM medications WHERE id = ?",
|
||||
args: [medicationId],
|
||||
});
|
||||
expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]);
|
||||
});
|
||||
|
||||
it("scopes dose reads and writes to the authenticated user", async () => {
|
||||
const ownerId = await createUser("owner-dose");
|
||||
const otherId = await createUser("other-dose");
|
||||
const ownerCookie = 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" });
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.body).toContain("101-0-1760000000000");
|
||||
expect(listResponse.body).not.toContain("202-0-1760000000000");
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/doses/taken/101-0-1760000000000",
|
||||
headers: { cookie: otherCookie },
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
|
||||
const ownerDose = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [ownerId, "101-0-1760000000000"],
|
||||
});
|
||||
expect(Number(ownerDose.rows[0].count)).toBe(1);
|
||||
});
|
||||
|
||||
it("enforces medication ownership on refill history and report generation", async () => {
|
||||
const ownerId = await createUser("owner-refill");
|
||||
const otherId = await createUser("other-refill");
|
||||
const otherCookie = await buildSessionCookie(app, otherId, "other-refill");
|
||||
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
|
||||
await seedRefill({ userId: ownerId, medicationId });
|
||||
|
||||
const refillListResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medicationId}/refills`,
|
||||
headers: { cookie: otherCookie },
|
||||
});
|
||||
|
||||
expect(refillListResponse.statusCode).toBe(404);
|
||||
|
||||
const refillMutationResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medicationId}/refill`,
|
||||
headers: { cookie: otherCookie },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
});
|
||||
|
||||
expect(refillMutationResponse.statusCode).toBe(404);
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
headers: { cookie: otherCookie },
|
||||
payload: { medicationIds: [medicationId] },
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(403);
|
||||
expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" });
|
||||
});
|
||||
|
||||
it("scopes share people to the authenticated user's medications", async () => {
|
||||
const ownerId = await createUser("owner-share");
|
||||
const otherId = await createUser("other-share");
|
||||
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-share");
|
||||
|
||||
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
|
||||
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/people",
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ people: ["Daniel"] });
|
||||
});
|
||||
|
||||
it("rejects mutation routes for read-only API keys across business endpoints", async () => {
|
||||
const userId = await createUser("readonly-business-key");
|
||||
const medicationId = await seedMedication({ userId, name: "Readonly Med" });
|
||||
const apiToken = "ma_readonly_business_routes_123456789";
|
||||
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||
|
||||
const responses = await Promise.all([
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
payload: buildMedicationPayload("Blocked Create"),
|
||||
}),
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
payload: { doseId: "1-0-1760000000000" },
|
||||
}),
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medicationId}/refill`,
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
}),
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
payload: { takenBy: "Daniel", scheduleDays: 7 },
|
||||
}),
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
payload: buildImportPayload(),
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => {
|
||||
const userId = await createUser("readonly-export-user");
|
||||
const otherId = await createUser("readonly-export-other");
|
||||
await seedMedication({ userId, name: "Readable Owner Med" });
|
||||
await seedMedication({ userId: otherId, name: "Unreadable Other Med" });
|
||||
const apiToken = "ma_readonly_export_access_123456789";
|
||||
await insertApiKey({ userId, token: apiToken, scope: "read" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export",
|
||||
headers: { authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("Readable Owner Med");
|
||||
expect(response.body).not.toContain("Unreadable Other Med");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
||||
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||
const getDbPaths = vi.fn().mockReturnValue({
|
||||
dataDir: "/tmp/medassist-data",
|
||||
dbPath: "/tmp/medassist-data/medassist.db",
|
||||
url: "file:/tmp/medassist-data/medassist.db",
|
||||
dbPath: "/tmp/medassist-data/medassist-ng.db",
|
||||
url: "file:/tmp/medassist-data/medassist-ng.db",
|
||||
});
|
||||
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||
@@ -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 = {
|
||||
@@ -102,7 +108,7 @@ describe("db/client bootstrap", () => {
|
||||
await mod.migrationsReady;
|
||||
|
||||
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
|
||||
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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" } };
|
||||
|
||||
expect(isContainerPackage("bottle")).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("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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+328
-386
@@ -1,487 +1,412 @@
|
||||
/**
|
||||
* Tests for /doses/taken API endpoints.
|
||||
* Tests marking doses as taken, listing taken doses, and unmarking.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
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";
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// Since we can't easily import routes that depend on the global db,
|
||||
// we'll create simplified route handlers for testing the core logic.
|
||||
// =============================================================================
|
||||
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);
|
||||
|
||||
async function registerDoseRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: true,
|
||||
REGISTRATION_ENABLED: true,
|
||||
FORM_LOGIN_ENABLED: true,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
JWT_SECRET: "test-jwt-secret",
|
||||
REFRESH_SECRET: "test-refresh-secret",
|
||||
COOKIE_SECRET: "test-cookie-secret",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
OPENAPI_DOCS_ENABLED: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// GET /doses/taken - List all taken doses
|
||||
app.get("/doses/taken", async (_request, _reply) => {
|
||||
// In test mode, use user ID 1 (will be created in tests)
|
||||
const userId = 1;
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
return {
|
||||
doses: result.rows.map((d) => ({
|
||||
doseId: d.dose_id,
|
||||
takenAt: (d.taken_at as number) * 1000, // Convert to ms
|
||||
markedBy: d.marked_by,
|
||||
})),
|
||||
};
|
||||
const { doseRoutes } = await import("../routes/doses.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 share_tokens");
|
||||
await testClient.execute("DELETE FROM api_keys");
|
||||
await testClient.execute("DELETE FROM refresh_tokens");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
// POST /doses/taken - Mark a dose as taken
|
||||
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseId } = request.body || {};
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
|
||||
return reply.status(400).send({ error: "doseId is required" });
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// DELETE /doses/taken/:doseId - Unmark a dose
|
||||
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => {
|
||||
const userId = 1;
|
||||
const { doseId } = request.params;
|
||||
|
||||
// Check if this dose was also dismissed
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
|
||||
// Already dismissed - keep the record as-is (don't delete)
|
||||
// The dose stays dismissed, we just ignore the undo request
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await client.execute({
|
||||
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /doses/dismiss - Dismiss missed doses without deducting stock
|
||||
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseIds } = request.body || {};
|
||||
|
||||
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
|
||||
return reply.status(400).send({ error: "doseIds array is required" });
|
||||
}
|
||||
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update to dismissed if not already
|
||||
if (!existing.rows[0].dismissed) {
|
||||
await client.execute({
|
||||
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
|
||||
args: [existing.rows[0].id],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Insert new dismissed record
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
async function insertMedication(options: {
|
||||
id: number;
|
||||
userId: number;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
looseTablets?: number;
|
||||
start?: string;
|
||||
}) {
|
||||
const intakeStart = options.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, '[1]', '[1]', ?, '[]', 0)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
JSON.stringify(options.takenBy ?? []),
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
intakeStart,
|
||||
"[]",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
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 _insertShareToken(userId: number, token: string, takenBy: string) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
||||
args: [userId, token, takenBy],
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
}
|
||||
|
||||
async function insertDose(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
markedBy?: string | null;
|
||||
dismissed?: boolean;
|
||||
takenAt?: number | null;
|
||||
takenSource?: "manual" | "automatic";
|
||||
}) {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
options.userId,
|
||||
options.doseId,
|
||||
options.markedBy ?? null,
|
||||
options.dismissed ? 1 : 0,
|
||||
options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0),
|
||||
options.takenSource ?? "manual",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("Dose Tracking API", () => {
|
||||
let ctx: TestContext;
|
||||
let app: FastifyInstance;
|
||||
let userId: number;
|
||||
let cookieHeader: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerDoseRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwtPlugin, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(doseRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Create test user - will get ID 1 since table is cleared
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
// Reset SQLite autoincrement so user gets ID 1
|
||||
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
|
||||
await clearTestData(ctx.client);
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
await clearTables();
|
||||
userId = await createUser("dose-test-user");
|
||||
cookieHeader = await buildSessionCookie(app, userId, "dose-test-user");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/taken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /doses/taken", () => {
|
||||
it("should mark a dose as taken", async () => {
|
||||
it("marks a dose as taken", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].dose_id).toBe(doseId);
|
||||
expect(result.rows[0].marked_by).toBeNull();
|
||||
expect(result.rows).toEqual([
|
||||
expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return idempotent response when dose already marked", async () => {
|
||||
it("returns an idempotent response when the dose is already marked", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId });
|
||||
|
||||
// Mark once
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Mark again
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Already marked" });
|
||||
|
||||
// Should still only have one record
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
const countResult = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
expect(Number(countResult.rows[0].count)).toBe(1);
|
||||
});
|
||||
|
||||
it("should reject request without doseId", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
it("rejects requests without a doseId", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
||||
expect(response.json()).toEqual({ error: "Required" });
|
||||
});
|
||||
|
||||
it("should reject request with empty doseId", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
it("accepts dose IDs with a person suffix and special characters", async () => {
|
||||
const doseId = "5-0-1735344000000-Max Müller";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: "" },
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseId is required" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||
});
|
||||
|
||||
it("rejects taking a dose when the medication is out of stock", async () => {
|
||||
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId: "5-0-1735344000000" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
});
|
||||
|
||||
it("allows taking a historical dose when stock existed at that occurrence", async () => {
|
||||
await insertMedication({
|
||||
id: 6,
|
||||
userId,
|
||||
packCount: 1,
|
||||
looseTablets: 0,
|
||||
start: "2025-01-01T08:00:00.000Z",
|
||||
});
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const historicalDoseId = "6-0-1736064000000";
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId: historicalDoseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /doses/taken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /doses/taken", () => {
|
||||
it("should return empty array when no doses taken", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
it("returns an empty array when no doses were taken", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ doses: [] });
|
||||
});
|
||||
|
||||
it("should return list of taken doses", async () => {
|
||||
const doseId1 = "1-0-1735344000000";
|
||||
const doseId2 = "1-0-1735430400000";
|
||||
|
||||
// Mark two doses
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: doseId1 },
|
||||
});
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId: doseId2 },
|
||||
it("returns only the authenticated user's taken doses with metadata", async () => {
|
||||
const otherUserId = await createUser("dose-other-user");
|
||||
await insertDose({
|
||||
userId,
|
||||
doseId: "1-0-1735344000000",
|
||||
markedBy: "Daniel",
|
||||
takenSource: "automatic",
|
||||
});
|
||||
await insertDose({ userId, doseId: "1-0-1735430400000" });
|
||||
await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" });
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(2);
|
||||
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
// Each dose should have a takenAt timestamp
|
||||
for (const dose of data.doses) {
|
||||
expect(dose.takenAt).toBeTypeOf("number");
|
||||
expect(dose.takenAt).toBeGreaterThan(0);
|
||||
expect(dose.markedBy).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should include markedBy when present", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Insert directly with markedBy
|
||||
await ctx.client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, "Daniel"],
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(1);
|
||||
expect(data.doses[0].markedBy).toBe("Daniel");
|
||||
expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([
|
||||
"1-0-1735344000000",
|
||||
"1-0-1735430400000",
|
||||
]);
|
||||
expect(data.doses).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }),
|
||||
expect.objectContaining({ markedBy: null, takenSource: "manual" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/taken/:doseId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DELETE /doses/taken/:doseId", () => {
|
||||
it("should unmark a dose", async () => {
|
||||
it("unmarks an existing dose", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId });
|
||||
|
||||
// Mark first
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Verify marked
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
// Unmark
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify unmarked
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
const countResult = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
expect(Number(countResult.rows[0].count)).toBe(0);
|
||||
});
|
||||
|
||||
it("should succeed even if dose was not marked", async () => {
|
||||
const doseId = "nonexistent-dose-id";
|
||||
it("keeps the record when the dose is dismissed", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId, dismissed: true });
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]);
|
||||
});
|
||||
|
||||
it("should preserve dismissed status when unmarking a dose", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// First dismiss the dose
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
// Verify it's dismissed
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
const originalTakenAt = result.rows[0].taken_at;
|
||||
|
||||
// Now try to unmark it (undo) - should keep the dismissed record
|
||||
const response = await ctx.app.inject({
|
||||
it("still succeeds when the dose does not exist", async () => {
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/taken/${encodeURIComponent(doseId)}`,
|
||||
url: "/doses/taken/nonexistent-dose-id",
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
// Verify the record still exists and is still dismissed
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
|
||||
args: [doseId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dose ID Format Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Dose ID Format", () => {
|
||||
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
|
||||
const doseId = "5-0-1735344000000";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
|
||||
const doseId = "5-0-1735344000000-Daniel";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should handle special characters in dose ID", async () => {
|
||||
// Dose ID with URL-unsafe characters (edge case)
|
||||
const doseId = "5-0-1735344000000-Max Müller";
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// Can retrieve it
|
||||
const getResponse = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: "/doses/taken",
|
||||
});
|
||||
|
||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dismiss Doses Tests (POST /doses/dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /doses/dismiss", () => {
|
||||
it("should dismiss multiple doses", async () => {
|
||||
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
it("dismisses multiple doses", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds },
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1",
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows.length).toBe(2);
|
||||
expect(Number(result.rows[0].count)).toBe(2);
|
||||
});
|
||||
|
||||
it("should not double-count already dismissed doses", async () => {
|
||||
it("does not double-count already dismissed doses", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId, dismissed: true });
|
||||
|
||||
// Dismiss once
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
// Dismiss again
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
@@ -489,54 +414,71 @@ describe("Dose Tracking API", () => {
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||
});
|
||||
|
||||
it("should reject empty doseIds array", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should reject missing doseIds", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
|
||||
it("converts a taken dose into a dismissed one", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId, dismissed: false });
|
||||
|
||||
// First mark as taken
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Then dismiss it
|
||||
const response = await ctx.app.inject({
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||
|
||||
// Verify it's now dismissed
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]);
|
||||
});
|
||||
|
||||
it("rejects missing or empty doseIds", async () => {
|
||||
const emptyResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseIds: [] },
|
||||
});
|
||||
|
||||
expect(emptyResponse.statusCode).toBe(400);
|
||||
expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" });
|
||||
|
||||
const missingResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(missingResponse.statusCode).toBe(400);
|
||||
expect(missingResponse.json()).toEqual({ error: "Required" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /doses/dismiss", () => {
|
||||
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
||||
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
||||
await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/doses/dismiss",
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, clearedCount: 2 });
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
|
||||
args: [userId],
|
||||
});
|
||||
expect(rows.rows).toEqual([
|
||||
expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,32 +11,32 @@ const EnvSchema = z.object({
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("3000"),
|
||||
.default(3000),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default(false),
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default(false),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
.default(15),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
.default(7),
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default(false),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -45,7 +45,7 @@ const EnvSchema = z.object({
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
.default(true),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
|
||||
@@ -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,283 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { db } from "../db/client.js";
|
||||
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectWhere<T>(result: T) {
|
||||
return {
|
||||
from: () => ({
|
||||
where: async () => result,
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
const mockedDb = vi.mocked(db);
|
||||
let originalTz: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
|
||||
originalTz = process.env.TZ;
|
||||
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
if (originalTz === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-marks due intakes in automatic mode even when all intake reminder channels are disabled", async () => {
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
insertedRows.push(row);
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrIntakeReminders: false,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(insertedRows[0]).toMatchObject({
|
||||
userId: 11,
|
||||
doseId: `7-0-${new Date(2026, 0, 5).getTime()}`,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-mark completed for userId=11: inserted=1");
|
||||
});
|
||||
|
||||
it("does not auto-mark due intakes when current stock is empty", async () => {
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
insertedRows.push(row);
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrIntakeReminders: false,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||
});
|
||||
|
||||
it("suppresses intake notifications entirely when automatic mode and skip-taken reminders are both enabled", async () => {
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
insertedRows.push(row);
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
skipRemindersForTakenDoses: true,
|
||||
emailEnabled: true,
|
||||
notificationEmail: "user@example.com",
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrIntakeReminders: false,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Sending reminder for 1 intakes...");
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
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
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
@@ -76,7 +77,12 @@ async function createSchema(client: Client) {
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
medication_form text NOT NULL DEFAULT 'tablet',
|
||||
pill_form text,
|
||||
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||
package_type text NOT NULL DEFAULT 'blister',
|
||||
package_amount_value integer NOT NULL DEFAULT 0,
|
||||
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
@@ -95,6 +101,8 @@ async function createSchema(client: Client) {
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
medication_end_date text,
|
||||
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
@@ -109,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,
|
||||
@@ -132,6 +141,7 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
share_medication_overview integer NOT NULL DEFAULT 0,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
@@ -196,10 +206,10 @@ describe("Integration Tests", () => {
|
||||
beforeAll(async () => {
|
||||
await createSchema(testClient);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
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 },
|
||||
});
|
||||
@@ -246,6 +256,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
@@ -299,6 +312,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
@@ -337,6 +353,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
||||
@@ -398,6 +417,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Weekly Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
|
||||
},
|
||||
});
|
||||
@@ -535,6 +557,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Interval Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
|
||||
},
|
||||
});
|
||||
@@ -589,6 +614,9 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
@@ -915,17 +943,17 @@ describe("Integration Tests", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Planner usage calculation", () => {
|
||||
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
|
||||
const futureDailyStart = "2030-01-15T08:00:00.000Z";
|
||||
const futureEveningStart = "2030-01-15T20:00:00.000Z";
|
||||
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
|
||||
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
|
||||
|
||||
it("should calculate correct usage for daily medication", async () => {
|
||||
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow (future date)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
// This avoids daylight-saving-time edge cases in local test environments.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -945,8 +973,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr, // 10 days
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -961,15 +989,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should detect insufficient stock", async () => {
|
||||
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -989,8 +1010,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1002,15 +1023,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate weekly medication usage correctly", async () => {
|
||||
// Create medication: 10 pills total
|
||||
// Schedule: 1 pill every 7 days starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1029,8 +1043,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: thirtyFiveDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1043,18 +1057,8 @@ describe("Integration Tests", () => {
|
||||
it("should handle multiple intake schedules per medication", async () => {
|
||||
// Create medication with morning and evening doses
|
||||
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const morningStart = tomorrow.toISOString();
|
||||
|
||||
const eveningStart = new Date(tomorrow);
|
||||
eveningStart.setHours(20, 0, 0, 0);
|
||||
const eveningStartStr = eveningStart.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const morningStart = futureDailyStart;
|
||||
const eveningStartStr = futureEveningStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1076,8 +1080,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: morningStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1089,14 +1093,7 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate correct blisters needed", async () => {
|
||||
// 10 pills per blister, need 25 pills → need 3 blisters
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1115,8 +1112,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { fetchMock, requireAuthMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
requireAuthMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function createEmaRow(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
category: "Human",
|
||||
medicine_status: "Authorised",
|
||||
name_of_medicine: "Aspirin 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetylsalicylic acid",
|
||||
active_substance: "Acetylsalicylic acid",
|
||||
marketing_authorisation_developer_applicant_holder: "Bayer",
|
||||
therapeutic_area_mesh: "Pain",
|
||||
therapeutic_indication: "Pain relief",
|
||||
atc_code_human: "N02BA01",
|
||||
generic_or_hybrid: "No",
|
||||
biosimilar: "No",
|
||||
marketing_authorisation_date: "01/02/2024",
|
||||
ema_product_number: "EMA-ASPIRIN",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js");
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("medication enrichment", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
requireAuthMock.mockReset();
|
||||
requireAuthMock.mockImplementation(async () => {});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("normalizes German ingredient queries for EMA-backed search results", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
createEmaRow({
|
||||
name_of_medicine: "Ibuprofen 400 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Ibuprofen",
|
||||
active_substance: "Ibuprofen",
|
||||
ema_product_number: "EMA-IBUPROFEN",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5);
|
||||
|
||||
expect(response.normalizedQuery).toBe("paracetamol 500 mg");
|
||||
expect(response.results).toHaveLength(1);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "EMA-TYLENOL",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
matchType: "ingredient",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires auth and returns EMA search results from the route", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=aspirin&limit=1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requireAuthMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.json()).toMatchObject({
|
||||
query: "aspirin",
|
||||
normalizedQuery: "aspirin",
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
source: "ema",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=semaglutide")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "12345",
|
||||
name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]",
|
||||
synonym: "Wegovy 0.25 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Semaglutide", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "12345",
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "00011-1111",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "openfda",
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(response.results.find((result) => result.code === "00011-1111")?.packageOptions).toEqual([
|
||||
{
|
||||
label: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||
description: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: 20,
|
||||
looseTablets: 0,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes results with package sizes before source-only matches", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "1191",
|
||||
name: "Aspirin 500 MG Oral Tablet [Aspirin]",
|
||||
synonym: "Aspirin 500 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Bayer Aspirin",
|
||||
generic_name: "Acetylsalicylic acid",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Aspirin", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toHaveLength(3);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
expect(response.results[1]).toMatchObject({
|
||||
code: "1191",
|
||||
source: "rxnorm",
|
||||
});
|
||||
expect(response.results[2]).toMatchObject({
|
||||
code: "EMA-ASPIRIN",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts richer package hits ahead of package-bearing results with fewer options", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ibuprofen Max",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "60 tablets in 1 bottle" }, { description: "120 tablets in 1 bottle" }],
|
||||
},
|
||||
{
|
||||
product_ndc: "00022-2222",
|
||||
brand_name: "Ibuprofen Compact",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "20 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Ibuprofen", 3);
|
||||
|
||||
expect(response.results.slice(0, 2)).toMatchObject([
|
||||
{
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
},
|
||||
{
|
||||
code: "00022-2222",
|
||||
source: "openfda",
|
||||
},
|
||||
]);
|
||||
expect(response.results[0].packageOptions).toHaveLength(2);
|
||||
expect(response.results[1].packageOptions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("validates malformed search requests", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns enrichment suggestions with optional RxNorm strength data", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Acetaminophen 500 MG Oral Tablet" },
|
||||
{ name: "Acetaminophen 650 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
selection: {
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
source: "ema+rxnorm",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" },
|
||||
{ label: "650 mg", pillWeightMg: 650, doseUnit: "mg" },
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: false,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("includes package suggestions from openFDA fallback in route responses", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||
return Promise.resolve(jsonResponse({ idGroup: {} }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Tylenol",
|
||||
generic_name: "Acetaminophen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Acetaminophen", strength: "500 mg" }],
|
||||
packaging: [{ description: "30 tablets in 1 bottle" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
selection: {
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
source: "ema+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||
packageOptions: [
|
||||
{
|
||||
label: "30 tablets in 1 bottle",
|
||||
description: "30 tablets in 1 bottle",
|
||||
packageType: "bottle",
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 30,
|
||||
looseTablets: 30,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||
return Promise.reject(new Error("rxnorm timeout"));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
});
|
||||
|
||||
expect(response.selection.source).toBe("ema");
|
||||
expect(response.suggestions.strengthOptions).toEqual([]);
|
||||
expect(response.meta).toEqual({
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: false,
|
||||
partial: true,
|
||||
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/rxcui/12345/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Ozempic",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
code: "12345",
|
||||
source: "rxnorm",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("search=product_ndc%3A%2200011-1111%22")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "US Ibuprofen",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||
packaging: [{ description: "100 mL in 1 bottle" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=ibuprofen&search=2")) {
|
||||
return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } }));
|
||||
}
|
||||
if (url.includes("/rxcui/161/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Ibuprofen 200 MG Oral Tablet" },
|
||||
{ name: "Ibuprofen 400 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "US Ibuprofen",
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||
],
|
||||
packageOptions: [
|
||||
{
|
||||
label: "100 mL in 1 bottle",
|
||||
description: "100 mL in 1 bottle",
|
||||
packageType: "liquid_container",
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 100,
|
||||
looseTablets: 100,
|
||||
packageAmountValue: 100,
|
||||
packageAmountUnit: "ml",
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not found when an explicit selection cannot be resolved", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Unknown",
|
||||
name: "Completely Different Medication",
|
||||
genericName: "No match",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toMatchObject({
|
||||
code: "MEDICATION_ENRICHMENT_NOT_FOUND",
|
||||
error: "Selected medication could not be resolved.",
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
type OidcMocks = {
|
||||
discovery: ReturnType<typeof vi.fn>;
|
||||
@@ -54,7 +55,7 @@ async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
||||
|
||||
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret-12345",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Client } from "@libsql/client";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// Create test database and mocks before anything else (hoisted)
|
||||
const {
|
||||
@@ -93,7 +94,12 @@ async function createSchema(client: Client) {
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
medication_form text NOT NULL DEFAULT 'tablet',
|
||||
pill_form text,
|
||||
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||
package_type text NOT NULL DEFAULT 'blister',
|
||||
package_amount_value integer NOT NULL DEFAULT 0,
|
||||
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
@@ -112,6 +118,8 @@ async function createSchema(client: Client) {
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
medication_end_date text,
|
||||
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
@@ -126,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,
|
||||
@@ -149,6 +158,7 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
share_medication_overview integer NOT NULL DEFAULT 0,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
@@ -207,7 +217,7 @@ describe("Planner Routes", () => {
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
@@ -284,7 +294,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -330,7 +340,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -434,7 +444,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -522,7 +532,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -697,7 +707,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -727,7 +737,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -763,7 +773,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -849,7 +859,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -982,7 +992,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1036,6 +1046,36 @@ describe("Planner Routes", () => {
|
||||
expect(title).not.toContain("Low");
|
||||
expect(message).toContain("Running critically low");
|
||||
});
|
||||
|
||||
it("should return 400 when only tube medications are in active meds", async () => {
|
||||
// Insert a tube medication (should be excluded from reminders)
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type)
|
||||
VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Expects 400 because tube medications are excluded from stock reminders
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "No active medications to notify" });
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /reminder/send-prescription", () => {
|
||||
@@ -1082,7 +1122,7 @@ describe("Planner Routes", () => {
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
|
||||
* Tests adding refills to medication stock and retrieving refill history.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
clearTestData,
|
||||
closeTestApp,
|
||||
createTestMedication,
|
||||
createTestUser,
|
||||
type TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// Store userId at module level so routes can access it
|
||||
let currentUserId = 1;
|
||||
|
||||
// =============================================================================
|
||||
// Route Registration
|
||||
// =============================================================================
|
||||
|
||||
async function registerRefillRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
|
||||
// POST /medications/:id/refill - Add stock and record history
|
||||
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
||||
"/medications/:id/refill",
|
||||
async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
||||
|
||||
// Validate input
|
||||
if (packsAdded < 0 || loosePillsAdded < 0) {
|
||||
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
||||
}
|
||||
if (packsAdded === 0 && loosePillsAdded === 0) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
||||
}
|
||||
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
||||
FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
const med = medResult.rows[0];
|
||||
const newPackCount = (med.pack_count as number) + packsAdded;
|
||||
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
||||
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
||||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||||
|
||||
// Update medication stock
|
||||
await client.execute({
|
||||
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
||||
args: [newPackCount, newLooseTablets, medId],
|
||||
});
|
||||
|
||||
// Record refill history
|
||||
await client.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
args: [medId, userId, packsAdded, loosePillsAdded],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pillsAdded: totalPillsAdded,
|
||||
newPackCount,
|
||||
newLooseTablets,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /medications/:id/refills - Get refill history
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
// Get refill history, newest first
|
||||
const refillResult = await client.execute({
|
||||
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
||||
FROM refill_history
|
||||
WHERE medication_id = ? AND user_id = ?
|
||||
ORDER BY refill_date DESC`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
return {
|
||||
refills: refillResult.rows.map((r) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packs_added,
|
||||
loosePillsAdded: r.loose_pills_added,
|
||||
refillDate: r.refill_date,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("Refill API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
let medId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerRefillRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Create test user
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
// Update the module-level userId so routes use the correct one
|
||||
currentUserId = userId;
|
||||
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
||||
medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /medications/:id/refill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /medications/:id/refill", () => {
|
||||
it("should add packs to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
||||
expect(data.newPackCount).toBe(3); // 1 + 2
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].pack_count).toBe(3);
|
||||
});
|
||||
|
||||
it("should add loose pills to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { loosePillsAdded: 15 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(15);
|
||||
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].loose_tablets).toBe(20);
|
||||
});
|
||||
|
||||
it("should add both packs and loose pills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
||||
expect(data.newPackCount).toBe(2);
|
||||
expect(data.newLooseTablets).toBe(15);
|
||||
});
|
||||
|
||||
it("should record refill in history", async () => {
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
||||
});
|
||||
|
||||
// Check history
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].packs_added).toBe(2);
|
||||
expect(result.rows[0].loose_pills_added).toBe(5);
|
||||
});
|
||||
|
||||
it("should reject refill with zero amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("At least one");
|
||||
});
|
||||
|
||||
it("should reject refill with negative amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: -1 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("non-negative");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/99999/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /medications/:id/refills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /medications/:id/refills", () => {
|
||||
it("should return empty array when no refills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ refills: [] });
|
||||
});
|
||||
|
||||
it("should return refill history newest first", async () => {
|
||||
// Add two refills with different values so we can identify them
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
});
|
||||
|
||||
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.refills).toHaveLength(2);
|
||||
|
||||
// Newest first (loose pills - added second)
|
||||
expect(data.refills[0].packsAdded).toBe(0);
|
||||
expect(data.refills[0].loosePillsAdded).toBe(20);
|
||||
|
||||
// Older (packs - added first)
|
||||
expect(data.refills[1].packsAdded).toBe(1);
|
||||
expect(data.refills[1].loosePillsAdded).toBe(0);
|
||||
|
||||
// Each entry should have an id and refillDate
|
||||
for (const refill of data.refills) {
|
||||
expect(refill.id).toBeTypeOf("number");
|
||||
expect(refill.refillDate).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/99999/refills`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cascade Delete Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Cascade Delete", () => {
|
||||
it("should delete refill history when medication is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
// Delete medication
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it("should delete refill history when user is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
// Delete user
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM users WHERE id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
@@ -15,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,
|
||||
@@ -45,7 +48,9 @@ vi.mock("nodemailer", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
|
||||
const { settingsRoutes, sendShoutrrrNotification, loadUserSettings, getAllUserSettings } = await import(
|
||||
"../routes/settings.js"
|
||||
);
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
const { reportRoutes } = await import("../routes/report.js");
|
||||
|
||||
@@ -106,7 +111,7 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(reportRoutes);
|
||||
@@ -137,11 +142,76 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.language).toBe("en");
|
||||
expect(body.shareStockStatus).toBe(true);
|
||||
expect(body.upcomingTodayOnly).toBe(false);
|
||||
expect(body.shareScheduleTodayOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /settings returns a non-empty serialized payload with SMTP fields", async () => {
|
||||
process.env.SMTP_HOST = "smtp.example.com";
|
||||
process.env.SMTP_PORT = "2525";
|
||||
process.env.SMTP_USER = "mailer@example.com";
|
||||
process.env.SMTP_FROM = "MedAssist <mailer@example.com>";
|
||||
process.env.SMTP_PASS = "secret";
|
||||
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: true,
|
||||
notificationEmail: "person@example.com",
|
||||
reminderDaysBefore: 5,
|
||||
repeatDailyReminders: true,
|
||||
lowStockDays: 14,
|
||||
normalStockDays: 45,
|
||||
highStockDays: 90,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 20,
|
||||
maxNaggingReminders: 4,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
upcomingTodayOnly: true,
|
||||
shareScheduleTodayOnly: true,
|
||||
swapDashboardMainSections: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).not.toBe("{}");
|
||||
|
||||
const body = response.json();
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
emailEnabled: true,
|
||||
notificationEmail: "person@example.com",
|
||||
reminderDaysBefore: 5,
|
||||
repeatDailyReminders: true,
|
||||
repeatRemindersEnabled: true,
|
||||
reminderRepeatIntervalMinutes: 20,
|
||||
maxNaggingReminders: 4,
|
||||
stockCalculationMode: "manual",
|
||||
upcomingTodayOnly: true,
|
||||
shareScheduleTodayOnly: true,
|
||||
swapDashboardMainSections: true,
|
||||
smtpHost: "smtp.example.com",
|
||||
smtpPort: 2525,
|
||||
smtpUser: "mailer@example.com",
|
||||
smtpFrom: "MedAssist <mailer@example.com>",
|
||||
hasSmtpPassword: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -168,7 +238,6 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
maxNaggingReminders: 5,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
@@ -190,7 +259,30 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
payload: { language: "fr" },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid language");
|
||||
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
|
||||
});
|
||||
|
||||
it("PUT /settings/language creates and updates the stored language", async () => {
|
||||
let response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "de" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "en" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const stored = await testClient.execute({
|
||||
sql: "SELECT language FROM user_settings WHERE user_id = 1",
|
||||
});
|
||||
expect(stored.rows[0].language).toBe("en");
|
||||
});
|
||||
|
||||
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
||||
@@ -207,7 +299,12 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
process.env.SMTP_HOST = "smtp.example.com";
|
||||
process.env.SMTP_USER = "mailer@example.com";
|
||||
process.env.SMTP_TOKEN = "secret";
|
||||
nodemailerSendMail.mockResolvedValue(undefined);
|
||||
nodemailerSendMail.mockResolvedValue({
|
||||
accepted: ["person@example.com"],
|
||||
rejected: [],
|
||||
response: "250 2.0.0 OK",
|
||||
messageId: "test-message-id",
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -219,6 +316,22 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST /settings/test-email maps generic transport failures to HTTP 500", async () => {
|
||||
process.env.SMTP_HOST = "smtp.example.com";
|
||||
process.env.SMTP_USER = "mailer@example.com";
|
||||
process.env.SMTP_PASS = "secret";
|
||||
nodemailerSendMail.mockRejectedValue(new Error("socket hang up"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-email",
|
||||
payload: { email: "person@example.com" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json()).toMatchObject({ code: "TEST_EMAIL_FAILED" });
|
||||
});
|
||||
|
||||
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -228,6 +341,68 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /settings/test-shoutrrr returns 500 when notification delivery fails", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-shoutrrr",
|
||||
payload: { url: "ftp://invalid.example.com/topic" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toMatch(/Only HTTP\/HTTPS protocols are allowed|Unsupported URL format/);
|
||||
});
|
||||
|
||||
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-shoutrrr",
|
||||
payload: { url: "ntfy://ntfy.sh/medassist" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
||||
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: false,
|
||||
},
|
||||
{
|
||||
action: "http",
|
||||
label: "Skip",
|
||||
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||
method: "POST",
|
||||
clear: false,
|
||||
},
|
||||
{
|
||||
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 () => {
|
||||
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
||||
expect(result.success).toBe(false);
|
||||
@@ -235,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({
|
||||
@@ -261,6 +437,166 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification returns HTTP response errors for ntfy-style endpoints", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("rate limited") });
|
||||
|
||||
const result = await sendShoutrrrNotification("https://ntfy.sh/medassist", "Title", "Body");
|
||||
|
||||
expect(result).toEqual({ success: false, error: "HTTP 429: rate limited" });
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification rejects invalid Discord webhook identifiers", async () => {
|
||||
const result = await sendShoutrrrNotification("discord://bad-token@not-a-number", "Title", "Body");
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Invalid Discord webhook ID" });
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification validates Pushover URL credentials", async () => {
|
||||
const result = await sendShoutrrrNotification("pushover://missing-token", "Title", "Body");
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Invalid Pushover URL format" });
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification requires Telegram chats and validates tokens", async () => {
|
||||
let result = await sendShoutrrrNotification("telegram://123:abc@telegram", "Title", "Body");
|
||||
expect(result).toEqual({ success: false, error: "Telegram URL requires chats parameter" });
|
||||
|
||||
result = await sendShoutrrrNotification("telegram://invalid@telegram?chats=123", "Title", "Body");
|
||||
expect(result).toEqual({ success: false, error: "Invalid Telegram token format" });
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification converts Gotify URLs and supports disabletls", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await sendShoutrrrNotification(
|
||||
"gotify://push.example.com/basepath/token123?disabletls=yes&priority=8",
|
||||
"Title",
|
||||
"Body"
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0];
|
||||
expect(targetUrl).toBe("http://push.example.com/basepath/message?token=token123");
|
||||
expect(requestInit.body).toBe("Body\n\n(priority=8)");
|
||||
expect(requestInit.headers).toMatchObject({ Tags: "pill" });
|
||||
});
|
||||
|
||||
it("loadUserSettings creates defaults for users without settings", async () => {
|
||||
const settings = await loadUserSettings(1);
|
||||
|
||||
expect(settings).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: 1,
|
||||
emailEnabled: false,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("loadUserSettings maps persisted settings", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||
stock_calculation_mode, share_stock_status, skip_reminders_for_taken_doses,
|
||||
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||
upcoming_today_only, share_schedule_today_only, swap_dashboard_main_sections
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
1,
|
||||
1,
|
||||
"person@example.com",
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
12,
|
||||
30,
|
||||
90,
|
||||
"de",
|
||||
"manual",
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
30,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
});
|
||||
|
||||
const settings = await loadUserSettings(1);
|
||||
|
||||
expect(settings).toEqual(
|
||||
expect.objectContaining({
|
||||
notificationEmail: "person@example.com",
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
stockCalculationMode: "manual",
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("getAllUserSettings returns mapped entries for each persisted user", async () => {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [2, "second-user", "local"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
|
||||
swap_dashboard_main_sections
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [1, 0, null, 1, 1, 1, 1, "ntfy://ntfy.sh/topic", 1, 1, 1, 7, 1, 30, 60, 120, "en", "manual", 1, 1, 0, 1],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (
|
||||
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
|
||||
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
|
||||
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
|
||||
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
|
||||
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
|
||||
swap_dashboard_main_sections
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [2, 1, "second@example.com", 0, 1, 1, 0, null, 1, 1, 1, 10, 0, 20, 50, 100, "de", "automatic", 1, 0, 0, 0],
|
||||
});
|
||||
|
||||
const allSettings = await getAllUserSettings();
|
||||
|
||||
expect(allSettings).toHaveLength(2);
|
||||
expect(allSettings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ userId: 1, stockCalculationMode: "manual", upcomingTodayOnly: true }),
|
||||
expect.objectContaining({
|
||||
userId: 2,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
||||
await seedMedication("Owned Med");
|
||||
const response = await app.inject({
|
||||
@@ -294,8 +630,35 @@ 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].quantityAdded).toBe(22);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -326,7 +689,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);
|
||||
});
|
||||
|
||||
@@ -377,7 +742,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,
|
||||
@@ -413,10 +786,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// Import from utils to avoid index.ts import side effects (server start)
|
||||
import {
|
||||
@@ -197,6 +198,7 @@ describe("Server Bootstrap", () => {
|
||||
logger: {
|
||||
level: "silent", // Disable logging for tests
|
||||
},
|
||||
ajv: documentationSchemaAjv,
|
||||
});
|
||||
|
||||
expect(app).toBeDefined();
|
||||
@@ -206,7 +208,7 @@ describe("Server Bootstrap", () => {
|
||||
});
|
||||
|
||||
it("should register sensible plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
|
||||
// Sensible adds error helpers
|
||||
@@ -219,7 +221,7 @@ describe("Server Bootstrap", () => {
|
||||
it("should register cors plugin with multiple origins", async () => {
|
||||
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
|
||||
// Add a test route
|
||||
@@ -243,7 +245,7 @@ describe("Server Bootstrap", () => {
|
||||
});
|
||||
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
// Add a test route that sets a cookie
|
||||
@@ -267,7 +269,7 @@ describe("Server Bootstrap", () => {
|
||||
|
||||
describe("Config Decorator", () => {
|
||||
it("should create config with auth settings", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 7;
|
||||
@@ -369,7 +371,7 @@ describe("Server Bootstrap", () => {
|
||||
|
||||
describe("Route Registration", () => {
|
||||
it("should register multiple route plugins", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: FastifyInstance) => {
|
||||
@@ -402,7 +404,7 @@ describe("Server Bootstrap", () => {
|
||||
|
||||
describe("Server Startup", () => {
|
||||
it("should listen on specified port", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
@@ -415,7 +417,7 @@ describe("Server Bootstrap", () => {
|
||||
});
|
||||
|
||||
it("should handle listen errors gracefully", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
|
||||
// Try to listen on an invalid port
|
||||
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user