Compare commits
317 Commits
v1.11.0
...
b5c68ba809
| Author | SHA1 | Date | |
|---|---|---|---|
| b5c68ba809 | |||
| 5a4a72e1f4 | |||
| 47d230ace2 | |||
| 812b14df03 | |||
| c78fc43083 | |||
| e4a1b449c6 | |||
| 767ae23843 | |||
| 3eb56885f9 | |||
| c5b08b28c1 | |||
| 1eb7579706 | |||
| e69e46f9fc | |||
| 1f5dd36b5c | |||
| 545793fdd2 | |||
| 2f5fc2d9e9 | |||
| 4212469cd5 | |||
| db602d8360 | |||
| a95c6e3657 | |||
| 827d1adc35 | |||
| f8e4b0faaf | |||
| c5c75f65e4 | |||
| 26e9b39f47 | |||
| 7362683c2b | |||
| 1a7b82d728 | |||
| 92f6995b1a | |||
| 6daafcc0dd | |||
| 795aa59acb | |||
| 1514483cf1 | |||
| 554e2ba5ae | |||
| 4ae845d412 | |||
| 59519ae214 | |||
| 328f732066 | |||
| ec478f7601 | |||
| c25076e83b | |||
| b4dc1074e8 | |||
| aa2313427a | |||
| 6a31019fdc | |||
| ae5d6cc3e8 | |||
| 5060d135ba | |||
| 4019716b9b | |||
| 7df17ef705 | |||
| b3c46ea179 | |||
| 255746d9f5 | |||
| d99bc3d99e | |||
| f265d090c6 | |||
| 8473ed8387 | |||
| c38964cd70 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c | |||
| 7554a79898 | |||
| 70f2392a71 | |||
| ba789f9794 | |||
| 277fc3e686 | |||
| b838f0e8ea | |||
| 0b888cf00a | |||
| dbc722a898 | |||
| 15a44d4f55 | |||
| 4de138015d | |||
| 3bb8b93a4c | |||
| 3af8a5a704 | |||
| f301f24182 | |||
| 6dc1e68392 | |||
| e4b1630922 | |||
| c7be73786b | |||
| cdfb19bde2 | |||
| f7da65e7a1 | |||
| 27e42c0935 | |||
| 67ad693b31 | |||
| ab3facc47a | |||
| ce02b4211a | |||
| 40bd7ba3b7 | |||
| 826d85937c | |||
| 6d98a049bc | |||
| 435ca5f1d6 | |||
| ecf9cfb539 | |||
| dafa5abab4 | |||
| cc5141c997 | |||
| 22725fa566 | |||
| a5fe76545e | |||
| 527f4251e5 | |||
| 5064de3bff | |||
| 40d6f33676 | |||
| 0dab318b66 | |||
| 932524125e | |||
| c291c88f2b | |||
| e42e4f5639 | |||
| b70fc88921 | |||
| 95aec8350a | |||
| 401228699f | |||
| 0d2b21199e | |||
| d5b3c5c21f | |||
| 002f16c505 | |||
| aa050f7dc5 | |||
| 0795bfe589 | |||
| 25483c12f0 | |||
| 2a340855fb | |||
| 52fec1a4e5 | |||
| 1cb4a44cef | |||
| 51b09dc563 | |||
| dbbd9d5ed8 | |||
| 15f1e33aa4 | |||
| 5161949578 | |||
| d721bab01a | |||
| eec1653ff4 | |||
| 6bba006e64 | |||
| 59ffb55dfd | |||
| ad48ab6ba7 | |||
| f4a5f5112a | |||
| 98062358be | |||
| 4132ba486d | |||
| 0faad5d28b | |||
| 218b9056fa | |||
| a7bd353f75 | |||
| bd2bfe6972 | |||
| 8a9b44ef31 | |||
| 026091c5ca | |||
| 08f75e44ff | |||
| 5e3a10a93c | |||
| 7f2ef09df5 | |||
| f46043970f | |||
| b58c4fe5bb | |||
| 73a235dd83 | |||
| ce184a6c56 | |||
| 675cb88f3e | |||
| 4b8fa10b39 | |||
| c39b5c9501 | |||
| a1c7e0e62c | |||
| 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 | |||
| 259f00e7a0 | |||
| e9f2760815 | |||
| d0e2ee0783 | |||
| c620146c4b | |||
| 33c1095e77 | |||
| 5d657558f7 | |||
| 0c28999c89 | |||
| 2296303236 | |||
| 9a2d42b8b9 | |||
| 088a6c1a05 | |||
| 228fd4cd7e | |||
| e346d60f39 | |||
| afb8e5028c | |||
| 9ab077a037 | |||
| 976d7356ec | |||
| 943148fb49 | |||
| 94bd8bd6e8 | |||
| 0cf1c5353e | |||
| 98cf1ce1d2 | |||
| 75c201cab5 | |||
| 74f079d13e | |||
| fd3b770a81 | |||
| 612aa007aa | |||
| 02af93ec55 | |||
| 8f57aa8bc9 | |||
| f42ed87d94 | |||
| 8de54b9065 | |||
| b489e1e117 | |||
| 8c97abd3c9 | |||
| 2eec415af6 | |||
| 243a46f960 | |||
| 052751b2ba | |||
| 89d565bc9d | |||
| 08a18fc14a | |||
| e41efdf98b | |||
| cefac8cc4e | |||
| 779870960c | |||
| 871e6066ec | |||
| ff100dfea5 | |||
| 47581ca7ad | |||
| 39e9ebbf28 | |||
| 41b20bb4e6 | |||
| f9c51956d5 | |||
| 543b42b540 | |||
| 36a2f7d537 | |||
| 4b697374f6 | |||
| c47a35d642 | |||
| d8d8c4a07e | |||
| 3f041f26aa | |||
| 1e043c8bf3 |
+32
-4
@@ -10,15 +10,36 @@ PUID=1000
|
||||
PGID=1000
|
||||
|
||||
PORT=3000
|
||||
# Docker Compose quickstart serves the frontend on http://localhost:4174.
|
||||
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
|
||||
# Server default timezone for scheduled reminders.
|
||||
# Users can override this in Settings -> Timezone.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Public base URL used for notification action links.
|
||||
# Required for intake reminder action buttons.
|
||||
# Use an externally reachable HTTPS URL for remote/self-hosted access.
|
||||
# PUBLIC_APP_URL=https://medassist.example.com
|
||||
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
||||
# Local Vite development automatically allows this hostname; set
|
||||
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
|
||||
|
||||
# Log level: debug, info, warn, error, silent
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Rate limit: max requests per minute per IP (default: 100)
|
||||
# Increase for development/testing environments
|
||||
# RATE_LIMIT_MAX=100
|
||||
|
||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||
TZ=Europe/Berlin
|
||||
# API documentation UI + OpenAPI JSON
|
||||
# Docs are served on /docs and /docs/json.
|
||||
# Default behavior: enabled outside production, disabled in production.
|
||||
# Recommended:
|
||||
# development, staging: OPENAPI_DOCS_ENABLED=true
|
||||
# production: leave unset or set OPENAPI_DOCS_ENABLED=false
|
||||
# OPENAPI_DOCS_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# Authentication (optional - disabled by default for easy setup)
|
||||
@@ -29,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=
|
||||
@@ -99,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)
|
||||
# Push notifications (Shoutrrr URL)
|
||||
# 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
|
||||
@@ -123,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_MEDICATION_OVERVIEW=false # Show medication overview on shared schedule links
|
||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Recommended for better reporting relevancy
|
||||
- name: SonarQube Scan
|
||||
uses: kitabisa/sonarqube-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.SONARQUBE_HOST }}
|
||||
login: ${{ secrets.SONARQUBE_TOKEN }}
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -89,14 +110,42 @@ PR #141: "fix: planner checkbox layout on single line"
|
||||
|
||||
---
|
||||
|
||||
## PR Metadata (MANDATORY)
|
||||
|
||||
Every Pull Request MUST have the following sidebar fields populated at creation time:
|
||||
|
||||
| Field | Value | How |
|
||||
|-------|-------|-----|
|
||||
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
|
||||
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
|
||||
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
|
||||
|
||||
**Label mapping for PRs:**
|
||||
| Branch prefix / commit type | Label |
|
||||
|---|---|
|
||||
| `feat/` | `enhancement` |
|
||||
| `fix/` | `bug` |
|
||||
| `docs/` | `documentation` |
|
||||
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
|
||||
| `chore/release-*` | No label needed (release PRs are automated) |
|
||||
|
||||
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Branch, PR, and Merge Workflow
|
||||
|
||||
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
|
||||
|
||||
@@ -104,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"
|
||||
@@ -117,32 +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, linking the related issue:
|
||||
```bash
|
||||
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
|
||||
|
||||
Description of changes"
|
||||
```
|
||||
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
|
||||
3. **Present the PR URL to the user and wait for confirmation.**
|
||||
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.
|
||||
- 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
|
||||
@@ -196,26 +245,9 @@ Apply these rules strictly:
|
||||
|
||||
## Task 3: Execute Release
|
||||
|
||||
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
|
||||
Use the manual release flow. The repository no longer uses a public release helper script.
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major|x.y.z>
|
||||
```
|
||||
|
||||
The script performs these steps in order:
|
||||
1. Checks out and updates `main`
|
||||
2. Creates release branch `chore/release-X.Y.Z`
|
||||
3. Bumps version in `backend/package.json` and `frontend/package.json`
|
||||
4. Commits, pushes, and creates a PR
|
||||
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
|
||||
6. Merges the PR (squash + delete branch)
|
||||
7. Creates a signed tag `vX.Y.Z` and pushes it
|
||||
|
||||
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
|
||||
|
||||
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
|
||||
|
||||
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
|
||||
**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.
|
||||
|
||||
### Version Files (MANDATORY)
|
||||
|
||||
@@ -228,7 +260,7 @@ The version number is displayed in the **About modal** (Settings → About) as a
|
||||
- The About modal will show the old version
|
||||
- The version link will point to a non-existent GitHub release page
|
||||
|
||||
### Manual Release (if script is not available)
|
||||
### Manual Release
|
||||
|
||||
1. Create release branch:
|
||||
```bash
|
||||
@@ -355,11 +387,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)
|
||||
@@ -408,30 +447,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 |
|
||||
@@ -453,6 +479,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
|
||||
@@ -462,7 +494,7 @@ Code complete & validated by testing-manager
|
||||
↓
|
||||
1. Ensure a GitHub issue exists (create if not)
|
||||
2. Create feature branch (fix/... or feat/...)
|
||||
3. Commit, push, create PR (with "Closes #N" in body)
|
||||
3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
|
||||
4. Wait for CI (all required checks)
|
||||
5. Merge PR to main (squash + delete branch)
|
||||
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
||||
@@ -472,8 +504,7 @@ Ready for release?
|
||||
7. Check current version (git tag + package.json)
|
||||
8. Analyze changes → determine SemVer level
|
||||
9. If minor/major: check README.md for needed updates (Task 5)
|
||||
10. Run ./scripts/release.sh <patch|minor|major>
|
||||
(or manually: branch → version bump → PR → CI → merge → tag)
|
||||
10. Run the manual release flow: branch → version bump → PR → CI → merge → tag
|
||||
↓
|
||||
11. Write release notes (mandatory for minor/major)
|
||||
12. Publish GitHub release
|
||||
|
||||
@@ -14,9 +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
|
||||
|
||||
@@ -26,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:
|
||||
|
||||
@@ -36,39 +46,83 @@ 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 && npm run test:e2e:ui
|
||||
cd frontend && npm run test:e2e:headed
|
||||
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
|
||||
```
|
||||
|
||||
## Backend Test Patterns
|
||||
@@ -77,13 +131,31 @@ cd frontend && npm run test:e2e:headed
|
||||
- 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
|
||||
|
||||
@@ -114,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,76 +1,13 @@
|
||||
# MedAssist-ng - AI Coding Instructions
|
||||
# MedAssist-ng - Copilot Entry Point
|
||||
|
||||
## Purpose
|
||||
This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
|
||||
|
||||
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
|
||||
If rules differ between files, follow `AGENTS.md`.
|
||||
|
||||
## Project Orientation (Read First)
|
||||
## Required Startup Steps
|
||||
|
||||
- **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`
|
||||
|
||||
Use this orientation for quick navigation before applying the rules below.
|
||||
|
||||
## Always-On Rules
|
||||
|
||||
- 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.
|
||||
- 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`
|
||||
1. Read `AGENTS.md` first when it exists in the workspace.
|
||||
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
|
||||
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
|
||||
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
|
||||
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
|
||||
|
||||
+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.
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v1.0.2
|
||||
- uses: actions/add-to-project@v2.0.0
|
||||
with:
|
||||
project-url: ${{ vars.PROJECT_URL }}
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
|
||||
@@ -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,29 @@ 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 older runs on the same ref so the shared branch tag stays aligned
|
||||
# with the newest commit instead of racing older builds against newer ones.
|
||||
cancel-in-progress: true
|
||||
|
||||
# Default minimal permissions
|
||||
permissions:
|
||||
@@ -48,10 +65,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 +76,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 +87,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 +100,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 +115,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 +153,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 +198,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@v7
|
||||
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']
|
||||
});
|
||||
+38
-4
@@ -67,18 +67,52 @@ Thumbs.db
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
/.vscode/settings.json
|
||||
|
||||
# Keep shared VS Code settings
|
||||
# .vscode/ is NOT ignored - settings.json is useful for the team
|
||||
# Keep shared VS Code workspace files, but ignore personal editor settings.
|
||||
|
||||
# ===================
|
||||
# Misc
|
||||
# Local-only workspace artifacts (never upstream)
|
||||
# ===================
|
||||
*.local
|
||||
.cache/
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
.claude/
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
doku
|
||||
/doku/
|
||||
/plan/
|
||||
/.planning/
|
||||
.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
|
||||
|
||||
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||
.github/agents/copilot-instructions.md
|
||||
.github/agents/gsd-*.agent.md
|
||||
.github/agents/medassist-feature-orchestrator.agent.md
|
||||
.github/agents/speckit.*.agent.md
|
||||
.github/get-shit-done/
|
||||
.github/gsd-file-manifest.json
|
||||
.github/prompts/speckit.*.prompt.md
|
||||
.github/skills/gsd-*/
|
||||
ops/medtest/
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- refreshed: 2026-04-30 -->
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## System Overview
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend SPA (React) │
|
||||
├──────────────────┬──────────────────┬───────────────────────┤
|
||||
│ App Shell/Routes │ Shared State │ Feature Pages │
|
||||
│ `frontend/src/ │ `frontend/src/ │ `frontend/src/pages/` │
|
||||
│ App.tsx` │ context/` │ │
|
||||
└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend API (Fastify) │
|
||||
│ `backend/src/index.ts` + `backend/src/routes/` │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SQLite Persistence + Migration Layer │
|
||||
│ `backend/src/db/schema.ts` + `backend/src/db/client.ts` │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Responsibility | File |
|
||||
|-----------|----------------|------|
|
||||
| Frontend bootstrap | Mount providers/router and start app tree | `frontend/src/main.tsx` |
|
||||
| App router/shell | Public share routes, authenticated shell routes, global modal composition | `frontend/src/App.tsx` |
|
||||
| Frontend orchestration | Compose domain hooks and expose app-level state/actions | `frontend/src/context/AppContext.tsx` |
|
||||
| API proxy boundary | Rewrite `/api/*` requests to backend root routes | `frontend/vite.config.ts` |
|
||||
| Backend composition root | Register plugins/routes, await migrations, start schedulers | `backend/src/index.ts` |
|
||||
| Route handlers | HTTP contracts, validation, auth hooks, response shaping | `backend/src/routes/*.ts` |
|
||||
| Domain services | Shared domain logic and scheduler behavior | `backend/src/services/*.ts` |
|
||||
| Persistence | Table definitions + compatibility migration/runtime initialization | `backend/src/db/schema.ts`, `backend/src/db/client.ts` |
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Layered modular monolith (single frontend SPA + single backend process)
|
||||
|
||||
**Key Characteristics:**
|
||||
- Frontend uses React Router + context/hook composition (`frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`).
|
||||
- Backend uses route modules with shared service modules (`backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||
- Data persistence is centralized in Drizzle schema + startup migrations (`backend/src/db/schema.ts`, `backend/src/db/client.ts`).
|
||||
|
||||
## Layers
|
||||
|
||||
**Frontend Presentation + Orchestration:**
|
||||
- Purpose: Render UI, route navigation, manage client state, invoke API.
|
||||
- Location: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/`, `frontend/src/context/`, `frontend/src/hooks/`.
|
||||
- Contains: pages, modals, app shell, hook-based API callers.
|
||||
- Depends on: backend `/api/*`, i18n, shared frontend utils/types.
|
||||
- Used by: browser clients.
|
||||
|
||||
**Backend HTTP/API Layer:**
|
||||
- Purpose: Expose REST endpoints, authenticate/authorize requests, validate input, map to service/db logic.
|
||||
- Location: `backend/src/index.ts`, `backend/src/routes/`, `backend/src/plugins/`.
|
||||
- Contains: Fastify app setup, route registration, auth middleware.
|
||||
- Depends on: services, db client/schema, env plugin.
|
||||
- Used by: frontend SPA and API consumers.
|
||||
|
||||
**Domain Services Layer:**
|
||||
- Purpose: Reusable business logic for scheduling, notifications, stock math, parsing.
|
||||
- Location: `backend/src/services/`, `backend/src/utils/`.
|
||||
- Contains: reminder scheduler, notification builders/delivery, medication helpers.
|
||||
- Depends on: db models and utilities.
|
||||
- Used by: routes and startup process.
|
||||
|
||||
**Persistence Layer:**
|
||||
- Purpose: Define DB schema and keep existing SQLite instances compatible.
|
||||
- Location: `backend/src/db/schema.ts`, `backend/src/db/client.ts`, `backend/drizzle/`.
|
||||
- Contains: tables, migration execution, backward-compatible alter migrations.
|
||||
- Depends on: Drizzle + libsql client.
|
||||
- Used by: routes/services.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Primary Request Path
|
||||
|
||||
1. Frontend page triggers API call via `/api/*` fetch (`frontend/src/pages/PlannerPage.tsx:307`).
|
||||
2. Vite proxy rewrites `/api` prefix to backend route root (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||
3. Fastify route handles request under `/planner/send-email` with auth + validation (`backend/src/routes/planner.ts:141`, `backend/src/routes/planner.ts:158`).
|
||||
4. Route loads user settings and dispatches channel delivery helpers (`backend/src/routes/planner.ts:221`, `backend/src/routes/planner.ts:432`, `backend/src/routes/planner.ts:829`).
|
||||
|
||||
### Public Share Flow
|
||||
|
||||
1. Frontend routes public token URL to shared schedule view (`frontend/src/App.tsx:35`).
|
||||
2. Shared schedule component fetches token payload from `/api/share/:token` (`frontend/src/components/SharedSchedule.tsx:311`).
|
||||
3. Backend public share route reads token/settings and returns filtered medication schedule (`backend/src/routes/share.ts:125`, `backend/src/routes/share.ts:156`).
|
||||
|
||||
**State Management:**
|
||||
- Frontend: context-centric state aggregation (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||
- Backend: DB-backed state with runtime scheduler state persisted through notification state utilities (`backend/src/services/reminder-scheduler.ts:42`).
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Auth Context + Guards:**
|
||||
- Purpose: unify session/API-key auth across protected routes.
|
||||
- Examples: `backend/src/plugins/auth.ts`, `backend/src/routes/settings.ts`.
|
||||
- Pattern: route-level `preHandler` guard plus request decoration (`backend/src/routes/settings.ts:138`, `backend/src/plugins/auth.ts:236`).
|
||||
|
||||
**Notification Delivery Contract:**
|
||||
- Purpose: keep route-triggered and scheduler-triggered notifications consistent.
|
||||
- Examples: `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/notifications/delivery.ts`.
|
||||
- Pattern: shared builders/delivery/state helpers imported into both paths (`backend/src/routes/planner.ts:23`, `backend/src/services/reminder-scheduler.ts:39`).
|
||||
|
||||
**Frontend App Context Aggregator:**
|
||||
- Purpose: centralize shared medication/settings/dose/share/refill state for page/modal consumers.
|
||||
- Examples: `frontend/src/context/AppContext.tsx`, `frontend/src/context/ShareContext.tsx`.
|
||||
- Pattern: compose domain hooks, expose typed value via provider (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Frontend bootstrap:**
|
||||
- Location: `frontend/src/main.tsx`
|
||||
- Triggers: browser loads `index.html`.
|
||||
- Responsibilities: initialize theme/provider stack and router (`frontend/src/main.tsx:12`, `frontend/src/main.tsx:15`).
|
||||
|
||||
**Backend process entry:**
|
||||
- Location: `backend/src/index.ts`
|
||||
- Triggers: `npm run dev`/`npm start` in backend package.
|
||||
- Responsibilities: await migrations, register routes, start HTTP listener and schedulers (`backend/src/index.ts:231`, `backend/src/index.ts:305`, `backend/src/index.ts:309`, `backend/src/index.ts:334`).
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
- **Threading:** Single Node.js event loop process with in-process schedulers started at runtime (`backend/src/index.ts:309`, `backend/src/index.ts:323`).
|
||||
- **Global state:** Module/global singletons exist in auth and context layers (`backend/src/plugins/auth.ts:15`, `frontend/src/context/AppContext.tsx:222`).
|
||||
- **Circular imports:** Not detected from sampled route/service/db/frontend orchestration files.
|
||||
- **API boundary:** Frontend network calls must use `/api/*` so proxy rewrite applies (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Duplicated Backend App Wiring
|
||||
|
||||
**What happens:** Route/plugin registration appears in both `createApp(...)` and top-level startup path.
|
||||
**Why it's wrong:** Two bootstrap paths increase divergence risk when new routes/plugins are added in one path but not the other.
|
||||
**Do this instead:** Keep a single shared app-construction function used by both test/runtime startup paths (`backend/src/index.ts:133`, `backend/src/index.ts:207`, `backend/src/index.ts:289`).
|
||||
|
||||
### Oversized Frontend Orchestration Context
|
||||
|
||||
**What happens:** `AppContext` aggregates many unrelated concerns (medications, settings, doses, sharing, import/export, modal history) in one large provider.
|
||||
**Why it's wrong:** High coupling and broad rerender surface make safe changes harder and increase regression risk.
|
||||
**Do this instead:** Preserve existing provider contract, but move new domain concerns into focused hooks/providers and re-export through composition only when needed (`frontend/src/context/AppContext.tsx`, file size ~1035 lines).
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Fail fast at route boundary with explicit status codes and schema validation, then log context-rich errors.
|
||||
|
||||
**Patterns:**
|
||||
- Route validation + immediate 400 responses for invalid input (`backend/src/routes/medications.ts:76`, `backend/src/routes/medications.ts:584`).
|
||||
- Planner routes return explicit channel/config errors (`backend/src/routes/planner.ts:204`, `backend/src/routes/planner.ts:509`).
|
||||
- Frontend captures network errors and maps them to normalized error codes for UI handling (`frontend/src/hooks/useMedications.ts:80`).
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:** Fastify logger options configured centrally with environment-aware formatting (`backend/src/index.ts:66`, `backend/src/index.ts:161`).
|
||||
**Validation:** Zod validation for medication payloads and explicit OpenAPI schema contracts in routes (`backend/src/routes/medications.ts:76`, `backend/src/routes/planner.ts:157`).
|
||||
**Authentication:** Route-level auth hooks and dual API-key/session handling (`backend/src/routes/planner.ts:141`, `backend/src/plugins/auth.ts:113`, `backend/src/plugins/auth.ts:236`).
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-04-30*
|
||||
@@ -0,0 +1,122 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Backend startup duplication and config drift:**
|
||||
- Issue: `backend/src/index.ts` contains two parallel server setup paths (the exported `createApp(...)` flow and the top-level runtime bootstrap). Plugin/route registration and rate-limit defaults are duplicated in both branches.
|
||||
- Files: `backend/src/index.ts`
|
||||
- Impact: Configuration behavior can diverge between test/programmatic app construction and production startup (for example, `createApp` uses fixed `rateLimit` max `300`, while runtime startup uses `process.env.RATE_LIMIT_MAX` fallback `100`).
|
||||
- Fix approach: Extract one canonical app-construction function and let both runtime startup and tests consume it; remove duplicated registration blocks.
|
||||
|
||||
**Notification architecture leakage and duplicated composition logic:**
|
||||
- Issue: Notification delivery service code imports a route-layer helper (`sendShoutrrrNotification`) from settings routes, and large HTML/text reminder composition blocks are duplicated across manual and automatic reminder paths.
|
||||
- Files: `backend/src/services/notifications/delivery.ts`, `backend/src/routes/settings.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||
- Impact: Layer boundary violations increase coupling, and duplicated notification formatting logic makes behavior regressions likely when changing message content or channel behavior.
|
||||
- Fix approach: Move `sendShoutrrrNotification` to a service-layer module, make routes call service APIs only, and centralize email/push payload builders for planner + scheduler flows.
|
||||
|
||||
**Migration artifact ambiguity in drizzle numbering:**
|
||||
- Issue: There are two migration files with `0008_` prefix, but the journal tracks only one `0008` tag and then jumps to `0009`.
|
||||
- Files: `backend/drizzle/0008_add_obsolete_medications.sql`, `backend/drizzle/0008_add_prescription_tracking.sql`, `backend/drizzle/meta/_journal.json`
|
||||
- Impact: Developer confusion and higher risk of migration-order mistakes during future schema changes.
|
||||
- Fix approach: Align migration file names and journal tags so each migration number is unique and journal order is obvious.
|
||||
|
||||
**Monolithic UI/editor and route modules with broad lint suppressions:**
|
||||
- Issue: Core interaction files are very large and rely on file-level `biome-ignore-all` suppressions for multiple rule categories.
|
||||
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/SharedSchedule.tsx`, `frontend/src/components/MedDetailModal.tsx`, `backend/src/routes/medications.ts`
|
||||
- Impact: Refactors become high-risk; local regressions are harder to isolate; suppressed rule categories hide legitimate quality issues in future edits.
|
||||
- Fix approach: Split by domain slices (state orchestration vs rendering vs helper transforms), then replace file-level suppressions with narrow, local exceptions only where justified.
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Environment-dependent behavior mismatch between test app factory and runtime app:**
|
||||
- Symptoms: Programmatic app creation and runtime startup can apply different operational defaults (rate limiting and selected config pathways).
|
||||
- Files: `backend/src/index.ts`
|
||||
- Trigger: Using `createApp(...)` in tests/integration contexts while production startup uses the top-level runtime branch.
|
||||
- Workaround: Explicitly pass runtime-equivalent options into `createApp(...)` in tests until startup construction is unified.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Server-side outbound notification surface is broad and sensitive to parser correctness:**
|
||||
- Risk: The app performs server-side HTTP requests to user-configurable notification URLs, including multiple protocol handlers (`pushover://`, `telegram://`, `gotify://`, generic webhook URLs).
|
||||
- Files: `backend/src/routes/settings.ts`
|
||||
- Current mitigation: URL sanitation/validation and hostname checks are present (`sanitizeNotificationUrl`, `validateNotificationHostname` usage in route logic).
|
||||
- Recommendations: Add focused security regression tests for sanitizer bypasses and callback URL edge cases, and keep all outbound request execution in a dedicated service layer.
|
||||
|
||||
**Auth-off bootstrap path creates implicit default user state:**
|
||||
- Risk: In auth-disabled mode, startup creates/relies on a default user path automatically.
|
||||
- Files: `backend/src/db/client.ts`
|
||||
- Current mitigation: Controlled by `AUTH_ENABLED` environment setting.
|
||||
- Recommendations: Add startup log warnings when running without auth outside development and enforce explicit environment confirmation in deployment templates.
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Reminder scheduling uses repeated full scans over users and medication/dose datasets:**
|
||||
- Problem: Reminder checks iterate all user settings and compute stock/prescription reminders with repeated in-memory loops over medication and dose collections.
|
||||
- Files: `backend/src/services/reminder-scheduler.ts`, `backend/src/utils/scheduler-utils.ts`
|
||||
- Cause: Polling/check strategy prioritizes correctness and compatibility over incremental indexing.
|
||||
- Improvement path: Introduce incremental candidate selection (changed-medication windows, per-user next-check indices) and reduce repeated whole-set scans.
|
||||
|
||||
**Intake reminder scheduler polls every minute and may scale linearly with active schedules:**
|
||||
- Problem: Intake reminder check loop runs continuously at 60s interval and processes all due reminders/users each tick.
|
||||
- Files: `backend/src/services/intake-reminder-scheduler.ts`
|
||||
- Cause: Fixed-interval scheduler (`CHECK_INTERVAL_MS = 60 * 1000`) with loop-driven due-item selection.
|
||||
- Improvement path: Move toward next-due-time scheduling or bucketing strategy; keep minute polling as fallback only.
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Reminder state persistence and lock handling mix sync file IO with best-effort catches:**
|
||||
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||
- Why fragile: Reminder state writes are synchronous file writes and some read paths swallow errors (`catch {}`), while lock/state files are local filesystem coordination primitives.
|
||||
- Safe modification: Keep file format backward-compatible, add explicit error telemetry, and add tests for concurrent/failed write scenarios before changing scheduler state logic.
|
||||
- Test coverage: No direct tests detected for `notifications/delivery.ts` and only limited direct state-function assertions.
|
||||
|
||||
**Desktop/mobile medication edit parity depends on two large independent UI paths:**
|
||||
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/medications/MedicationEditCoordinator.tsx`
|
||||
- Why fragile: The same editing domain is implemented in separate surfaces, each with dense UI logic and custom interaction handling.
|
||||
- Safe modification: Apply shared form-section components first, then update desktop and mobile in the same change; validate both paths with targeted tests.
|
||||
- Test coverage: Coverage exists (`MedicationEditCoordinator`, `MobileEditModal`, `MedicationDialogs` tests), but parity regressions remain a recurring risk due to file size/complexity.
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Current reminder architecture is single-node/local-state oriented:**
|
||||
- Current capacity: Scheduler state and lock coordination are local files under data directory (`reminder-state.json`, `scheduler-locks/*`).
|
||||
- Limit: Horizontal multi-instance scaling can duplicate work or require externalized coordination.
|
||||
- Scaling path: Move reminder state/locks to DB or distributed lock backend and make scheduler execution leader-aware.
|
||||
|
||||
**SQLite file-backed persistence constrains concurrent write scaling:**
|
||||
- Current capacity: Single SQLite file with local filesystem path resolution.
|
||||
- Limit: Higher write concurrency and distributed deployments will hit filesystem/database locking and throughput limits.
|
||||
- Scaling path: Keep SQLite for local/small deployments; define migration path to managed DB for larger multi-user workloads.
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**Route-to-service coupling in notification stack:**
|
||||
- Risk: Service-layer delivery module depends on route-layer helper import.
|
||||
- Impact: Refactors of route modules can break unrelated notification infrastructure and complicate testing boundaries.
|
||||
- Migration plan: Move shared notification send helpers into `backend/src/services/notifications/*` and keep route modules thin.
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**Risk-driven scheduler stress/integration test suite for state-lock edge cases:**
|
||||
- Problem: Complex scheduler/state code paths rely on file coordination and mixed channel delivery outcomes, but dedicated stress/chaos-style verification is limited.
|
||||
- Blocks: High-confidence scaling and reliability changes in reminder subsystems.
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Notification delivery abstraction lacks direct unit tests:**
|
||||
- What's not tested: Direct behavior of SMTP transport creation/result validation and push delivery helpers in the dedicated delivery module.
|
||||
- Files: `backend/src/services/notifications/delivery.ts`
|
||||
- Risk: Regressions in recipient validation, SMTP response handling, or provider fallback can ship unnoticed.
|
||||
- Priority: High
|
||||
|
||||
**Reminder state persistence/locking has limited direct verification:**
|
||||
- What's not tested: Corrupted file recovery, concurrent state writes, and lock stale-file behavior under failure modes.
|
||||
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||
- Risk: Duplicate sends or missed sends after crashes/restarts are difficult to detect early.
|
||||
- Priority: High
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-04-30*
|
||||
@@ -0,0 +1,116 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Frontend React components and pages use PascalCase file names (for example `frontend/src/components/MobileEditModal.tsx`, `frontend/src/pages/MedicationsPage.tsx`).
|
||||
- Hooks use `useX` camelCase naming in files and symbols (for example `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useScheduleController.ts`).
|
||||
- Backend routes/services use kebab-case file names with domain suffixes (for example `backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||
- Test files use `*.test.ts` or `*.test.tsx` in dedicated test folders (for example `backend/src/test/planner.test.ts`, `frontend/src/test/components/MobileEditModal.test.tsx`).
|
||||
|
||||
**Functions:**
|
||||
- Use camelCase names for functions and methods (for example `parseIntakesWithUnits` in `backend/src/services/medications-service.ts`, `loadMeds` in `frontend/src/hooks/useMedications.ts`).
|
||||
- Use verb-first names for side-effect operations (`loadMeds`, `deleteMed`, `uploadMedImage` in `frontend/src/hooks/useMedications.ts`).
|
||||
|
||||
**Variables:**
|
||||
- Use camelCase for local variables and state (`refillHistoryExpanded`, `scheduleDays`, `showFutureDays` in `frontend/src/context/AppContext.tsx`).
|
||||
- Constant maps and singleton keys use UPPER_SNAKE_CASE (`LOG_LEVELS` in `backend/src/utils/logger.ts`, `APP_CONTEXT_SINGLETON_KEY` in `frontend/src/context/AppContext.tsx`).
|
||||
|
||||
**Types:**
|
||||
- Type aliases and interfaces use PascalCase (`AppContextValue` in `frontend/src/context/AppContext.tsx`, `TestContext` in `backend/src/test/setup.ts`).
|
||||
- Return-shape interfaces use `UseXReturn` convention for hooks (`UseMedicationsReturn` in `frontend/src/hooks/useMedications.ts`).
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- Tool used: Biome (`biome.json`, scripts in `frontend/package.json`, `backend/package.json`, `package.json`).
|
||||
- Key settings from `biome.json`:
|
||||
- `indentStyle: tab`
|
||||
- `indentWidth: 2`
|
||||
- `lineWidth: 120`
|
||||
- JavaScript quote style is double quotes, semicolons enabled, trailing commas `es5`.
|
||||
|
||||
**Linting:**
|
||||
- Tool used: Biome linter (`biome.json`).
|
||||
- Key rules enforced/relevant:
|
||||
- `style.useConst: error`
|
||||
- `style.noNestedTernary: warn`
|
||||
- `correctness.noUnusedVariables: warn`
|
||||
- `suspicious.noExplicitAny: warn`
|
||||
- Project governance in `AGENTS.md` reinforces readable code, early returns, and no nested ternaries.
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
1. Node built-ins first in backend modules (for example `node:path` in `backend/src/routes/medications.ts`, `node:crypto` in `backend/src/index.ts`).
|
||||
2. External packages second (`fastify`, `zod`, `drizzle-orm` in backend; `react`, `@testing-library/*` in frontend).
|
||||
3. Internal modules last with relative paths (`../db/client.js`, `../../types`).
|
||||
|
||||
**Path Aliases:**
|
||||
- Not detected in TypeScript configs (`frontend/tsconfig.json`, `backend/tsconfig.json` do not define `paths`).
|
||||
- Relative imports are the standard.
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Patterns:**
|
||||
- Backend validates request data with Zod schemas and `.refine(...)` constraints before route logic (`backend/src/routes/medications.ts`).
|
||||
- Backend route tests assert explicit status codes and body shape (`backend/src/test/routes-real.test.ts`, `backend/src/test/planner.test.ts`).
|
||||
- Frontend hooks often normalize recoverable API errors into UI-safe states (`frontend/src/hooks/useMedications.ts` converts network failures into `NETWORK_ERROR`).
|
||||
- Some frontend fetch flows still use tolerant fallbacks (`catch(() => setMeds([]))` in `frontend/src/hooks/useMedications.ts`), so future changes should prefer explicit user-facing error channels per `AGENTS.md` fail-clear guidance.
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:**
|
||||
- Backend startup logger wrapper over console with level filtering in `backend/src/utils/logger.ts`.
|
||||
- Runtime HTTP logging via Fastify logger options in `backend/src/index.ts` (`buildLoggerOptions`, request correlation IDs).
|
||||
- Frontend logging utility mirrors backend level semantics (`frontend/src/utils/logger.ts`).
|
||||
|
||||
**Patterns:**
|
||||
- Central log-level maps (`LOG_LEVELS`) and `shouldLog` gating are standard in both frontend and backend logger modules.
|
||||
- Correlation ID propagation is enforced at request boundaries (`backend/src/index.ts` onRequest hook setting `x-correlation-id`).
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- Comments are used for rationale and test setup intent, not line-by-line narration.
|
||||
- Typical examples:
|
||||
- Migration/setup intent in `backend/src/test/setup.ts`
|
||||
- E2E stability rationale in `frontend/e2e/fixtures/index.ts`
|
||||
- Timeout/determinism notes in `frontend/vitest.config.ts` and `frontend/playwright.base.config.ts`
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
- Used selectively for exported utilities and test helpers (`backend/src/test/setup.ts`, `frontend/e2e/fixtures/index.ts`, `frontend/src/utils/logger.ts`).
|
||||
- Not mandatory for every function; concise type annotations plus targeted comments are preferred.
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
- Small-to-medium focused functions are common in services/hooks (`parseRawIntakeUnits`, `normalizeDateTime` in `backend/src/services/medications-service.ts`).
|
||||
- Larger orchestrator modules exist where domain aggregation is required (`frontend/src/context/AppContext.tsx`).
|
||||
|
||||
**Parameters:**
|
||||
- Object parameters are used for extensibility in test factories and route payload shapes (`CreateMedicationOptions` in `backend/src/test/setup.ts`).
|
||||
- Explicit primitive parameters used for concise helpers (`clickEditMed(page, medName)` in `frontend/e2e/medication-edit.spec.ts`).
|
||||
|
||||
**Return Values:**
|
||||
- Explicit return types are common on exported functions (`Promise<TestContext>`, `UseMedicationsReturn`).
|
||||
- Guard-clause returns are common for invalid input or unavailable state (`if (!intakesJson) return [];` in `backend/src/services/medications-service.ts`).
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Named exports are preferred for utilities, hooks, and service functions (`backend/src/services/notifications/index.ts`, `frontend/src/hooks/index.ts`).
|
||||
- Mixed export style is used where legacy/default exports remain practical (`default` exports in component barrel `frontend/src/components/index.ts`).
|
||||
|
||||
**Barrel Files:**
|
||||
- Barrel files are actively used for stable import surfaces:
|
||||
- `frontend/src/components/index.ts`
|
||||
- `frontend/src/hooks/index.ts`
|
||||
- `backend/src/services/notifications/index.ts`
|
||||
- Practical rule for new code: export domain-level public APIs through local barrels, keep deep internal helpers imported directly.
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-04-30*
|
||||
@@ -0,0 +1,111 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Medication Data APIs:**
|
||||
- European Medicines Agency (EMA) JSON catalog - medication lookup seed and periodic catalog refresh
|
||||
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`EMA_MEDICINES_URL`)
|
||||
- Auth: none detected in code
|
||||
- RxNorm (NLM RxNav REST) - normalized name/search enrichment and strength/form hints
|
||||
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`RXNORM_BASE_URL`)
|
||||
- Auth: none detected in code
|
||||
- openFDA NDC API - product/package metadata enrichment
|
||||
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`OPENFDA_NDC_URL`)
|
||||
- Auth: none detected in code
|
||||
|
||||
**Authentication/Identity Provider Integration:**
|
||||
- OIDC providers (Authelia, Authentik, Pocket ID, Keycloak documented) - SSO login/callback flow
|
||||
- SDK/Client: `openid-client` used in `backend/src/routes/oidc.ts`
|
||||
- Auth: `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` validated in `backend/src/plugins/env.ts`
|
||||
|
||||
**Messaging/Notifications:**
|
||||
- SMTP providers - transactional reminder/test emails
|
||||
- SDK/Client: `nodemailer` in `backend/src/services/notifications/delivery.ts`
|
||||
- Auth: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` or `SMTP_TOKEN`, `SMTP_FROM`, `SMTP_SECURE`
|
||||
- Push endpoints via Shoutrrr-compatible URL parsing
|
||||
- SDK/Client: native `fetch` in `backend/src/routes/settings.ts` (`sendShoutrrrNotification`)
|
||||
- Auth: URL-embedded creds/token per provider and optional basic auth extracted/sanitized in code
|
||||
- Explicit external push provider endpoints used directly:
|
||||
- `https://api.pushover.net/1/messages.json` in `backend/src/routes/settings.ts`
|
||||
- `https://api.telegram.org` in `backend/src/routes/settings.ts`
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- SQLite (file-based, local persistent volume)
|
||||
- Connection: `DATA_DIR` (path resolution), optional `DOTENV_PATH` for env source
|
||||
- Client: `@libsql/client` + `drizzle-orm` in `backend/src/db/client.ts`
|
||||
- Migration pipeline:
|
||||
- SQL migration artifacts in `backend/drizzle/*.sql`
|
||||
- Runtime migration/alter execution in `backend/src/db/client.ts` and `backend/src/db/migration-utils.ts`
|
||||
|
||||
**File Storage:**
|
||||
- Local filesystem only
|
||||
- Backend data root resolved by `backend/src/db/path-utils.ts`
|
||||
- Image/static user files served from `/images` in `backend/src/index.ts`
|
||||
- Compose bind mount `./data:/app/data` in `docker-compose.yml`
|
||||
|
||||
**Caching:**
|
||||
- In-process memory cache only for selected integration data
|
||||
- OIDC discovery config cache in `backend/src/routes/oidc.ts` (`oidcConfig`)
|
||||
- EMA catalog snapshot + refresh promise in `backend/src/services/medication-enrichment.ts`
|
||||
- No external cache service detected (no Redis/Memcached dependency in package manifests)
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:**
|
||||
- Custom session/JWT auth with optional OIDC SSO extension
|
||||
- Implementation: Fastify cookie + JWT plugin, refresh token table, API key hashing in `backend/src/plugins/auth.ts`, `backend/src/routes/auth.ts`, `backend/src/plugins/jwt.ts`, `backend/src/routes/oidc.ts`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None detected for third-party SaaS error tracking (no Sentry/Rollbar/etc. dependencies)
|
||||
|
||||
**Logs:**
|
||||
- Structured app logging via Fastify/Pino in `backend/src/index.ts`
|
||||
- Pretty logging in dev through `pino-pretty` (`backend/package.json`, logger setup in `backend/src/index.ts`)
|
||||
- Frontend/nginx log behavior controlled through env and `frontend/nginx-entrypoint.sh` (documented in `.env.example`)
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Container image publishing to GitHub Container Registry (`ghcr.io`) in `.github/workflows/docker-build.yml`
|
||||
- Runtime deployment model is self-hosted Docker Compose stack (`docker-compose.yml`)
|
||||
|
||||
**CI Pipeline:**
|
||||
- GitHub Actions for lint/type/test (`.github/workflows/test.yml`)
|
||||
- Playwright E2E job (`.github/workflows/e2e.yml`)
|
||||
- Docker build/push and optional release automation (`.github/workflows/docker-build.yml`)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- Core runtime: `PORT`, `CORS_ORIGINS`, `LOG_LEVEL`, `TZ` (`backend/src/plugins/env.ts`, `.env.example`)
|
||||
- Auth when enabled: `AUTH_ENABLED=true` with `JWT_SECRET`, `REFRESH_SECRET`, `COOKIE_SECRET` (`backend/src/plugins/env.ts`)
|
||||
- OIDC when enabled: `OIDC_ENABLED=true` with issuer/client/redirect vars (`backend/src/plugins/env.ts`)
|
||||
- Email notifications: `SMTP_HOST`, `SMTP_USER`, plus pass/token and sender config (`backend/src/services/notifications/delivery.ts`, `.env.example`)
|
||||
- Data location: `DATA_DIR` used by DB path resolver (`backend/src/db/path-utils.ts`)
|
||||
|
||||
**Secrets location:**
|
||||
- Local runtime env file `.env` (present in repository root; values not inspected)
|
||||
- CI secrets managed by GitHub Actions secret store (e.g., `${{ secrets.GITHUB_TOKEN }}` in `.github/workflows/docker-build.yml`)
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- OIDC callback endpoint: `/auth/oidc/callback` in `backend/src/routes/oidc.ts`
|
||||
- No inbound third-party webhook receiver route detected in backend routes
|
||||
|
||||
**Outgoing:**
|
||||
- Outbound HTTP notifications to webhook-style targets from `sendShoutrrrNotification` in `backend/src/routes/settings.ts`
|
||||
- Provider-specific outgoing callbacks/APIs:
|
||||
- Pushover API endpoint
|
||||
- Telegram Bot API endpoint
|
||||
- Outbound SMTP delivery through configured mail host (`backend/src/services/notifications/delivery.ts`)
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-04-30*
|
||||
@@ -0,0 +1,86 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript (ESM) - Backend and frontend application code in `backend/src/**/*.ts` and `frontend/src/**/*.{ts,tsx}`
|
||||
- SQL (SQLite migrations) - Schema evolution files in `backend/drizzle/*.sql`
|
||||
|
||||
**Secondary:**
|
||||
- CSS - UI styling in `frontend/src/**/*.css` and CSS modules such as `frontend/src/features/schedule/TimelineSurface.module.css`
|
||||
- YAML - CI/CD and compose configuration in `.github/workflows/*.yml`, `docker-compose.yml`, `docker-compose.dev.yml`
|
||||
- Shell - Container/runtime entrypoints in `backend/docker-entrypoint.sh`, `frontend/nginx-entrypoint.sh`
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js 22 runtime baseline (`node:22-slim` in `backend/Dockerfile`, `frontend/Dockerfile`; `actions/setup-node@v6` with `node-version: '22'` in `.github/workflows/test.yml` and `.github/workflows/e2e.yml`)
|
||||
|
||||
**Package Manager:**
|
||||
- npm (scripts in root `package.json`, `backend/package.json`, `frontend/package.json`)
|
||||
- Lockfile: present (`backend/package-lock.json`, `frontend/package-lock.json` referenced by workflow cache in `.github/workflows/test.yml`)
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Fastify 5 (`fastify`, `@fastify/*` in `backend/package.json`; app bootstrap in `backend/src/index.ts`)
|
||||
- React 19 (`react`, `react-dom` in `frontend/package.json`; app entry in `frontend/src/main.tsx`)
|
||||
- Vite 8 (`vite` and `@vitejs/plugin-react` in `frontend/package.json`; config in `frontend/vite.config.ts`)
|
||||
- Drizzle ORM + libSQL client (`drizzle-orm`, `@libsql/client` in `backend/package.json`; DB init in `backend/src/db/client.ts`)
|
||||
- Mantine 8 UI system (`@mantine/*` in `frontend/package.json`; provider in `frontend/src/ui/providers/AppUiProvider.tsx`)
|
||||
|
||||
**Testing:**
|
||||
- Vitest 4 (`vitest`, `@vitest/coverage-v8` in backend/frontend package manifests; configs in `backend/vitest.config.ts`, `frontend/vitest.config.ts`)
|
||||
- Playwright (`@playwright/test` in `frontend/package.json`; configs in `frontend/playwright*.config.ts`; CI run in `.github/workflows/e2e.yml`)
|
||||
- Testing Library (`@testing-library/*` in `frontend/package.json`)
|
||||
|
||||
**Build/Dev:**
|
||||
- TypeScript compiler (`tsc` scripts in `backend/package.json` and frontend type-check via `frontend/package.json`)
|
||||
- TSX watcher for backend dev (`tsx watch src/index.ts` in `backend/package.json`)
|
||||
- Biome for lint/format (`biome.json`, lint/check scripts across package manifests)
|
||||
- Drizzle Kit for DB migration generation (`drizzle-kit` in `backend/package.json`, config in `backend/drizzle.config.ts`)
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- `fastify` and `@fastify/*` - HTTP API runtime, security middleware, docs middleware (`backend/src/index.ts`)
|
||||
- `drizzle-orm` + `@libsql/client` - SQLite data access and migration execution (`backend/src/db/client.ts`)
|
||||
- `openid-client` + `jose` - OIDC SSO and token operations (`backend/src/routes/oidc.ts`, `backend/package.json`)
|
||||
- `nodemailer` - SMTP notification delivery (`backend/src/services/notifications/delivery.ts`)
|
||||
- `react`, `react-router-dom`, `@mantine/*` - SPA UI shell, routing, and component system (`frontend/src/main.tsx`, `frontend/src/App.tsx`)
|
||||
- `i18next` + `react-i18next` - Localization runtime (`frontend/src/i18n/index.ts`)
|
||||
|
||||
**Infrastructure:**
|
||||
- `dotenv` + `zod` - env loading/validation (`backend/src/plugins/env.ts`)
|
||||
- `sharp` - image processing pipeline support (`backend/package.json`, image route usage in medication flows)
|
||||
- `@fastify/swagger` + `@fastify/swagger-ui` - OpenAPI docs on `/docs` (`backend/src/index.ts`)
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment:**
|
||||
- Runtime env schema and validation in `backend/src/plugins/env.ts`
|
||||
- Example variable inventory in `.env.example`
|
||||
- Frontend proxy target via `BACKEND_URL` in `frontend/vite.config.ts` and compose files
|
||||
|
||||
**Build:**
|
||||
- Backend TS build config: `backend/tsconfig.json`
|
||||
- Frontend TS + Vite config: `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, `frontend/vite.config.ts`
|
||||
- DB migration tooling config: `backend/drizzle.config.ts`
|
||||
- Quality tooling config: `biome.json`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js 22 with npm for local runs (`backend/package.json`, `frontend/package.json` scripts)
|
||||
- Optional Docker Compose local stack (`docker-compose.dev.yml`)
|
||||
- Browser runtime for frontend and Playwright browser binaries for E2E (`frontend/package.json`, `.github/workflows/e2e.yml`)
|
||||
|
||||
**Production:**
|
||||
- Containerized deployment using prebuilt images from GHCR (`docker-compose.yml` references `ghcr.io/danielvolz/medassist-ng-backend:latest` and `ghcr.io/danielvolz/medassist-ng-frontend:latest`)
|
||||
- Backend persistent filesystem for SQLite/data in mounted `./data` (`docker-compose.yml`, DB path resolver in `backend/src/db/path-utils.ts`)
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-04-30*
|
||||
@@ -0,0 +1,138 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
medassist/
|
||||
├── frontend/ # React + Vite SPA, UI, hooks, page routes, frontend tests
|
||||
├── backend/ # Fastify API, domain services, DB schema/migrations, backend tests
|
||||
├── backend/drizzle/ # SQL migration files + drizzle meta journal
|
||||
├── docs/ # Product/ops docs and screenshots
|
||||
├── doku/ # Local-only working notes and reports (ignored)
|
||||
├── .github/ # CI workflows, agents, local skill/runtime metadata
|
||||
├── .planning/codebase/ # Generated codebase mapping documents
|
||||
├── data/ # Runtime/local SQLite backups and scheduler files
|
||||
└── package.json # Root workspace scripts for lint orchestration
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**frontend/src:**
|
||||
- Purpose: Product UI and client-side app logic.
|
||||
- Contains: `pages/`, `components/`, `context/`, `hooks/`, `ui/`, `utils/`, `i18n/`, `test/`.
|
||||
- Key files: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`.
|
||||
|
||||
**backend/src:**
|
||||
- Purpose: HTTP API, auth, domain services, and persistence access.
|
||||
- Contains: `routes/`, `services/`, `plugins/`, `db/`, `utils/`, `test/`.
|
||||
- Key files: `backend/src/index.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/db/client.ts`.
|
||||
|
||||
**backend/drizzle:**
|
||||
- Purpose: SQL migration history for SQLite compatibility.
|
||||
- Contains: numbered migration files and `meta/_journal.json`.
|
||||
- Key files: `backend/drizzle/0000_init.sql`, `backend/drizzle/0014_add_user_settings_timezone.sql`.
|
||||
|
||||
**frontend/e2e:**
|
||||
- Purpose: Playwright end-to-end scenarios and fixtures.
|
||||
- Contains: browser tests + auth fixtures.
|
||||
- Key files: `frontend/e2e/fixtures/` and spec files under `frontend/e2e/`.
|
||||
|
||||
**docs + doku:**
|
||||
- Purpose: formal docs (`docs/`) and local-only work tracking (`doku/`).
|
||||
- Contains: behavior/spec docs, screenshots, local report/memory logs.
|
||||
- Key files: `docs/TECH_STACK.md`, `doku/memory_notes.md`, `doku/report.md`.
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `frontend/src/main.tsx`: Browser bootstrap; mounts providers and router.
|
||||
- `frontend/src/App.tsx`: Route graph and global modal/shell orchestration.
|
||||
- `backend/src/index.ts`: Fastify app setup + startup runtime.
|
||||
|
||||
**Configuration:**
|
||||
- `frontend/vite.config.ts`: Dev server, `/api` proxy rewrite, build-time constants.
|
||||
- `frontend/vitest.config.ts`: Frontend unit test config.
|
||||
- `backend/vitest.config.ts`: Backend unit/integration test config.
|
||||
- `backend/drizzle.config.ts`: Drizzle migration configuration.
|
||||
- `.gitignore`: Local-only/generated path policy (including `.planning/`, `doku/`, `data/`, coverage/test artifacts).
|
||||
|
||||
**Core Logic:**
|
||||
- `backend/src/routes/`: API contracts and request handlers.
|
||||
- `backend/src/services/`: Scheduler, notifications, medication helpers.
|
||||
- `backend/src/db/schema.ts`: Source-of-truth table definitions.
|
||||
- `frontend/src/context/`: Shared app orchestration state.
|
||||
- `frontend/src/pages/`: Screen-level composition.
|
||||
|
||||
**Testing:**
|
||||
- `frontend/src/test/`: Frontend unit/component tests.
|
||||
- `frontend/e2e/`: Playwright E2E tests.
|
||||
- `backend/src/test/`: Backend route/service/db tests.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- React components/pages use PascalCase: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MedDetailModal.tsx`.
|
||||
- Hooks use `use*` naming: `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useSettings.ts`.
|
||||
- Backend routes/services use kebab-case: `backend/src/routes/medication-enrichment.ts`, `backend/src/services/reminder-scheduler.ts`.
|
||||
- Migrations use numbered descriptive names: `backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql`.
|
||||
|
||||
**Directories:**
|
||||
- Feature/layer folders are lowercase: `frontend/src/context`, `backend/src/services`.
|
||||
- Test directories stay colocated by runtime side (`frontend/src/test`, `backend/src/test`).
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Feature:**
|
||||
- Primary code:
|
||||
- Frontend UI route/screen: `frontend/src/pages/` (compose from existing `components/`, `hooks/`, `ui/`).
|
||||
- Backend endpoint: `backend/src/routes/` + matching domain logic in `backend/src/services/`.
|
||||
- Persistence additions: `backend/src/db/schema.ts` plus migration updates in `backend/src/db/client.ts` and `backend/drizzle/`.
|
||||
- Tests:
|
||||
- Frontend unit/component: `frontend/src/test/`.
|
||||
- Backend unit/integration: `backend/src/test/`.
|
||||
- E2E flow: `frontend/e2e/`.
|
||||
|
||||
**New Component/Module:**
|
||||
- Implementation:
|
||||
- Shared UI primitive/layout: `frontend/src/ui/`.
|
||||
- Domain-specific UI component: `frontend/src/components/` (or nested feature folder).
|
||||
- Backend reusable domain behavior: `backend/src/services/`.
|
||||
|
||||
**Utilities:**
|
||||
- Shared helpers:
|
||||
- Frontend: `frontend/src/utils/`.
|
||||
- Backend: `backend/src/utils/`.
|
||||
- DB-specific helpers: `backend/src/db/` focused utility modules.
|
||||
|
||||
## Special Directories
|
||||
|
||||
**frontend/dist, backend/dist:**
|
||||
- Purpose: build output artifacts.
|
||||
- Generated: Yes.
|
||||
- Committed: No (`dist/` ignored in `.gitignore`).
|
||||
|
||||
**frontend/playwright-report, frontend/test-results, frontend/coverage, backend/coverage:**
|
||||
- Purpose: test artifacts/reports.
|
||||
- Generated: Yes.
|
||||
- Committed: No (ignored in `.gitignore`).
|
||||
|
||||
**data/:**
|
||||
- Purpose: runtime/local DB, reminder state, scheduler locks.
|
||||
- Generated: Yes.
|
||||
- Committed: No (`data/` ignored in `.gitignore`).
|
||||
|
||||
**doku/:**
|
||||
- Purpose: local work memory/reporting and internal notes.
|
||||
- Generated: Mixed (manual local notes + artifacts).
|
||||
- Committed: No (`doku/` ignored in `.gitignore`).
|
||||
|
||||
**.planning/codebase/:**
|
||||
- Purpose: generated architecture/stack/convention/concern maps for GSD planning/execution.
|
||||
- Generated: Yes.
|
||||
- Committed: No (`.planning/` ignored by policy in this workspace).
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-04-30*
|
||||
@@ -0,0 +1,203 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-04-30
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Vitest 4.x for unit/integration tests in both packages:
|
||||
- Frontend config: `frontend/vitest.config.ts`
|
||||
- Backend config: `backend/vitest.config.ts`
|
||||
- Config evidence:
|
||||
- Frontend uses `environment: 'jsdom'` with React setup file `frontend/src/test/setup.ts`.
|
||||
- Backend uses `environment: 'node'` with setup file `backend/src/test/setup.ts`.
|
||||
|
||||
**Assertion Library:**
|
||||
- Vitest `expect`.
|
||||
- Frontend extends DOM assertions via `@testing-library/jest-dom` in `frontend/src/test/setup.ts`.
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
cd frontend && npm test # Watch/unit tests
|
||||
cd frontend && npm run test:run # CI-style frontend run
|
||||
cd frontend && npm run test:coverage # Frontend coverage
|
||||
cd backend && npm test # Watch/unit tests
|
||||
cd backend && npm run test:run # CI-style backend run
|
||||
cd backend && npm run test:coverage # Backend coverage
|
||||
cd frontend && npm run test:e2e # Stable Playwright suite
|
||||
cd frontend && npm run test:e2e:all # Cross-browser Playwright suite
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- Backend unit/integration tests are in `backend/src/test/*.test.ts`.
|
||||
- Frontend unit/component/hook/context tests are in `frontend/src/test/**`.
|
||||
- Browser E2E tests are in `frontend/e2e/*.spec.ts`.
|
||||
|
||||
**Naming:**
|
||||
- Unit/integration: `*.test.ts` or `*.test.tsx` (for example `backend/src/test/routes-real.test.ts`, `frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||
- E2E: `*.spec.ts` (for example `frontend/e2e/medication-edit.spec.ts`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
backend/src/test/
|
||||
setup.ts
|
||||
*.test.ts
|
||||
|
||||
frontend/src/test/
|
||||
setup.ts
|
||||
App.test.tsx
|
||||
components/*.test.tsx
|
||||
context/*.test.tsx
|
||||
hooks/*.test.ts
|
||||
pages/*.test.tsx
|
||||
utils/*.test.ts
|
||||
|
||||
frontend/e2e/
|
||||
auth.setup.ts
|
||||
fixtures/index.ts
|
||||
*.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
describe("Feature Area", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handles expected behavior", async () => {
|
||||
// arrange
|
||||
// act
|
||||
// assert
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
Pattern evidence: `frontend/src/test/components/MobileEditModal.test.tsx`, `backend/src/test/planner.test.ts`.
|
||||
|
||||
**Patterns:**
|
||||
- Setup pattern:
|
||||
- Frontend centralizes browser mocks in `frontend/src/test/setup.ts` (fetch, localStorage, clipboard, history, i18n).
|
||||
- Backend provides reusable app/database factories in `backend/src/test/setup.ts` (`buildTestApp`, `createTestUser`, `createTestMedication`).
|
||||
- Teardown pattern:
|
||||
- `afterAll` closes Fastify app and DB clients (`backend/src/test/planner.test.ts`, `backend/src/test/integration.test.ts`).
|
||||
- Assertion pattern:
|
||||
- Route tests assert both HTTP status and response body (`backend/src/test/routes-real.test.ts`).
|
||||
- UI tests assert presence and behavior via Testing Library role/test-id queries (`frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
- Vitest mocks (`vi.mock`, `vi.fn`, `vi.hoisted`, `vi.stubGlobal`).
|
||||
|
||||
**Patterns:**
|
||||
```typescript
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return { testClient: client, testDb: db };
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
```
|
||||
Pattern evidence: `backend/src/test/integration.test.ts`, `backend/src/test/routes-real.test.ts`.
|
||||
|
||||
```typescript
|
||||
vi.mock("../../components/ConfirmModal", () => ({
|
||||
ConfirmModal: ({ onConfirm }) => <button onClick={onConfirm}>confirm</button>,
|
||||
}));
|
||||
```
|
||||
Pattern evidence: `frontend/src/test/components/MedicationDialogs.test.tsx`.
|
||||
|
||||
**What to Mock:**
|
||||
- External side effects and infrastructure boundaries: SMTP/nodemailer, fetch network calls, auth/plugin env modules, browser APIs.
|
||||
- Component dependencies in focused unit tests (replace heavy children with stubs).
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Core business behavior under direct test (route handlers in route tests, hook logic in hook tests, E2E API + UI flow in Playwright).
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
```typescript
|
||||
const userId = await createTestUser(client, { username: "testuser" });
|
||||
const medId = await createTestMedication(client, { userId, name: "Test Medication" });
|
||||
```
|
||||
Pattern evidence: `backend/src/test/setup.ts`, used by `backend/src/test/medications.test.ts`.
|
||||
|
||||
```typescript
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await applyVideoSafetyMode(page);
|
||||
await setupAuthMeMock(page);
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
Pattern evidence: `frontend/e2e/fixtures/index.ts`.
|
||||
|
||||
**Location:**
|
||||
- Backend factories/utilities: `backend/src/test/setup.ts`.
|
||||
- Frontend E2E shared fixtures and API helpers: `frontend/e2e/fixtures/index.ts`.
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:**
|
||||
- Frontend global thresholds in `frontend/vitest.config.ts`: lines/functions/branches/statements = 75.
|
||||
- Backend global thresholds in `backend/vitest.config.ts`: lines 60, functions 65, branches 50, statements 60.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
cd frontend && npm run test:coverage
|
||||
cd backend && npm run test:coverage
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- Component/hook/utils tests in `frontend/src/test/**`.
|
||||
- Utility/service route-unit style tests in `backend/src/test/*.test.ts`.
|
||||
|
||||
**Integration Tests:**
|
||||
- Backend route interaction and multi-route behavior tests in files like:
|
||||
- `backend/src/test/integration.test.ts`
|
||||
- `backend/src/test/routes-real.test.ts`
|
||||
|
||||
**E2E Tests:**
|
||||
- Playwright used with setup project and browser projects (`frontend/playwright.base.config.ts`).
|
||||
- Auth/session and API seeding helpers in `frontend/e2e/fixtures/index.ts`.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
await waitFor(() => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
Pattern evidence: `frontend/src/test/context/AppContext.test.tsx`.
|
||||
|
||||
```typescript
|
||||
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
```
|
||||
Pattern evidence: `backend/src/test/routes-real.test.ts`.
|
||||
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { rows: [] } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||
```
|
||||
Pattern evidence: `backend/src/test/planner.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-04-30*
|
||||
Vendored
+8
-1
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"vitest.root": "backend",
|
||||
"vitest.enable": true,
|
||||
"vitest.commandLine": "npm test --"
|
||||
"vitest.commandLine": "npm test --",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"test": true
|
||||
},
|
||||
"gitea.instanceURL": "https://git.cuttlecloud.com",
|
||||
"gitea.owner": "Patrick",
|
||||
"gitea.repo": "medassist-ng",
|
||||
"gitea.token": "cb06442b1b93224196cbe10782770685fe091b65"
|
||||
}
|
||||
|
||||
Vendored
+166
@@ -0,0 +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": []
|
||||
},
|
||||
{
|
||||
"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-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-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,19 +119,27 @@ Share your medication schedule with others via a public link.
|
||||
</blockquote>
|
||||
</details>
|
||||
|
||||
### Medication Setup
|
||||
- Optional medication lookup in the editor on desktop and mobile
|
||||
- Supports `RxNorm`, `openFDA`, and `EMA` with source labels
|
||||
- Review-and-apply flow with package-size suggestions when available
|
||||
- Manual entry remains available
|
||||
|
||||
### Smart Inventory
|
||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection)
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container)
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
- One-click refill with package-aware refill options for discrete containers and amount-based packages
|
||||
- Complete refill history per medication
|
||||
- Automatic stock updates after each refill
|
||||
|
||||
### Flexible Schedules
|
||||
- Daily, weekly, or custom intervals per medication
|
||||
- Independent schedules for each medication
|
||||
- Optional timeline filters for dashboard and shared schedule views
|
||||
|
||||
### Stock Alerts & Reminders
|
||||
- Notifications before stock runs out
|
||||
@@ -139,23 +147,30 @@ 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
|
||||
- Plan ahead for vacations, business trips, or hospital stays
|
||||
- Calculate medication demand for a trip or date range with package-aware units
|
||||
- Send demand reports via email or push notification
|
||||
|
||||
### Reports
|
||||
- Generate medication reports as PDF, Markdown, or plain text
|
||||
- Include intake history, refill history, and prescription details
|
||||
|
||||
### Multi-Person Support
|
||||
- Manage medications for multiple people
|
||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||
- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
|
||||
- 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
|
||||
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
|
||||
- Review validated import contents before replacing current data
|
||||
- Optionally download a fresh backup before confirming import
|
||||
- Import previously exported data with automatic ID remapping
|
||||
- Choose whether to include sensitive data in exports
|
||||
|
||||
### Notifications
|
||||
- Email via SMTP
|
||||
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||
- Supports both stock warnings and intake reminders
|
||||
- Supports stock warnings and intake reminders
|
||||
|
||||
### Privacy & Security
|
||||
- Fully self-hosted
|
||||
@@ -171,139 +186,57 @@ 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.
|
||||
|
||||
### Verify Deployment
|
||||
|
||||
After the containers start, confirm the stack is actually healthy:
|
||||
|
||||
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
|
||||
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
|
||||
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
|
||||
|
||||
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
|
||||
|
||||
# Configuration
|
||||
|
||||
All configuration is done via environment variables in `.env`. Copy `.env.example` to get started.
|
||||
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
|
||||
|
||||
### General
|
||||
### Initial Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PUID` | `1000` | User ID for container file permissions |
|
||||
| `PGID` | `1000` | Group ID for container file permissions |
|
||||
| `PORT` | `3000` | Backend API port |
|
||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed frontend origins |
|
||||
| `TZ` | `Europe/Berlin` | Default timezone for reminders |
|
||||
|
||||
### Authentication
|
||||
Optional but commonly needed:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AUTH_ENABLED` | `false` | Enable user authentication |
|
||||
| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
|
||||
| `JWT_SECRET` | — | Access token signing key (required if auth enabled) |
|
||||
| `REFRESH_SECRET` | — | Refresh token signing key (required if auth enabled) |
|
||||
| `COOKIE_SECRET` | — | Cookie signing key (required if auth enabled) |
|
||||
| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
|
||||
| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
|
||||
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
|
||||
|
||||
Generate secrets with: `openssl rand -hex 32`
|
||||
Detailed configuration references:
|
||||
|
||||
### OIDC / SSO
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
|
||||
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
|
||||
| `OIDC_CLIENT_ID` | — | Client ID from OIDC provider |
|
||||
| `OIDC_CLIENT_SECRET` | — | Client secret from OIDC provider |
|
||||
| `OIDC_REDIRECT_URI` | — | Full callback URL (e.g., `https://your-domain.com/api/auth/oidc/callback`) |
|
||||
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
|
||||
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim for username |
|
||||
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
|
||||
| `OIDC_PROVIDER_NAME` | `SSO` | Name shown on login button |
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SMTP_HOST` | — | SMTP server hostname |
|
||||
| `SMTP_PORT` | `587` | SMTP server port |
|
||||
| `SMTP_USER` | — | SMTP username |
|
||||
| `SMTP_PASS` | — | SMTP password |
|
||||
| `SMTP_TOKEN` | — | OAuth2/App token (takes precedence over password) |
|
||||
| `SMTP_FROM` | — | Sender email address |
|
||||
| `SMTP_SECURE` | `false` | Use TLS |
|
||||
|
||||
### Reminders
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
|
||||
| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
|
||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||
|
||||
### Push Notifications (Shoutrrr)
|
||||
|
||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||
|
||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
|
||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
||||
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
||||
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
||||
|
||||
### Default User Settings
|
||||
|
||||
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
|
||||
|
||||
#### URL Examples
|
||||
|
||||
**ntfy** (free, self-hostable):
|
||||
```
|
||||
ntfy://ntfy.sh/your-topic
|
||||
ntfy://user:password@your-server.com/topic
|
||||
```
|
||||
|
||||
**Pushover** (free app for iOS/Android):
|
||||
```
|
||||
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||
```
|
||||
Get your keys at [pushover.net](https://pushover.net/):
|
||||
- **User Key**: Shown on your dashboard (top right)
|
||||
- **API Token**: Create an application → copy the API Token
|
||||
|
||||
**Gotify** (self-hosted):
|
||||
```
|
||||
gotify://your-server.com/TOKEN
|
||||
```
|
||||
|
||||
**Discord**:
|
||||
```
|
||||
discord://TOKEN@WEBHOOK_ID
|
||||
```
|
||||
|
||||
**Telegram**:
|
||||
```
|
||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||
```
|
||||
|
||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
- Full configuration reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
- Push notifications: [docs/PUSH_NOTIFICATIONS.md](docs/PUSH_NOTIFICATIONS.md)
|
||||
- Default user settings: [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||
|
||||
- Frontend: `http://localhost:5173` (hot reload)
|
||||
- Backend: `http://localhost:3000`
|
||||
For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
|
||||
ALTER TABLE `medications` ADD `obsolete_at` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `api_keys` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text(100) NOT NULL,
|
||||
`key_hash` text(128) NOT NULL,
|
||||
`token_prefix` text(24) DEFAULT '' NOT NULL,
|
||||
`scope` text(10) DEFAULT 'write' NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`last_used_at` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `intake_journal` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`dose_tracking_id` integer NOT NULL,
|
||||
`medication_id` integer NOT NULL,
|
||||
`scheduled_for` integer NOT NULL,
|
||||
`note` text NOT NULL,
|
||||
`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,
|
||||
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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
@@ -57,6 +57,69 @@
|
||||
"when": 1770659669121,
|
||||
"tag": "0007_add_share_stock_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1771160400000,
|
||||
"tag": "0008_add_obsolete_medications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1771694832866,
|
||||
"tag": "0010_add_dose_tracking_taken_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1772219947541,
|
||||
"tag": "0011_add_medication_form_lifecycle_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1772881208026,
|
||||
"tag": "0012_add_api_keys_and_package_amount_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1773348659979,
|
||||
"tag": "0013_add_share_medication_overview",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1775849300000,
|
||||
"tag": "0014_add_user_settings_timezone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1778962021119,
|
||||
"tag": "0015_add_intake_journal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1779044316043,
|
||||
"tag": "0016_add_share_allow_journal_notes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1656
-3240
File diff suppressed because it is too large
Load Diff
+30
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.10.3",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -19,31 +19,42 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@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.3.15",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
"tsx": "^4.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "2.6.5",
|
||||
"@esbuild-kit/core-utils": "3.3.2",
|
||||
"esbuild": "0.25.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
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,
|
||||
getDataDir,
|
||||
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 {
|
||||
@@ -65,8 +57,8 @@ let client: Client;
|
||||
try {
|
||||
client = createClient({ url });
|
||||
log.debug(`[DB] Database client created successfully`);
|
||||
} catch (err: any) {
|
||||
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
} catch (err: unknown) {
|
||||
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
|
||||
log.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -80,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
-385
@@ -1,388 +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: any) {
|
||||
return { success: false, error: err.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: any) {
|
||||
// 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.message?.includes("duplicate column") || err.message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// 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 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: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tables that might be missing (silently fail if already exists)
|
||||
const createTableMigrations = [
|
||||
// Added in v1.3.x - refill history tracking
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: any) {
|
||||
// Silently ignore "already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.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: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.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: any) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${e.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: any) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Repair failed: ${e.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";
|
||||
|
||||
@@ -41,8 +41,8 @@ export async function executeMigration(
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
} catch (err: unknown) {
|
||||
errors.push((err as Error).message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Client } from "@libsql/client";
|
||||
import type { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(
|
||||
database: ReturnType<typeof drizzle>
|
||||
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const alterMigrations = [
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
|
||||
`ALTER TABLE medications ADD COLUMN pill_form text`,
|
||||
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
|
||||
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
|
||||
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
|
||||
`ALTER TABLE medications ADD COLUMN total_pills integer`,
|
||||
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
|
||||
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
|
||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes 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 intake_journal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
scheduled_for INTEGER NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_key TEXT NOT NULL UNIQUE,
|
||||
sequence_id TEXT NOT NULL,
|
||||
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
|
||||
dose_ids_json TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
scheduled_for INTEGER,
|
||||
expires_at INTEGER NOT NULL,
|
||||
resolved_action TEXT,
|
||||
resolved_at INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
used_at INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'write',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_used_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: unknown) {
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postCreateAlterMigrations = [
|
||||
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||
];
|
||||
|
||||
for (const sql of postCreateAlterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: unknown) {
|
||||
if (!(e as Error).message?.includes("duplicate column")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createIndexMigrations = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||
];
|
||||
|
||||
for (const sql of createIndexMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: unknown) {
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e: unknown) {
|
||||
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Get the data directory path.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. DATA_DIR env var (set by docker-compose for containers)
|
||||
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
|
||||
* subdirectory -> use ../data (project root's data folder)
|
||||
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
|
||||
*/
|
||||
export function getDataDir(cwd: string = process.cwd()): string {
|
||||
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
|
||||
|
||||
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
|
||||
return resolve(cwd, "..", "data");
|
||||
}
|
||||
|
||||
return resolve(cwd, "data");
|
||||
}
|
||||
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = getDataDir(cwd);
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Client } from "@libsql/client";
|
||||
import {
|
||||
forEachScheduledOccurrenceInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getScheduleMatchWindowMs,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
||||
* [].toString() produced an empty string.
|
||||
*/
|
||||
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||
);
|
||||
repaired = result.rowsAffected;
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
|
||||
*/
|
||||
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
const medsResult = await client.execute(
|
||||
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
|
||||
);
|
||||
|
||||
if (medsResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
|
||||
if (dosesResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
|
||||
for (const row of dosesResult.rows) {
|
||||
const doseId = row.dose_id as string;
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)?.push({ id: row.id as number, doseId });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
for (const med of medsResult.rows) {
|
||||
const medId = med.id as number;
|
||||
const medDoses = dosesByMed.get(medId);
|
||||
if (!medDoses || medDoses.length === 0) continue;
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakes_json as string | null,
|
||||
{
|
||||
usageJson: (med.usage_json as string) || "[]",
|
||||
everyJson: (med.every_json as string) || "[]",
|
||||
startJson: (med.start_json as string) || "[]",
|
||||
},
|
||||
(med.intake_reminders_enabled as number) === 1
|
||||
);
|
||||
|
||||
if (intakes.length === 0) continue;
|
||||
|
||||
const validDatesByIntake = new Map<number, Set<number>>();
|
||||
for (let idx = 0; idx < intakes.length; idx++) {
|
||||
const intake = intakes[idx];
|
||||
const start = parseLocalDateTime(intake.start);
|
||||
const every = intake.every;
|
||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||
|
||||
const validDates = new Set<number>();
|
||||
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
|
||||
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||
});
|
||||
validDatesByIntake.set(idx, validDates);
|
||||
}
|
||||
|
||||
for (const dose of medDoses) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const intakeIdx = parseInt(parts[1], 10);
|
||||
const dateOnlyMs = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
|
||||
|
||||
const validDates = validDatesByIntake.get(intakeIdx);
|
||||
if (!validDates || validDates.has(dateOnlyMs)) continue;
|
||||
|
||||
const intake = intakes[intakeIdx];
|
||||
if (!intake) continue;
|
||||
|
||||
const halfInterval = getScheduleMatchWindowMs(intake);
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const validDate of validDates) {
|
||||
const dist = Math.abs(validDate - dateOnlyMs);
|
||||
if (dist < bestDist && dist <= halfInterval) {
|
||||
bestDist = dist;
|
||||
bestMatch = validDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch !== null) {
|
||||
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
|
||||
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
|
||||
|
||||
try {
|
||||
await client.execute({
|
||||
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
|
||||
args: [newDoseId, dose.id],
|
||||
});
|
||||
repaired++;
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
@@ -64,10 +64,23 @@ 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,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
last_reminder_med_name text,
|
||||
last_reminder_taken_by text,
|
||||
last_stock_reminder_sent text,
|
||||
last_stock_reminder_channel text,
|
||||
last_stock_reminder_med_names text,
|
||||
last_prescription_reminder_sent text,
|
||||
last_prescription_reminder_channel text,
|
||||
last_prescription_reminder_med_names text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
@@ -87,6 +100,7 @@ export function getTableCreationSQL(): string[] {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
|
||||
+102
-3
@@ -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),
|
||||
@@ -47,6 +52,13 @@ export const medications = sqliteTable("medications", {
|
||||
expiryDate: text("expiry_date"),
|
||||
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),
|
||||
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
|
||||
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
|
||||
@@ -93,10 +105,18 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||
// Current MedAssist versions no longer read or expose this setting in product flows.
|
||||
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// Whether shared schedule links also embed the medication overview section
|
||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||
// UI timeline visibility preferences
|
||||
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
|
||||
// Last notification tracking (intake reminders)
|
||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||
lastNotificationType: text("last_notification_type"),
|
||||
@@ -130,6 +150,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
|
||||
// =============================================================================
|
||||
@@ -141,10 +180,48 @@ export const shareTokens = sqliteTable("share_tokens", {
|
||||
token: text("token", { length: 64 }).notNull().unique(),
|
||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Notification Action Groups - Shared action state for reminder notifications
|
||||
// =============================================================================
|
||||
export const notificationActionGroups = sqliteTable("notification_action_groups", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
groupKey: text("group_key", { length: 255 }).notNull().unique(),
|
||||
sequenceId: text("sequence_id", { length: 255 }).notNull(),
|
||||
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
|
||||
doseIdsJson: text("dose_ids_json").notNull(),
|
||||
title: text("title", { length: 255 }).notNull(),
|
||||
message: text("message").notNull(),
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
resolvedAction: text("resolved_action", { length: 20 }),
|
||||
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Notification Action Tokens - Hashed tokens for public reminder responses
|
||||
// =============================================================================
|
||||
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
groupId: integer("group_id")
|
||||
.notNull()
|
||||
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
|
||||
kind: text("kind", { length: 20 }).notNull(),
|
||||
usedAt: integer("used_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking - Tracks when doses are marked as taken
|
||||
// =============================================================================
|
||||
@@ -156,7 +233,29 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Intake Journal - Optional owner-scoped note for a tracked dose event
|
||||
// =============================================================================
|
||||
export const intakeJournal = sqliteTable("intake_journal", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
doseTrackingId: integer("dose_tracking_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => doseTracking.id, { onDelete: "cascade" }),
|
||||
medicationId: integer("medication_id")
|
||||
.notNull()
|
||||
.references(() => medications.id, { onDelete: "cascade" }),
|
||||
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
|
||||
note: text("note").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -109,6 +109,8 @@ type TranslationKeys = {
|
||||
stockTitle: string;
|
||||
stockTitleMultiple: string;
|
||||
intakeTitle: string;
|
||||
intakeTakenConfirmation: string;
|
||||
intakeSkippedConfirmation: string;
|
||||
pillsLeft: string;
|
||||
daysLeft: string;
|
||||
pillsAt: string;
|
||||
@@ -179,6 +181,10 @@ type TranslationKeys = {
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
puffs: string;
|
||||
injections: string;
|
||||
units: string;
|
||||
ml: string;
|
||||
blister: string;
|
||||
blisters: string;
|
||||
day: string;
|
||||
@@ -207,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "The following medications are running low and should be reordered soon:",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
pills: "Pills",
|
||||
pills: "Available",
|
||||
days: "Days",
|
||||
runsOut: "Runs Out",
|
||||
},
|
||||
@@ -232,6 +238,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
|
||||
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
|
||||
intakeTakenConfirmation: "✅ This dose was marked as taken.",
|
||||
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
|
||||
pillsLeft: "{count} pills",
|
||||
daysLeft: "{count} days left",
|
||||
pillsAt: "{count} pills at {time}",
|
||||
@@ -299,6 +307,10 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
puffs: "puffs",
|
||||
injections: "injections",
|
||||
units: "units",
|
||||
ml: "ml",
|
||||
blister: "blister",
|
||||
blisters: "blisters",
|
||||
day: "day",
|
||||
@@ -325,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
pills: "Tabletten",
|
||||
pills: "Verfuegbar",
|
||||
days: "Tage",
|
||||
runsOut: "Aufgebraucht",
|
||||
},
|
||||
@@ -351,6 +363,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
|
||||
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
|
||||
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
|
||||
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
|
||||
pillsLeft: "{count} Tabletten",
|
||||
daysLeft: "{count} Tage übrig",
|
||||
pillsAt: "{count} Tabletten um {time}",
|
||||
@@ -420,6 +434,10 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
puffs: "Hübe",
|
||||
injections: "Injektionen",
|
||||
units: "Einheiten",
|
||||
ml: "ml",
|
||||
blister: "Blister",
|
||||
blisters: "Blister",
|
||||
day: "Tag",
|
||||
|
||||
+186
-11
@@ -1,29 +1,40 @@
|
||||
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 { intakeJournalRoutes } from "./routes/intake-journal.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
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 {
|
||||
@@ -44,6 +55,96 @@ 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;
|
||||
}
|
||||
|
||||
function buildHelmetOptions(_isProduction: boolean) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isPublicNotificationActionPath(url: string | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
|
||||
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
|
||||
}
|
||||
|
||||
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
|
||||
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: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
|
||||
{ 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;
|
||||
@@ -56,6 +157,7 @@ export async function createApp(options?: {
|
||||
refreshTtlDays?: number;
|
||||
isProduction?: boolean;
|
||||
imagesDir?: string;
|
||||
openApiDocsEnabled?: boolean;
|
||||
}): Promise<FastifyInstance> {
|
||||
const opts = {
|
||||
logLevel: options?.logLevel ?? "info",
|
||||
@@ -68,10 +170,20 @@ 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
|
||||
@@ -87,16 +199,35 @@ export async function createApp(options?: {
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(opts.isProduction));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: opts.corsOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
// JWT plugin
|
||||
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
||||
await app.register(jwt, jwtConfig);
|
||||
await app.register(jwtPlugin, jwtConfig);
|
||||
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
await registerApiDocs(app, opts.openApiDocsEnabled);
|
||||
|
||||
// Only register static if directory exists
|
||||
if (existsSync(opts.imagesDir)) {
|
||||
@@ -110,14 +241,19 @@ 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(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -136,9 +272,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);
|
||||
@@ -161,8 +303,26 @@ app.decorate("config", {
|
||||
});
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(rateLimit, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: "1 minute",
|
||||
@@ -171,9 +331,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/",
|
||||
@@ -182,14 +343,19 @@ 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(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
@@ -200,13 +366,22 @@ const start = async () => {
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
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),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
+145
-9
@@ -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?.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,9 +266,21 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (err: any) {
|
||||
request.authContext = {
|
||||
method: "session",
|
||||
scope: "write",
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Re-throw our own errors
|
||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(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;
|
||||
}
|
||||
// JWT verification failed
|
||||
|
||||
+56
-18
@@ -10,10 +10,15 @@ const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("3000"),
|
||||
.default("3000")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
PUBLIC_APP_URL: z.string().url().optional(),
|
||||
OPENAPI_DOCS_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.optional(),
|
||||
|
||||
// ==========================================================================
|
||||
// Auth Configuration
|
||||
@@ -21,14 +26,18 @@ const EnvSchema = z.object({
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
// Disable local auth when using SSO only
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
// Disable username/password form login (useful for OIDC-only setups)
|
||||
FORM_LOGIN_ENABLED: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
@@ -38,20 +47,20 @@ const EnvSchema = z.object({
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
.default("15")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
.default("7")
|
||||
.transform((v) => parseInt(v, 10)),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -59,16 +68,19 @@ const EnvSchema = z.object({
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
});
|
||||
|
||||
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 +140,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();
|
||||
}
|
||||
);
|
||||
}
|
||||
+316
-73
@@ -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
|
||||
@@ -53,6 +60,7 @@ const sensitiveRateLimitConfig = {
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(50, "Username must be at most 50 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
@@ -63,7 +71,7 @@ const registerSchema = z.object({
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
username: z.string().trim().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
});
|
||||
@@ -77,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;
|
||||
@@ -89,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
|
||||
@@ -100,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
|
||||
@@ -113,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",
|
||||
});
|
||||
}
|
||||
@@ -147,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,
|
||||
@@ -167,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();
|
||||
@@ -175,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);
|
||||
@@ -221,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` }
|
||||
);
|
||||
@@ -235,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
|
||||
@@ -271,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;
|
||||
@@ -280,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,
|
||||
});
|
||||
|
||||
@@ -293,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||
}
|
||||
|
||||
@@ -313,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 }
|
||||
);
|
||||
@@ -340,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));
|
||||
@@ -365,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
|
||||
@@ -394,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;
|
||||
@@ -404,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",
|
||||
});
|
||||
}
|
||||
@@ -452,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;
|
||||
@@ -461,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
|
||||
@@ -508,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;
|
||||
@@ -521,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));
|
||||
@@ -544,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;
|
||||
@@ -554,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
|
||||
|
||||
+1060
-119
File diff suppressed because it is too large
Load Diff
+786
-338
File diff suppressed because it is too large
Load Diff
@@ -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,373 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import {
|
||||
deleteIntakeJournalForDoseEvent,
|
||||
getIntakeJournalForDoseEvent,
|
||||
isTrackedDoseIdFormat,
|
||||
listIntakeJournalEntriesForUser,
|
||||
resolveTrackedDoseEventForUser,
|
||||
upsertIntakeJournalForDoseEvent,
|
||||
} from "../services/intake-journal-service.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
|
||||
const doseIdParamsSchema = {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const intakeJournalEntrySchema = {
|
||||
type: "object",
|
||||
required: [
|
||||
"doseTrackingId",
|
||||
"doseId",
|
||||
"medicationId",
|
||||
"medicationName",
|
||||
"scheduledFor",
|
||||
"dismissed",
|
||||
"takenSource",
|
||||
"note",
|
||||
"updatedAt",
|
||||
],
|
||||
properties: {
|
||||
doseTrackingId: { type: "integer" },
|
||||
doseId: { type: "string" },
|
||||
medicationId: { type: "integer" },
|
||||
medicationName: { type: "string" },
|
||||
scheduledFor: { type: "string", format: "date-time" },
|
||||
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||
dismissed: { type: "boolean" },
|
||||
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||
markedBy: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalEventResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entry"],
|
||||
properties: {
|
||||
entry: intakeJournalEntrySchema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalHistoryResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entries"],
|
||||
properties: {
|
||||
entries: {
|
||||
type: "array",
|
||||
items: intakeJournalEntrySchema,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const intakeJournalHistoryQuerySchema = z.object({
|
||||
medicationId: z.coerce.number().int().positive().optional(),
|
||||
from: z.string().trim().min(1).optional(),
|
||||
to: z.string().trim().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||
});
|
||||
|
||||
const intakeJournalUpsertSchema = z.object({
|
||||
note: z.string().max(4000),
|
||||
});
|
||||
|
||||
function getValidationErrorMessage(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return "Invalid request payload";
|
||||
}
|
||||
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value: string | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dismissed && value.getTime() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function buildJournalEntryDto(input: {
|
||||
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
|
||||
? T extends null
|
||||
? never
|
||||
: T
|
||||
: never;
|
||||
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
|
||||
}) {
|
||||
const { event, journalEntry } = input;
|
||||
|
||||
return {
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
doseId: event.doseId,
|
||||
medicationId: event.medicationId,
|
||||
medicationName: event.medicationName,
|
||||
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
|
||||
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
|
||||
dismissed: event.dismissed,
|
||||
takenSource: event.takenSource,
|
||||
markedBy: event.markedBy,
|
||||
note: journalEntry?.note ?? null,
|
||||
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
|
||||
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
export async function intakeJournalRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
|
||||
|
||||
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
|
||||
"/intake-journal",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "List intake journal history for the current owner",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medicationId: { type: "integer", minimum: 1 },
|
||||
from: { type: "string", format: "date-time" },
|
||||
to: { type: "string", format: "date-time" },
|
||||
limit: { type: "integer", minimum: 1, maximum: 200 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: intakeJournalHistoryResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const from = parseOptionalDate(parsed.data.from);
|
||||
if (parsed.data.from && !from) {
|
||||
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
|
||||
}
|
||||
|
||||
const to = parseOptionalDate(parsed.data.to);
|
||||
if (parsed.data.to && !to) {
|
||||
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
|
||||
}
|
||||
|
||||
if (from && to && from.getTime() > to.getTime()) {
|
||||
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
|
||||
}
|
||||
|
||||
const entries = await listIntakeJournalEntriesForUser({
|
||||
userId,
|
||||
medicationId: parsed.data.medicationId,
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
doseTrackingId: entry.doseTrackingId,
|
||||
doseId: entry.doseId,
|
||||
medicationId: entry.medicationId,
|
||||
medicationName: entry.medicationName,
|
||||
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
|
||||
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
|
||||
dismissed: entry.dismissed,
|
||||
takenSource: entry.takenSource,
|
||||
markedBy: entry.markedBy,
|
||||
note: entry.note,
|
||||
updatedAt: entry.updatedAt.toISOString(),
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Get intake journal context for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
response: {
|
||||
200: intakeJournalEventResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
|
||||
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Create or update an intake journal note for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["note"],
|
||||
properties: {
|
||||
note: { type: "string", maxLength: 4000 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
response: {
|
||||
200: intakeJournalEventResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||
userId,
|
||||
doseId,
|
||||
note: parsed.data.note,
|
||||
});
|
||||
|
||||
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/intake-journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
tags: ["intake-journal"],
|
||||
summary: "Delete an intake journal note for a tracked dose event",
|
||||
security: intakeJournalEndpointSecurity,
|
||||
params: doseIdParamsSchema,
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
required: ["success"],
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { doseId } = request.params;
|
||||
|
||||
if (!isTrackedDoseIdFormat(doseId)) {
|
||||
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
|
||||
if (!deleted) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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", "injections"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const packageOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
description: { type: "string" },
|
||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
|
||||
packCount: { type: "integer", minimum: 1 },
|
||||
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
+1492
-712
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,670 @@
|
||||
import formbody from "@fastify/formbody";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
|
||||
import { getTranslations, type Language } from "../i18n/translations.js";
|
||||
import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
|
||||
import {
|
||||
getNotificationActionTokenRecord,
|
||||
isNotificationActionExpired,
|
||||
} from "../services/notification-actions-service.js";
|
||||
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
|
||||
import { sendPushNotification } from "../services/notifications/delivery.js";
|
||||
import { sanitizeNotificationUrl } from "../services/settings-service.js";
|
||||
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||
|
||||
const querySchema = z.object({
|
||||
action: z.enum(["taken", "skip", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
type NotificationMutationAction = "taken" | "skip";
|
||||
|
||||
function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
|
||||
if (action === "taken") {
|
||||
return "taken";
|
||||
}
|
||||
|
||||
if (action === "skip" || action === "dismiss") {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
|
||||
const reminderFooterSeparator = "\n\n---\n";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function toHtmlText(value: string): string {
|
||||
return escapeHtml(value).replaceAll("\n", "<br />");
|
||||
}
|
||||
|
||||
function getLanguage(language: string | null): Language {
|
||||
return language === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function wantsHtml(request: FastifyRequest): boolean {
|
||||
return request.headers.accept?.includes("text/html") ?? false;
|
||||
}
|
||||
|
||||
function applyPublicNotificationCorsHeaders(
|
||||
request: FastifyRequest,
|
||||
reply: { header: (name: string, value: string) => unknown }
|
||||
) {
|
||||
const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
|
||||
reply.header("access-control-allow-origin", requestOrigin);
|
||||
reply.header("access-control-allow-methods", publicNotificationActionMethods);
|
||||
reply.header("access-control-allow-headers", "content-type");
|
||||
if (requestOrigin !== "*") {
|
||||
reply.header("vary", "Origin");
|
||||
}
|
||||
}
|
||||
|
||||
function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
|
||||
if (resolvedAction === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
|
||||
: "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
|
||||
: "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
function getActionRecordedText(language: Language, action: NotificationMutationAction) {
|
||||
if (action === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
function buildReplacementReminderMessage(
|
||||
language: Language,
|
||||
action: NotificationMutationAction,
|
||||
originalMessage: string
|
||||
): string {
|
||||
const tr = getTranslations(language);
|
||||
const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation;
|
||||
const separatorIndex = originalMessage.indexOf(reminderFooterSeparator);
|
||||
|
||||
if (separatorIndex >= 0) {
|
||||
const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd();
|
||||
const footer = originalMessage.slice(separatorIndex);
|
||||
return `${beforeFooter}\n\n${confirmationLine}${footer}`;
|
||||
}
|
||||
|
||||
return `${originalMessage.trimEnd()}\n\n${confirmationLine}`;
|
||||
}
|
||||
|
||||
async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearUrl = new URL(sanitized.url);
|
||||
clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(clearUrl.toString(), {
|
||||
method: "PUT",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const normalizedSequenceId = sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteUrl = new URL(sanitized.url);
|
||||
deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(deleteUrl.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceNtfyNotificationSequence(options: {
|
||||
userId: number;
|
||||
sequenceId: string;
|
||||
language: Language;
|
||||
title: string;
|
||||
originalMessage: string;
|
||||
action: NotificationMutationAction;
|
||||
viewUrl: string | null;
|
||||
}): Promise<{ replaced: boolean; providerMessageId?: string }> {
|
||||
const normalizedSequenceId = options.sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, options.userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, {
|
||||
actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined,
|
||||
viewUrl: options.viewUrl ?? undefined,
|
||||
clickUrl: options.viewUrl ?? undefined,
|
||||
sequenceId: normalizedSequenceId,
|
||||
tags: ["pill"],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
||||
}
|
||||
|
||||
return { replaced: true, providerMessageId: result.providerMessageId };
|
||||
}
|
||||
|
||||
function renderPage(options: {
|
||||
language: Language;
|
||||
title: string;
|
||||
message: string;
|
||||
bodyTitle: string;
|
||||
bodyText: string;
|
||||
viewUrl: string | null;
|
||||
actionButtons: Array<{ label: string; formAction?: string }>;
|
||||
}): string {
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
const forms =
|
||||
options.actionButtons.length > 0
|
||||
? `<div class="actions">${options.actionButtons
|
||||
.map((button) => {
|
||||
const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
|
||||
return `<form method="POST"${formAction}><button type="submit">${escapeHtml(button.label)}</button></form>`;
|
||||
})
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const viewLink = options.viewUrl
|
||||
? `<p><a href="${escapeHtml(options.viewUrl)}">${escapeHtml(labels.view)}</a></p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${options.language}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(options.bodyTitle)}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f4f5f7; color: #1f2937; }
|
||||
main { max-width: 640px; margin: 48px auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); }
|
||||
h1 { margin-top: 0; font-size: 1.5rem; }
|
||||
.card { padding: 16px; border-radius: 12px; background: #f9fafb; margin: 16px 0 24px; }
|
||||
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
form { margin: 0; }
|
||||
button, a { display: inline-flex; align-items: center; justify-content: center; min-width: 140px; border-radius: 10px; padding: 12px 16px; font: inherit; text-decoration: none; }
|
||||
button { border: none; background: #0f766e; color: white; cursor: pointer; }
|
||||
form:last-of-type button { background: #475569; }
|
||||
a { background: #e2e8f0; color: #0f172a; }
|
||||
p { line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(options.bodyTitle)}</h1>
|
||||
<p>${escapeHtml(options.bodyText)}</p>
|
||||
<div class="card">
|
||||
<strong>${escapeHtml(options.title)}</strong>
|
||||
<p>${toHtmlText(options.message)}</p>
|
||||
</div>
|
||||
${forms}
|
||||
${viewLink}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
|
||||
const normalizedTokenAction = normalizeNotificationAction(tokenKind);
|
||||
if (normalizedTokenAction) {
|
||||
return normalizedTokenAction;
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(request.query);
|
||||
if (parsedQuery.success && parsedQuery.data.action) {
|
||||
return normalizeNotificationAction(parsedQuery.data.action);
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
if (body && typeof body === "object" && "action" in body) {
|
||||
const actionValue = (body as { action?: unknown }).action;
|
||||
if (typeof actionValue === "string") {
|
||||
return normalizeNotificationAction(actionValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationActionLogContext(
|
||||
record: Awaited<ReturnType<typeof getNotificationActionTokenRecord>> extends infer T ? Exclude<T, null> : never,
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
groupId: record.group.id,
|
||||
userId: record.group.userId,
|
||||
tokenKind: record.token.kind,
|
||||
doseCount: record.doseIds.length,
|
||||
hasViewUrl: record.viewUrl !== null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
method: request.method,
|
||||
hasOrigin: typeof request.headers.origin === "string",
|
||||
expectsHtml: wantsHtml(request),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export async function notificationActionRoutes(app: FastifyInstance) {
|
||||
await app.register(formbody);
|
||||
|
||||
applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
|
||||
|
||||
app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
404: genericErrorSchema,
|
||||
405: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action GET request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected direct GET for unresolved non-respond token"
|
||||
);
|
||||
return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const labels = getNotificationActionLabels(language);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
let bodyTitle: string;
|
||||
let bodyText: string;
|
||||
let actionButtons: Array<{ label: string; formAction?: string }> = [];
|
||||
|
||||
if (resolvedAction) {
|
||||
({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
|
||||
} else {
|
||||
if (record.token.kind === "taken") {
|
||||
bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
|
||||
: "Confirm that this dose should be marked as taken.";
|
||||
actionButtons = [{ label: labels.taken }];
|
||||
} else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
|
||||
bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
|
||||
: "Confirm that this intake should be marked as skipped.";
|
||||
actionButtons = [{ label: labels.skip }];
|
||||
} else {
|
||||
bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
|
||||
: "Choose an action for this medication reminder.";
|
||||
actionButtons = [
|
||||
{ label: labels.taken, formAction: "?action=taken" },
|
||||
{ label: labels.skip, formAction: "?action=skip" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle,
|
||||
bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: resolvedAction ? [] : actionButtons,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
409: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action POST request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
const action = parseRequestedAction(request, record.token.kind);
|
||||
if (!action) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Missing or invalid action for notification action POST request"
|
||||
);
|
||||
return reply.status(400).send({ error: "Notification action is required" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
if (resolvedAction) {
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
|
||||
"[NotificationActions] Ignored notification action because it was already resolved"
|
||||
);
|
||||
const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: alreadyProcessedText.bodyTitle,
|
||||
bodyText: alreadyProcessedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
action: resolvedAction,
|
||||
alreadyProcessed: true,
|
||||
message: alreadyProcessedText.jsonMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "taken") {
|
||||
for (const [doseIndex, doseId] of record.doseIds.entries()) {
|
||||
const result = await markDoseTakenForUser({
|
||||
userId: record.group.userId,
|
||||
doseId,
|
||||
source: "notification",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
failedDoseIndex: doseIndex,
|
||||
code: result.code,
|
||||
}),
|
||||
"[NotificationActions] Failed to record taken notification action"
|
||||
);
|
||||
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
await db
|
||||
.update(notificationActionTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(notificationActionTokens.id, record.token.id));
|
||||
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action }),
|
||||
"[NotificationActions] Recorded notification action"
|
||||
);
|
||||
|
||||
const recordedText = getActionRecordedText(language, action);
|
||||
let replacedNtfyNotification = false;
|
||||
const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
|
||||
|
||||
try {
|
||||
const replacementResult = await replaceNtfyNotificationSequence({
|
||||
userId: record.group.userId,
|
||||
sequenceId: record.group.sequenceId,
|
||||
language,
|
||||
title: record.group.title,
|
||||
originalMessage: record.group.message,
|
||||
action,
|
||||
viewUrl: record.viewUrl,
|
||||
});
|
||||
replacedNtfyNotification = replacementResult.replaced;
|
||||
|
||||
if (replacementResult.providerMessageId) {
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
}
|
||||
|
||||
if (
|
||||
replacementResult.replaced &&
|
||||
previousNtfyMessageId.length > 0 &&
|
||||
previousNtfyMessageId !== replacementResult.providerMessageId
|
||||
) {
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
originalMessageId: previousNtfyMessageId,
|
||||
error,
|
||||
}),
|
||||
"[NotificationActions] Failed to delete original ntfy notification after replacement"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
"[NotificationActions] Failed to replace ntfy notification after resolved action"
|
||||
);
|
||||
}
|
||||
|
||||
if (!replacedNtfyNotification) {
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
"[NotificationActions] Failed to delete ntfy notification after resolved action"
|
||||
);
|
||||
try {
|
||||
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (clearError) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
|
||||
"[NotificationActions] Failed to clear ntfy notification after delete fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: recordedText.bodyTitle,
|
||||
bodyText: recordedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({ success: true, action });
|
||||
}
|
||||
);
|
||||
}
|
||||
+97
-61
@@ -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,83 +66,110 @@ 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: any) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}`);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -159,20 +189,26 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
request.log.error("[OIDC] Missing sub claim in token response");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
|
||||
// Extract username from configured claim
|
||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||
const username =
|
||||
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
||||
(userInfo as Record<string, string>)[usernameClaim] ||
|
||||
userInfo.preferred_username ||
|
||||
userInfo.email ||
|
||||
userInfo.sub;
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
request.log.error(
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Clean cookies
|
||||
@@ -183,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||
|
||||
if (!user) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -202,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);
|
||||
|
||||
@@ -210,9 +246,9 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// In dev: CORS_ORIGINS contains the frontend URL
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -252,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;
|
||||
}
|
||||
|
||||
@@ -276,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(
|
||||
@@ -286,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` }
|
||||
);
|
||||
|
||||
+857
-496
File diff suppressed because it is too large
Load Diff
+287
-104
@@ -6,20 +6,100 @@ 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,
|
||||
isDiscreteCountPackageType,
|
||||
isPackageAmountPackageType,
|
||||
normalizePackageType,
|
||||
} from "../utils/package-profiles.js";
|
||||
|
||||
const refillSchema = z
|
||||
.object({
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: z.number().int().min(0).default(0),
|
||||
quantityAdded: z.number().int().min(0).default(0),
|
||||
usePrescription: z.boolean().default(false),
|
||||
})
|
||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||
message: "Must add at least one pack or some loose pills",
|
||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
|
||||
message: "Must add at least one pack or some quantity",
|
||||
});
|
||||
|
||||
const refillBodyOpenApiSchema = {
|
||||
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,129 +115,232 @@ 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 { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const isPackageAmountPackage = isPackageAmountPackageType(packageType);
|
||||
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
if (usePrescription) {
|
||||
if (!(med.prescriptionEnabled ?? false)) {
|
||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||
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;
|
||||
|
||||
const requestedPackAdds = Math.max(0, packsAdded);
|
||||
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||
let effectivePacksAdded = requestedPackAdds;
|
||||
if (isDiscreteCountPackage) {
|
||||
effectivePacksAdded = 0;
|
||||
}
|
||||
const remaining = med.prescriptionRemainingRefills ?? 0;
|
||||
if (remaining <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
const totalPillsAdded = isAmountBased
|
||||
? effectiveLoosePillsAdded
|
||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||
|
||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||
}
|
||||
}
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + packsAdded;
|
||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||
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 (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
}
|
||||
}
|
||||
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
const refillBaselineAt = new Date();
|
||||
const baselineStockBeforeRefill = isAmountBased
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
.set({
|
||||
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||
let newPackCount = med.packCount + effectivePacksAdded;
|
||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||
let newTotalAmount = med.totalPills ?? med.looseTablets;
|
||||
|
||||
if (isDiscreteCountPackage) {
|
||||
newLooseTablets = targetCurrentStock;
|
||||
newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
|
||||
newStockAdjustment = 0;
|
||||
} else if (isPackageAmountPackage) {
|
||||
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
|
||||
newLooseTablets = targetCurrentStock;
|
||||
newTotalAmount = targetCurrentStock;
|
||||
newStockAdjustment = 0;
|
||||
} else {
|
||||
const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
|
||||
newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
|
||||
}
|
||||
|
||||
let consumedRefills = 0;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isDiscreteCountPackage ? 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,
|
||||
stockAdjustment: 0, // Reset offset since we're adding to base stock
|
||||
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
|
||||
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,
|
||||
loosePillsAdded,
|
||||
usedPrescription: usePrescription,
|
||||
})
|
||||
.returning();
|
||||
if (isPackageAmountPackage) {
|
||||
updatePayload.totalPills = newTotalAmount;
|
||||
updatePayload.packageAmountValue = amountPerPackage;
|
||||
}
|
||||
|
||||
// Calculate pills added for response (packageType-aware)
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
|
||||
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,
|
||||
loosePillsAdded,
|
||||
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 isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const pillsPerPack = isDiscreteCountPackage ? 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,
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { and, eq, gte, lt } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
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),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const hasStartDate = typeof value.startDate === "string";
|
||||
const hasEndDate = typeof value.endDate === "string";
|
||||
|
||||
if (hasStartDate !== hasEndDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "startDate and endDate must be provided together",
|
||||
path: hasStartDate ? ["endDate"] : ["startDate"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasStartDate || !hasEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateValue = value.startDate!;
|
||||
const endDateValue = value.endDate!;
|
||||
const startDate = new Date(startDateValue);
|
||||
const endDate = new Date(endDateValue);
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid date range",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const reportDataBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["medicationIds"],
|
||||
properties: {
|
||||
medicationIds: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 100,
|
||||
items: { type: "integer", minimum: 1 },
|
||||
},
|
||||
startDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
endDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
takenByFilter: {
|
||||
type: "array",
|
||||
maxItems: 50,
|
||||
items: { type: "string", minLength: 1, maxLength: 100 },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
medicationIds: [1, 3, 5],
|
||||
startDate: "2026-05-01T00:00:00.000Z",
|
||||
endDate: "2026-06-01T00:00:00.000Z",
|
||||
takenByFilter: ["Daniel"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getPersonTagKey(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
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(getPersonTagKey(takenBy));
|
||||
}
|
||||
|
||||
function getDoseScheduledAtMs(doseId: string): number | null {
|
||||
const match = trackedDoseIdPattern.exec(doseId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduledAtMs = Number.parseInt(match[3], 10);
|
||||
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
|
||||
}
|
||||
|
||||
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
|
||||
if (!range) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (timestampMs === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timestampMs >= range.startMs && timestampMs < range.endMs;
|
||||
}
|
||||
|
||||
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" },
|
||||
quantityAdded: { 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) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
||||
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, startDate, endDate, takenByFilter } = parsed.data;
|
||||
const normalizedTakenByFilter = takenByFilter?.length
|
||||
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
|
||||
: null;
|
||||
const dateRange =
|
||||
startDate && endDate
|
||||
? {
|
||||
startMs: new Date(startDate).getTime(),
|
||||
endMs: new Date(endDate).getTime(),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db
|
||||
.select({
|
||||
id: medications.id,
|
||||
packageType: medications.packageType,
|
||||
blistersPerPack: medications.blistersPerPack,
|
||||
pillsPerBlister: medications.pillsPerBlister,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
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));
|
||||
|
||||
// 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 (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) 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;
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
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);
|
||||
|
||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||
const medication = medMap.get(medId);
|
||||
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||
|
||||
// Get refills for this medication scoped to the authenticated user.
|
||||
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
|
||||
if (dateRange) {
|
||||
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
|
||||
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
|
||||
}
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(and(...refillFilters));
|
||||
|
||||
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,
|
||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
+640
-402
File diff suppressed because it is too large
Load Diff
+581
-141
@@ -1,12 +1,21 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, 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 { redactTokenForLog } from "../utils/redaction.js";
|
||||
import {
|
||||
getAllTakenByForMedication,
|
||||
parseIntakesJson,
|
||||
@@ -14,17 +23,161 @@ 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
|
||||
// =============================================================================
|
||||
const createShareSchema = z.object({
|
||||
takenBy: z.string().min(1, "takenBy is required"),
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
expiryDays: z
|
||||
.union([z.number().int().min(1).max(365), z.null()])
|
||||
.optional()
|
||||
.default(null),
|
||||
allowJournalNotes: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
{ bearerAuth: [] },
|
||||
{ cookieAuth: [] },
|
||||
];
|
||||
|
||||
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||
|
||||
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
|
||||
if (expiryDays == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
|
||||
const isoValue = toIsoTimestamp(value);
|
||||
return isoValue != null && new Date(isoValue).getTime() < Date.now();
|
||||
}
|
||||
|
||||
const createShareBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||
allowJournalNotes: { type: "boolean", default: false },
|
||||
expiryDays: {
|
||||
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
example: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 14,
|
||||
allowJournalNotes: true,
|
||||
expiryDays: 30,
|
||||
},
|
||||
} 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" },
|
||||
allowJournalNotes: { 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;
|
||||
|
||||
const shareListResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
shareLinks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
shareUrl: { type: "string" },
|
||||
},
|
||||
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["shareLinks"],
|
||||
} as const;
|
||||
|
||||
const ownerTokenParamsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
} 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,139 +198,292 @@ 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;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
// 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: tokenRef=${tokenRef}`);
|
||||
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: tokenRef=${tokenRef}, 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,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
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,
|
||||
};
|
||||
});
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 60,
|
||||
timeWindow: "1 minute",
|
||||
errorResponseBuilder: () => ({ error: "rate_limited" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
if (!shareTokenPattern.test(token)) {
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
|
||||
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: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, 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" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
|
||||
const expiresAt = resolveExpiryDate(expiryDays);
|
||||
|
||||
// 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(
|
||||
@@ -195,63 +501,197 @@ 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) {
|
||||
const existingTokenRef = redactTokenForLog(existingShare.token);
|
||||
await db
|
||||
.update(shareTokens)
|
||||
.set({ scheduleDays, expiresAt, allowJournalNotes })
|
||||
.where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
// 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,
|
||||
allowJournalNotes,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share - PROTECTED: List active share links for current owner
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get(
|
||||
"/share",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: shareListResponseSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const shares = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(eq(shareTokens.userId, userId))
|
||||
.orderBy(desc(shareTokens.createdAt));
|
||||
|
||||
return {
|
||||
shareLinks: shares
|
||||
.filter((share) => !isExpiredTimestamp(share.expiresAt))
|
||||
.map((share) => ({
|
||||
token: share.token,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
|
||||
expiresAt: toIsoTimestamp(share.expiresAt),
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
shareUrl: `/share/${share.token}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token - PROTECTED: Revoke an existing share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string } }>(
|
||||
"/share/:token",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
params: ownerTokenParamsSchema,
|
||||
response: {
|
||||
204: { type: "null" },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const [share] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
|
||||
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
export type DoseTrackingSource = "manual" | "automatic" | "notification";
|
||||
|
||||
export type MarkDoseTakenResult =
|
||||
| {
|
||||
success: true;
|
||||
status: "marked" | "already_taken";
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type DismissDosesResult = {
|
||||
success: true;
|
||||
dismissedCount: number;
|
||||
alreadyTakenCount: number;
|
||||
};
|
||||
|
||||
export type SkipDosesResult = {
|
||||
success: true;
|
||||
skippedCount: number;
|
||||
alreadySkippedCount: number;
|
||||
switchedFromTakenCount: number;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
function hasRealTakenTimestamp(takenAt: Date | null): boolean {
|
||||
return takenAt instanceof Date && takenAt.getTime() > 0;
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: { userId: number; doseId: string }): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(options.doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
|
||||
const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
|
||||
const scheduledOccurrenceMs = intake
|
||||
? (() => {
|
||||
const doseDate = new Date(parsedDose.timestampMs);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
return new Date(
|
||||
doseDate.getFullYear(),
|
||||
doseDate.getMonth(),
|
||||
doseDate.getDate(),
|
||||
intakeStart.getHours(),
|
||||
intakeStart.getMinutes(),
|
||||
intakeStart.getSeconds(),
|
||||
intakeStart.getMilliseconds()
|
||||
).getTime();
|
||||
})()
|
||||
: parsedDose.timestampMs;
|
||||
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
|
||||
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
|
||||
|
||||
return (
|
||||
computeMedicationCurrentStock({
|
||||
medication,
|
||||
doses,
|
||||
stockCalculationMode,
|
||||
nowMs: stockBeforeDoseMs,
|
||||
}) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function markDoseTakenForUser(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
source: DoseTrackingSource;
|
||||
markedBy?: string | null;
|
||||
}): Promise<MarkDoseTakenResult> {
|
||||
const parsedDose = parseDoseId(input.doseId);
|
||||
if (!parsedDose) {
|
||||
return {
|
||||
success: false,
|
||||
code: "INVALID_DOSE",
|
||||
message: "Invalid dose ID",
|
||||
};
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||
|
||||
if (existing && !existing.dismissed) {
|
||||
return { success: true, status: "already_taken" };
|
||||
}
|
||||
|
||||
if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
|
||||
return { success: true, status: "already_taken" };
|
||||
}
|
||||
|
||||
if (existing?.dismissed) {
|
||||
return {
|
||||
success: false,
|
||||
code: "ALREADY_SKIPPED",
|
||||
message: "Dose is already skipped",
|
||||
};
|
||||
}
|
||||
|
||||
const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
|
||||
if (outOfStock) {
|
||||
return {
|
||||
success: false,
|
||||
code: "OUT_OF_STOCK",
|
||||
message: "Medication is out of stock",
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId: input.doseId,
|
||||
takenAt: new Date(),
|
||||
markedBy: input.markedBy ?? null,
|
||||
takenSource: input.source,
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
return { success: true, status: "marked" };
|
||||
}
|
||||
|
||||
export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise<SkipDosesResult> {
|
||||
let skippedCount = 0;
|
||||
let alreadySkippedCount = 0;
|
||||
let switchedFromTakenCount = 0;
|
||||
|
||||
for (const doseId of input.doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.dismissed) {
|
||||
alreadySkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||
switchedFromTakenCount++;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({
|
||||
dismissed: true,
|
||||
takenAt: new Date(0),
|
||||
takenSource: "manual",
|
||||
markedBy: null,
|
||||
})
|
||||
.where(eq(doseTracking.id, existing.id));
|
||||
skippedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
skippedCount,
|
||||
alreadySkippedCount,
|
||||
switchedFromTakenCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise<DismissDosesResult> {
|
||||
let dismissedCount = 0;
|
||||
let alreadyTakenCount = 0;
|
||||
|
||||
for (const doseId of input.doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.dismissed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||
alreadyTakenCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({
|
||||
dismissed: true,
|
||||
takenAt: new Date(0),
|
||||
takenSource: "manual",
|
||||
markedBy: null,
|
||||
})
|
||||
.where(eq(doseTracking.id, existing.id));
|
||||
dismissedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dismissedCount,
|
||||
alreadyTakenCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { intakeJournal } from "../db/schema.js";
|
||||
|
||||
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
|
||||
|
||||
export type IntakeJournalExportPayload = {
|
||||
journalNote: string;
|
||||
journalCreatedAt?: string | null;
|
||||
journalUpdatedAt?: string | null;
|
||||
};
|
||||
|
||||
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listIntakeJournalExportPayloadsForUser(
|
||||
userId: number
|
||||
): Promise<Map<number, IntakeJournalExportPayload>> {
|
||||
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.doseTrackingId,
|
||||
{
|
||||
journalNote: row.note,
|
||||
journalCreatedAt: toIsoStringOrNull(row.createdAt),
|
||||
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreIntakeJournalForImportedDose(input: {
|
||||
userId: number;
|
||||
doseTrackingId: number;
|
||||
medicationId: number;
|
||||
scheduledFor: Date;
|
||||
journalNote?: string | null;
|
||||
journalCreatedAt?: string | null;
|
||||
journalUpdatedAt?: string | null;
|
||||
database?: IntakeJournalWriteDatabase;
|
||||
}): Promise<boolean> {
|
||||
const normalizedNote = input.journalNote?.trim() ?? "";
|
||||
if (normalizedNote.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
|
||||
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
|
||||
const database = input.database ?? db;
|
||||
|
||||
await database.insert(intakeJournal).values({
|
||||
userId: input.userId,
|
||||
doseTrackingId: input.doseTrackingId,
|
||||
medicationId: input.medicationId,
|
||||
scheduledFor: input.scheduledFor,
|
||||
note: normalizedNote,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import type { DoseTrackingSource } from "./dose-tracking-service.js";
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
type MedicationTimingRow = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
genericName: string | null;
|
||||
intakesJson: string;
|
||||
usageJson: string;
|
||||
everyJson: string;
|
||||
startJson: string;
|
||||
intakeRemindersEnabled: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedTrackedDoseEvent = {
|
||||
doseTrackingId: number;
|
||||
userId: number;
|
||||
doseId: string;
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date;
|
||||
markedBy: string | null;
|
||||
takenSource: DoseTrackingSource;
|
||||
dismissed: boolean;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
|
||||
|
||||
export type IntakeJournalHistoryEntry = {
|
||||
id: number;
|
||||
doseTrackingId: number;
|
||||
doseId: string;
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date;
|
||||
markedBy: string | null;
|
||||
takenSource: DoseTrackingSource;
|
||||
dismissed: boolean;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function isTrackedDoseIdFormat(doseId: string): boolean {
|
||||
return parseDoseId(doseId) !== null;
|
||||
}
|
||||
|
||||
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
|
||||
const commercialName = medication.name?.trim() ?? "";
|
||||
if (commercialName.length > 0) {
|
||||
return commercialName;
|
||||
}
|
||||
|
||||
const genericName = medication.genericName?.trim() ?? "";
|
||||
if (genericName.length > 0) {
|
||||
return genericName;
|
||||
}
|
||||
|
||||
return `Medication #${medication.id}`;
|
||||
}
|
||||
|
||||
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{
|
||||
usageJson: medication.usageJson,
|
||||
everyJson: medication.everyJson,
|
||||
startJson: medication.startJson,
|
||||
},
|
||||
medication.intakeRemindersEnabled
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
if (!intake) {
|
||||
return new Date(parsedDose.timestampMs);
|
||||
}
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveTrackedDoseEventForUser(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
}): Promise<ResolvedTrackedDoseEvent | null> {
|
||||
const parsedDose = parseDoseId(input.doseId);
|
||||
if (!parsedDose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [event] = await db
|
||||
.select({
|
||||
doseTrackingId: doseTracking.id,
|
||||
userId: doseTracking.userId,
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
markedBy: doseTracking.markedBy,
|
||||
takenSource: doseTracking.takenSource,
|
||||
dismissed: doseTracking.dismissed,
|
||||
medicationId: medications.id,
|
||||
medicationName: medications.name,
|
||||
medicationGenericName: medications.genericName,
|
||||
intakesJson: medications.intakesJson,
|
||||
usageJson: medications.usageJson,
|
||||
everyJson: medications.everyJson,
|
||||
startJson: medications.startJson,
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
|
||||
.limit(1);
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduledFor = resolveScheduledFor(parsedDose, {
|
||||
id: event.medicationId,
|
||||
name: event.medicationName,
|
||||
genericName: event.medicationGenericName,
|
||||
intakesJson: event.intakesJson,
|
||||
usageJson: event.usageJson,
|
||||
everyJson: event.everyJson,
|
||||
startJson: event.startJson,
|
||||
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
userId: event.userId,
|
||||
doseId: event.doseId,
|
||||
medicationId: event.medicationId,
|
||||
medicationName: getMedicationDisplayName({
|
||||
id: event.medicationId,
|
||||
name: event.medicationName,
|
||||
genericName: event.medicationGenericName,
|
||||
}),
|
||||
scheduledFor,
|
||||
takenAt: event.takenAt,
|
||||
markedBy: event.markedBy,
|
||||
takenSource: event.takenSource as DoseTrackingSource,
|
||||
dismissed: event.dismissed ?? false,
|
||||
personSuffix: parsedDose.personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIntakeJournalForDoseEvent(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
}): Promise<IntakeJournalEntry | null> {
|
||||
const event = await resolveTrackedDoseEventForUser(input);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [journalEntry] = await db
|
||||
.select()
|
||||
.from(intakeJournal)
|
||||
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
|
||||
.limit(1);
|
||||
|
||||
return journalEntry ?? null;
|
||||
}
|
||||
|
||||
export async function upsertIntakeJournalForDoseEvent(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
note: string;
|
||||
}): Promise<IntakeJournalEntry | null> {
|
||||
const normalizedNote = input.note.trim();
|
||||
if (normalizedNote.length === 0) {
|
||||
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(intakeJournal)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
doseTrackingId: event.doseTrackingId,
|
||||
medicationId: event.medicationId,
|
||||
scheduledFor: event.scheduledFor,
|
||||
note: normalizedNote,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: intakeJournal.doseTrackingId,
|
||||
set: {
|
||||
userId: input.userId,
|
||||
medicationId: event.medicationId,
|
||||
note: normalizedNote,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||
}
|
||||
|
||||
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
|
||||
const event = await resolveTrackedDoseEventForUser(input);
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(intakeJournal)
|
||||
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function listIntakeJournalEntriesForUser(input: {
|
||||
userId: number;
|
||||
medicationId?: number;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit?: number;
|
||||
}): Promise<IntakeJournalHistoryEntry[]> {
|
||||
const filters = [eq(intakeJournal.userId, input.userId)];
|
||||
|
||||
if (typeof input.medicationId === "number") {
|
||||
filters.push(eq(intakeJournal.medicationId, input.medicationId));
|
||||
}
|
||||
|
||||
if (input.from) {
|
||||
filters.push(gte(intakeJournal.scheduledFor, input.from));
|
||||
}
|
||||
|
||||
if (input.to) {
|
||||
filters.push(lte(intakeJournal.scheduledFor, input.to));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: intakeJournal.id,
|
||||
doseTrackingId: intakeJournal.doseTrackingId,
|
||||
doseId: doseTracking.doseId,
|
||||
medicationId: intakeJournal.medicationId,
|
||||
medicationName: medications.name,
|
||||
medicationGenericName: medications.genericName,
|
||||
scheduledFor: intakeJournal.scheduledFor,
|
||||
takenAt: doseTracking.takenAt,
|
||||
markedBy: doseTracking.markedBy,
|
||||
takenSource: doseTracking.takenSource,
|
||||
dismissed: doseTracking.dismissed,
|
||||
note: intakeJournal.note,
|
||||
createdAt: intakeJournal.createdAt,
|
||||
updatedAt: intakeJournal.updatedAt,
|
||||
})
|
||||
.from(intakeJournal)
|
||||
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
|
||||
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
|
||||
.limit(input.limit ?? 100);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
doseTrackingId: row.doseTrackingId,
|
||||
doseId: row.doseId,
|
||||
medicationId: row.medicationId,
|
||||
medicationName: getMedicationDisplayName({
|
||||
id: row.medicationId,
|
||||
name: row.medicationName,
|
||||
genericName: row.medicationGenericName,
|
||||
}),
|
||||
scheduledFor: row.scheduledFor,
|
||||
takenAt: row.takenAt,
|
||||
markedBy: row.markedBy,
|
||||
takenSource: row.takenSource as DoseTrackingSource,
|
||||
dismissed: row.dismissed ?? false,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
@@ -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,36 +12,45 @@ import {
|
||||
type Language,
|
||||
t,
|
||||
} from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
|
||||
import { env } from "../plugins/env.js";
|
||||
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
||||
import type { ServiceLogger } from "../utils/logger.js";
|
||||
// Import shared utilities
|
||||
import {
|
||||
cleanOldIntakeReminders,
|
||||
createDefaultIntakeReminderState,
|
||||
getTimezone,
|
||||
getEffectiveTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type Intake,
|
||||
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 {
|
||||
createNotificationActionContext,
|
||||
storeNotificationActionGroupNtfyMessageId,
|
||||
} from "./notification-actions-service.js";
|
||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||
|
||||
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
|
||||
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
|
||||
|
||||
function loadIntakeReminderState(): IntakeReminderState {
|
||||
function loadIntakeReminderState(logger: ServiceLogger): IntakeReminderState {
|
||||
try {
|
||||
if (existsSync(intakeReminderStateFile)) {
|
||||
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[IntakeReminder] Failed to load reminder state file=${intakeReminderStateFile}: ${errorMessage}`);
|
||||
}
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
@@ -51,6 +59,219 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||
const intakeDate = intake.intakeTime;
|
||||
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||
if (intake.takenBy) {
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||
}
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
}
|
||||
|
||||
async function 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}`;
|
||||
}
|
||||
|
||||
function getPushProviderLabel(url: string): string {
|
||||
const normalizedUrl = url.trim().toLowerCase();
|
||||
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
|
||||
if (normalizedUrl.startsWith("discord://")) return "discord";
|
||||
if (normalizedUrl.startsWith("pushover://")) return "pushover";
|
||||
if (normalizedUrl.startsWith("gotify://")) return "gotify";
|
||||
if (normalizedUrl.startsWith("telegram://")) return "telegram";
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatActionContextLog(options: {
|
||||
actionMode: "full" | "view-only";
|
||||
doseCount: number;
|
||||
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
|
||||
}): string {
|
||||
const { actionMode, doseCount, actionContext } = options;
|
||||
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
|
||||
}
|
||||
|
||||
async function autoMarkDueIntakesAsTaken(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
locale: string,
|
||||
tz: string,
|
||||
logger: ServiceLogger
|
||||
): Promise<number> {
|
||||
if (settings.stockCalculationMode !== "automatic") {
|
||||
logger.debug(
|
||||
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const existingToday = await db
|
||||
.select({ doseId: doseTracking.doseId })
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, settings.userId),
|
||||
gte(doseTracking.takenAt, todayStart),
|
||||
lte(doseTracking.takenAt, todayEnd)
|
||||
)
|
||||
);
|
||||
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||
const trackedDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
let inserted = 0;
|
||||
|
||||
for (const med of rows) {
|
||||
if (med.isObsolete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
if (intakes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = 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(
|
||||
medDisplayName,
|
||||
intakes,
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
locale,
|
||||
tz,
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
|
||||
for (const intake of todaysIntakes) {
|
||||
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
|
||||
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
|
||||
continue;
|
||||
}
|
||||
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doseId = buildDoseIdForIntake({
|
||||
...intake,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
});
|
||||
|
||||
if (existingDoseIds.has(doseId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakeDefinition = intakes[intake.blisterIndex];
|
||||
const usage = intakeDefinition
|
||||
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
|
||||
: 0;
|
||||
if (remainingStock <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
takenAt: intake.intakeTime,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
async function sendIntakeReminderEmail(
|
||||
email: string,
|
||||
intakes: UpcomingIntake[],
|
||||
@@ -59,15 +280,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" };
|
||||
}
|
||||
|
||||
@@ -192,30 +408,23 @@ ${getFooterPlain(language)}`;
|
||||
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
|
||||
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
const mailResult = await sendEmailNotification({
|
||||
to: email,
|
||||
subject: `💊 ${subject}`,
|
||||
text: plainText,
|
||||
html,
|
||||
from: smtp.from,
|
||||
});
|
||||
|
||||
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> {
|
||||
@@ -223,29 +432,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(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 = getEffectiveTimezone(settings.timezone ?? null);
|
||||
|
||||
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;
|
||||
@@ -253,83 +488,99 @@ 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 rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
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 allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const state = loadIntakeReminderState(logger);
|
||||
const trackedDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
|
||||
...entry,
|
||||
currentStock: computeMedicationCurrentStock({
|
||||
medication: entry.med,
|
||||
doses: trackedDoses,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
nowMs: now.getTime(),
|
||||
}),
|
||||
}));
|
||||
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
|
||||
if (suppressedEmptyStockEntries.length > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
|
||||
.map((entry) =>
|
||||
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
|
||||
)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
|
||||
if (reminderEntriesEligible.length === 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
let scheduledIntakesTodayCount = 0;
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
logger.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 reminderEntriesEligible) {
|
||||
// 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) => {
|
||||
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,
|
||||
@@ -340,9 +591,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(
|
||||
@@ -355,25 +603,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()));
|
||||
@@ -390,13 +622,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;
|
||||
@@ -424,9 +660,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] = {
|
||||
@@ -435,16 +668,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
|
||||
@@ -456,27 +683,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()
|
||||
@@ -502,33 +743,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)
|
||||
@@ -558,10 +796,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"})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,13 +863,97 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
.join("\n") +
|
||||
repeatNote +
|
||||
`\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
|
||||
const actionMode = remindersToSend.length === 1 ? "full" : "view-only";
|
||||
const actionDoseIds = remindersToSend.map((intake) =>
|
||||
buildDoseIdForIntake({
|
||||
...intake,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
})
|
||||
);
|
||||
let actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null = null;
|
||||
let actionContextFailed = false;
|
||||
try {
|
||||
actionContext = await createNotificationActionContext({
|
||||
userId: settings.userId,
|
||||
title,
|
||||
message,
|
||||
doseIds: actionDoseIds,
|
||||
scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(),
|
||||
publicAppUrl: env.PUBLIC_APP_URL,
|
||||
language,
|
||||
actionMode,
|
||||
});
|
||||
} catch (error) {
|
||||
actionContextFailed = true;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
`[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
if (!actionContext) {
|
||||
if (actionContextFailed) {
|
||||
logger.warn(
|
||||
`[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)})`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
|
||||
logger.info(
|
||||
`[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
);
|
||||
}
|
||||
|
||||
const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!);
|
||||
logger.info(
|
||||
`[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
);
|
||||
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, {
|
||||
actions: actionContext?.actions,
|
||||
respondUrl: actionContext?.respondUrl,
|
||||
viewUrl: actionContext?.viewUrl,
|
||||
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||
sequenceId: actionContext?.sequenceId,
|
||||
tags: ["pill"],
|
||||
priority: hasNaggingReminder ? 4 : 3,
|
||||
});
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
if (actionContext?.groupId && result.providerMessageId) {
|
||||
try {
|
||||
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||
logger.info(
|
||||
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
} else if (actionContext?.groupId && pushProvider === "ntfy") {
|
||||
logger.warn(
|
||||
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,7 +1010,8 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
saveIntakeReminderState(state);
|
||||
|
||||
// Update global reminder state for UI display
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
updateReminderSentTime("intake", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
@@ -693,6 +1020,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,380 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { notificationActionGroups, notificationActionTokens } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js";
|
||||
|
||||
export type NotificationActionKind = "taken" | "skip" | "respond" | "view";
|
||||
|
||||
type TokenKind = Exclude<NotificationActionKind, "view">;
|
||||
type ActiveTokenKind = "taken" | "skip" | "respond";
|
||||
|
||||
export type NotificationActionContext = {
|
||||
groupId?: number;
|
||||
sequenceId?: string;
|
||||
respondUrl?: string;
|
||||
viewUrl: string;
|
||||
actions: PushNotificationAction[];
|
||||
};
|
||||
|
||||
type NotificationActionMode = "full" | "view-only";
|
||||
|
||||
export type NotificationActionTokenRecord = {
|
||||
token: typeof notificationActionTokens.$inferSelect;
|
||||
group: typeof notificationActionGroups.$inferSelect;
|
||||
doseIds: string[];
|
||||
viewUrl: string | null;
|
||||
};
|
||||
|
||||
const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function normalizePublicAppUrl(publicAppUrl: string): string {
|
||||
return publicAppUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function parseConfiguredUrl(value: string | null | undefined): URL | null {
|
||||
const trimmedValue = value?.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmedValue);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1";
|
||||
}
|
||||
|
||||
function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null {
|
||||
const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL);
|
||||
if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) {
|
||||
return normalizePublicAppUrl(configuredUrl.toString());
|
||||
}
|
||||
|
||||
const corsOrigins = env.CORS_ORIGINS.split(",")
|
||||
.map((origin) => parseConfiguredUrl(origin))
|
||||
.filter((origin): origin is URL => origin !== null);
|
||||
const reachableCorsOrigin =
|
||||
corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null;
|
||||
if (reachableCorsOrigin) {
|
||||
return normalizePublicAppUrl(reachableCorsOrigin.toString());
|
||||
}
|
||||
|
||||
return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null;
|
||||
}
|
||||
|
||||
function getScheduledKey(scheduledFor: Date): string {
|
||||
return String(Math.floor(scheduledFor.getTime() / 60000));
|
||||
}
|
||||
|
||||
function formatDateParam(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string {
|
||||
const params = new URLSearchParams();
|
||||
const primaryDoseId = doseIds[0];
|
||||
|
||||
if (scheduledFor) {
|
||||
params.set("day", formatDateParam(scheduledFor));
|
||||
}
|
||||
|
||||
if (primaryDoseId) {
|
||||
params.set("dose", primaryDoseId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`;
|
||||
}
|
||||
|
||||
function parseDoseIdsJson(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createSequenceId(groupKey: string): string {
|
||||
return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
export function createActionToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function hashActionToken(token: string): string {
|
||||
return createHash("sha256").update(token, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> {
|
||||
const token = createActionToken();
|
||||
await db.insert(notificationActionTokens).values({
|
||||
groupId,
|
||||
tokenHash: hashActionToken(token),
|
||||
kind,
|
||||
});
|
||||
|
||||
return { kind, token };
|
||||
}
|
||||
|
||||
async function createActionTokens(groupId: number): Promise<Record<ActiveTokenKind, string>> {
|
||||
const createdTokens = await Promise.all([
|
||||
createTokenRow(groupId, "taken"),
|
||||
createTokenRow(groupId, "skip"),
|
||||
createTokenRow(groupId, "respond"),
|
||||
]);
|
||||
|
||||
return createdTokens.reduce(
|
||||
(accumulator, entry) => {
|
||||
accumulator[entry.kind] = entry.token;
|
||||
return accumulator;
|
||||
},
|
||||
{ taken: "", skip: "", respond: "" } as Record<ActiveTokenKind, string>
|
||||
);
|
||||
}
|
||||
|
||||
async function resetActionTokens(groupId: number): Promise<void> {
|
||||
await db.delete(notificationActionTokens).where(eq(notificationActionTokens.groupId, groupId));
|
||||
}
|
||||
|
||||
export async function createNotificationActionContext(input: {
|
||||
userId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
doseIds: string[];
|
||||
scheduledFor: Date;
|
||||
publicAppUrl?: string | null;
|
||||
language: Language;
|
||||
actionMode?: NotificationActionMode;
|
||||
}): Promise<NotificationActionContext | null> {
|
||||
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||
if (!publicAppUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort();
|
||||
if (uniqueDoseIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = publicAppUrl;
|
||||
const actionMode = input.actionMode ?? "full";
|
||||
const labels = getNotificationActionLabels(input.language);
|
||||
const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds);
|
||||
|
||||
if (actionMode === "view-only") {
|
||||
return {
|
||||
viewUrl,
|
||||
actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }],
|
||||
};
|
||||
}
|
||||
|
||||
const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`;
|
||||
const sequenceId = createSequenceId(groupKey);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||
|
||||
let [group] = await db
|
||||
.select()
|
||||
.from(notificationActionGroups)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationActionGroups.groupKey, groupKey),
|
||||
isNull(notificationActionGroups.resolvedAction),
|
||||
gt(notificationActionGroups.expiresAt, now)
|
||||
)
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
const [existingGroup] = await db
|
||||
.select()
|
||||
.from(notificationActionGroups)
|
||||
.where(eq(notificationActionGroups.groupKey, groupKey));
|
||||
|
||||
if (existingGroup) {
|
||||
await resetActionTokens(existingGroup.id);
|
||||
[group] = await db
|
||||
.update(notificationActionGroups)
|
||||
.set({
|
||||
sequenceId,
|
||||
ntfyOriginalMessageId: "",
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
resolvedAction: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(notificationActionGroups.id, existingGroup.id))
|
||||
.returning();
|
||||
} else {
|
||||
[group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = await createActionTokens(group.id);
|
||||
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||
const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds);
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
sequenceId: group.sequenceId,
|
||||
respondUrl,
|
||||
viewUrl: resolvedViewUrl,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: groupLabels.taken,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "skip",
|
||||
label: groupLabels.skip,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||
method: "POST",
|
||||
},
|
||||
{ kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTestNotificationActionContext(input: {
|
||||
userId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
publicAppUrl?: string | null;
|
||||
language: Language;
|
||||
}): Promise<NotificationActionContext | null> {
|
||||
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
|
||||
if (!publicAppUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = publicAppUrl;
|
||||
const now = new Date();
|
||||
const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`;
|
||||
const sequenceId = createSequenceId(groupKey);
|
||||
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
|
||||
const viewUrl = buildViewUrl(baseUrl, null, []);
|
||||
|
||||
const [group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: "[]",
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: now,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const tokens = await createActionTokens(group.id);
|
||||
const groupLanguage = (group.language as Language | null) ?? input.language;
|
||||
const groupLabels = getNotificationActionLabels(groupLanguage);
|
||||
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
sequenceId: group.sequenceId,
|
||||
respondUrl,
|
||||
viewUrl,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: groupLabels.taken,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "skip",
|
||||
label: groupLabels.skip,
|
||||
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
|
||||
method: "POST",
|
||||
},
|
||||
{ kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotificationActionTokenRecord(
|
||||
rawToken: string
|
||||
): Promise<NotificationActionTokenRecord | null> {
|
||||
const tokenHash = hashActionToken(rawToken);
|
||||
const rows = await db
|
||||
.select({ token: notificationActionTokens, group: notificationActionGroups })
|
||||
.from(notificationActionTokens)
|
||||
.innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id))
|
||||
.where(eq(notificationActionTokens.tokenHash, tokenHash));
|
||||
|
||||
const record = rows[0];
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL);
|
||||
return {
|
||||
token: record.token,
|
||||
group: record.group,
|
||||
doseIds: parseDoseIdsJson(record.group.doseIdsJson),
|
||||
viewUrl: baseUrl
|
||||
? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean {
|
||||
return record.group.expiresAt.getTime() <= Date.now();
|
||||
}
|
||||
|
||||
export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise<void> {
|
||||
const normalizedMessageId = ntfyMessageId.trim();
|
||||
if (normalizedMessageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, groupId));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { Language } from "../../i18n/translations.js";
|
||||
|
||||
export type PushNotificationAction =
|
||||
| {
|
||||
kind: "taken";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "GET";
|
||||
};
|
||||
|
||||
export type PushNotificationOptions = {
|
||||
actions?: PushNotificationAction[];
|
||||
respondUrl?: string;
|
||||
viewUrl?: string;
|
||||
clickUrl?: string;
|
||||
tags?: string[];
|
||||
priority?: number;
|
||||
sequenceId?: string;
|
||||
};
|
||||
|
||||
type NtfyActionPayload = {
|
||||
action: "http" | "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method?: "POST";
|
||||
clear: boolean;
|
||||
};
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
|
||||
}
|
||||
|
||||
export function isNtfyNotificationUrl(urlStr: string): boolean {
|
||||
if (urlStr.startsWith("ntfy://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(urlStr);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationProvider(urlStr: string): string {
|
||||
if (isNtfyNotificationUrl(urlStr)) {
|
||||
return "ntfy";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationActionLabels(language: Language): {
|
||||
taken: string;
|
||||
skip: string;
|
||||
respond: string;
|
||||
view: string;
|
||||
} {
|
||||
if (language === "de") {
|
||||
return {
|
||||
taken: "Einnehmen",
|
||||
skip: "Überspringen",
|
||||
respond: "Antworten",
|
||||
view: "Öffnen",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
taken: "Take",
|
||||
skip: "Skip",
|
||||
respond: "Respond",
|
||||
view: "View",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
|
||||
const actions = options.actions ?? [];
|
||||
|
||||
return actions.map((action) => {
|
||||
if (action.kind === "view") {
|
||||
return {
|
||||
action: "view",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
clear: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "http",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
method: "POST",
|
||||
// Clear the original actionable ntfy notification locally after a successful mutation.
|
||||
clear: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
|
||||
if (!options.respondUrl && !options.viewUrl) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const lines = [message.trimEnd()];
|
||||
|
||||
if (options.respondUrl) {
|
||||
lines.push("", "Respond:", options.respondUrl);
|
||||
}
|
||||
|
||||
if (options.viewUrl) {
|
||||
lines.push("", "View:", options.viewUrl);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderNotificationActionPayload(
|
||||
urlStr: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions
|
||||
): { message: string; headers: Record<string, string> } {
|
||||
if (!isNtfyNotificationUrl(urlStr)) {
|
||||
return {
|
||||
message: appendFallbackActionLinks(message, options),
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const ntfyActions = buildNtfyActions(options);
|
||||
if (ntfyActions.length > 0) {
|
||||
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
|
||||
}
|
||||
if (options.clickUrl && ntfyActions.length === 0) {
|
||||
headers.Click = options.clickUrl;
|
||||
}
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
headers.Tags = options.tags.join(",");
|
||||
}
|
||||
if (typeof options.priority === "number") {
|
||||
headers.Priority = String(options.priority);
|
||||
}
|
||||
if (options.sequenceId) {
|
||||
headers["X-Sequence-ID"] = options.sequenceId;
|
||||
}
|
||||
|
||||
return { message, headers };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
|
||||
import { formatPlannerQuantity } from "../planner-service.js";
|
||||
|
||||
export type StockReminderItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical?: boolean;
|
||||
};
|
||||
|
||||
export type PrescriptionReminderItem = {
|
||||
name: string;
|
||||
remainingRefills: number;
|
||||
};
|
||||
|
||||
function splitStockItems(items: StockReminderItem[]): {
|
||||
emptyItems: StockReminderItem[];
|
||||
criticalItems: StockReminderItem[];
|
||||
lowItems: StockReminderItem[];
|
||||
} {
|
||||
const emptyItems = items.filter((item) => item.medsLeft <= 0);
|
||||
const criticalItems = items.filter((item) => item.medsLeft > 0 && item.isCritical !== false);
|
||||
const lowItems = items.filter((item) => item.medsLeft > 0 && item.isCritical === false);
|
||||
return { emptyItems, criticalItems, lowItems };
|
||||
}
|
||||
|
||||
export function buildStockReminderPushNotification(
|
||||
items: StockReminderItem[],
|
||||
language: Language
|
||||
): { title: string; message: string } {
|
||||
const tr = getTranslations(language);
|
||||
const { emptyItems, criticalItems, lowItems } = splitStockItems(items);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyItems.length > 0) titleParts.push(`🚨 ${emptyItems.length} ${tr.push.empty}`);
|
||||
if (criticalItems.length > 0) titleParts.push(`🚨 ${criticalItems.length} ${tr.push.critical}`);
|
||||
if (lowItems.length > 0) titleParts.push(`⚠️ ${lowItems.length} ${tr.push.lowStock}`);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyItems.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyItems.forEach((item) => messageParts.push(` • ${item.name}`));
|
||||
}
|
||||
if (criticalItems.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalItems.forEach((item) =>
|
||||
messageParts.push(
|
||||
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (lowItems.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowItems.forEach((item) =>
|
||||
messageParts.push(
|
||||
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPrescriptionReminderPushNotification(
|
||||
items: PrescriptionReminderItem[],
|
||||
language: Language
|
||||
): { title: string; message: string } {
|
||||
const tr = getTranslations(language);
|
||||
const emptyItems = items.filter((item) => item.remainingRefills <= 0);
|
||||
const lowItems = items.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyItems.length > 0) {
|
||||
titleParts.push(
|
||||
`🚨 ${emptyItems.length} ${emptyItems.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
}
|
||||
if (lowItems.length > 0) {
|
||||
titleParts.push(
|
||||
`🚨 ${lowItems.length} ${lowItems.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
}
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyItems.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
emptyItems.forEach((item) => messageParts.push(` • ${item.name}`));
|
||||
}
|
||||
if (lowItems.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
lowItems.forEach((item) =>
|
||||
messageParts.push(
|
||||
` • ${item.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: item.remainingRefills })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`,
|
||||
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
||||
import type { PushNotificationOptions } from "./action-renderer.js";
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function normalizeRecipients(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||
const accepted = normalizeRecipients(info.accepted);
|
||||
const rejected = normalizeRecipients(info.rejected);
|
||||
|
||||
if (accepted.length > 0) return null;
|
||||
if (rejected.length > 0) {
|
||||
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||
}
|
||||
|
||||
if (typeof info.response === "string" && info.response.trim()) {
|
||||
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||
}
|
||||
|
||||
return "SMTP did not confirm accepted recipients.";
|
||||
}
|
||||
|
||||
export type EmailDeliveryRequest = {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
export type EmailDeliveryResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
smtpResponse?: string;
|
||||
};
|
||||
|
||||
export function getSmtpConfig(): {
|
||||
host?: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
from?: string;
|
||||
} {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const port = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const secure = process.env.SMTP_SECURE === "true";
|
||||
const from = process.env.SMTP_FROM ?? user;
|
||||
|
||||
return { host, user, pass, port, secure, from };
|
||||
}
|
||||
|
||||
export function createSmtpTransport(smtp = getSmtpConfig()) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The SMTP endpoint is configured by the server operator via environment variables,
|
||||
// not derived from request-controlled input.
|
||||
// lgtm [js/request-forgery]
|
||||
return nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: {
|
||||
user: smtp.user,
|
||||
pass: smtp.pass ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
|
||||
const smtp = getSmtpConfig();
|
||||
if (!smtp.host || !smtp.user) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = createSmtpTransport(smtp);
|
||||
if (!transporter) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: input.from ?? smtp.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
return { success: false, error: deliveryError };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: mailResult.messageId,
|
||||
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
url: string,
|
||||
title: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions = {}
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, title, message, options);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true, providerMessageId: result.providerMessageId };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
buildPrescriptionReminderPushNotification,
|
||||
buildStockReminderPushNotification,
|
||||
type PrescriptionReminderItem,
|
||||
type StockReminderItem,
|
||||
} from "./builders.js";
|
||||
export {
|
||||
type EmailDeliveryRequest,
|
||||
type EmailDeliveryResult,
|
||||
getSmtpConfig,
|
||||
sendEmailNotification,
|
||||
sendPushNotification,
|
||||
} from "./delivery.js";
|
||||
export {
|
||||
getReminderState,
|
||||
loadReminderState,
|
||||
saveReminderState,
|
||||
updateReminderSentTime,
|
||||
updateUserReminderSentTime,
|
||||
} from "./state.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { getDataDir } from "../../db/db-utils.js";
|
||||
import { userSettings } from "../../db/schema.js";
|
||||
import {
|
||||
createDefaultReminderState,
|
||||
getTodayInTimezone,
|
||||
parseReminderState,
|
||||
type ReminderState,
|
||||
} from "../../utils/scheduler-utils.js";
|
||||
|
||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||
|
||||
export function loadReminderState(): ReminderState {
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
|
||||
export function saveReminderState(state: ReminderState): void {
|
||||
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function getReminderState(): ReminderState {
|
||||
return loadReminderState();
|
||||
}
|
||||
|
||||
export function updateReminderSentTime(
|
||||
type: "stock" | "intake" | "prescription" = "stock",
|
||||
channel: "email" | "push" | "both" = "email"
|
||||
): void {
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
}
|
||||
|
||||
// Stock and intake reminders are tracked separately so neither overwrites the other.
|
||||
export async function updateUserReminderSentTime(
|
||||
userId: number,
|
||||
type: "stock" | "intake" | "prescription" = "stock",
|
||||
channel: "email" | "push" | "both" = "email",
|
||||
medName?: string,
|
||||
takenBy?: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
if (type === "stock") {
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
lastStockReminderSent: now,
|
||||
lastStockReminderChannel: channel,
|
||||
lastStockReminderMedNames: medName ?? null,
|
||||
})
|
||||
.where(eq(userSettings.userId, userId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "prescription") {
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
lastPrescriptionReminderSent: now,
|
||||
lastPrescriptionReminderChannel: channel,
|
||||
lastPrescriptionReminderMedNames: medName ?? null,
|
||||
})
|
||||
.where(eq(userSettings.userId, userId));
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
lastAutoEmailSent: now,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
lastReminderMedName: medName ?? null,
|
||||
lastReminderTakenBy: takenBy ?? null,
|
||||
})
|
||||
.where(eq(userSettings.userId, userId));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getPlannerUnitKind, isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
|
||||
// Escape HTML to prevent XSS in email templates.
|
||||
export function escapeHtml(text: string): string {
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||
}
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function normalizeRecipients(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||
const accepted = normalizeRecipients(info.accepted);
|
||||
const rejected = normalizeRecipients(info.rejected);
|
||||
|
||||
if (accepted.length > 0) return null;
|
||||
if (rejected.length > 0) {
|
||||
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||
}
|
||||
|
||||
if (typeof info.response === "string" && info.response.trim()) {
|
||||
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||
}
|
||||
|
||||
return "SMTP did not confirm accepted recipients.";
|
||||
}
|
||||
|
||||
export function isContainerPackage(packageType?: string): boolean {
|
||||
return isAmountBasedPackageType(packageType);
|
||||
}
|
||||
|
||||
export function getPlannerUnit(
|
||||
packageType: string | undefined,
|
||||
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||
): string {
|
||||
const unitKind = getPlannerUnitKind(packageType);
|
||||
if (unitKind === "units") return tr.common.units;
|
||||
if (unitKind === "ml") return tr.common.ml;
|
||||
if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills;
|
||||
if (unitKind === "injections") return tr.common.injections ?? tr.common.pills;
|
||||
return tr.common.pills;
|
||||
}
|
||||
|
||||
export function formatPlannerQuantity(
|
||||
packageType: string | undefined,
|
||||
count: number,
|
||||
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||
): string {
|
||||
return `${count} ${getPlannerUnit(packageType, tr)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user