Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a016e45ef2 | |||
| cbc71822b0 | |||
| 150be1e114 | |||
| 6ff0ad2745 | |||
| 0ffab23b6d | |||
| b4ddf9fd65 | |||
| 8273b07231 | |||
| edf42bb068 | |||
| e2c274014f | |||
| 732a28dcc5 | |||
| 684abd7fb6 | |||
| bb693243c1 | |||
| fcc84e2d0b | |||
| 91c55f8cc3 | |||
| 12d1fbbb30 | |||
| 836c48264f | |||
| 12bfc61565 | |||
| 2c829da924 | |||
| 874babe1d8 | |||
| c9039b6e87 | |||
| 5918eb5aae | |||
| 19d3f83aef | |||
| 6922a856c0 | |||
| 45a319dc06 | |||
| 81ac12ba60 | |||
| 6c10f9af0c | |||
| 6eb7bf6d0d | |||
| 2a97a78810 | |||
| 92ea6d5f8b | |||
| 0c83648a56 | |||
| 77b0f3a0f9 | |||
| 82d8bec91b | |||
| 7122121c12 |
@@ -12,6 +12,9 @@ PGID=1000
|
||||
PORT=3000
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
LOG_LEVEL=info
|
||||
# Levels: debug, info, warn, error, silent
|
||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||
# and frontend browser console (via build-time injection)
|
||||
|
||||
# Rate limit: max requests per minute per IP (default: 100)
|
||||
# Increase for development/testing environments
|
||||
|
||||
@@ -7,6 +7,10 @@ body:
|
||||
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 +61,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:
|
||||
|
||||
@@ -13,6 +13,7 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
||||
## Critical Safety Rules
|
||||
|
||||
- **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.**
|
||||
- **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.
|
||||
@@ -48,12 +49,11 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
||||
|
||||
- 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:
|
||||
- Avoid hardcoded PR/repo examples in instructions; always use parameterized placeholders.
|
||||
- Use safe command patterns:
|
||||
- `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>'`
|
||||
- `SHA=$(GH_PAGER=cat gh pr view <PR_NUMBER> --json headRefOid --jq .headRefOid)`
|
||||
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/$SHA/check-runs --jq '<jq-filter>'`
|
||||
|
||||
---
|
||||
|
||||
@@ -89,6 +89,29 @@ 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:
|
||||
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
|
||||
```bash
|
||||
git push -u origin feat/short-description
|
||||
```
|
||||
2. Create a Pull Request via GitHub CLI, linking the related issue:
|
||||
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||
```bash
|
||||
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
|
||||
gh pr create \
|
||||
--title "fix: short description" \
|
||||
--body "Closes #<ISSUE_NUMBER>
|
||||
|
||||
Description of changes"
|
||||
Description of changes" \
|
||||
--assignee DanielVolz \
|
||||
--label bug \
|
||||
--project "@DanielVolz's MedAssist-ng project"
|
||||
```
|
||||
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
|
||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||
- The `--project` flag links the PR to the Project board.
|
||||
3. **Present the PR URL to the user and wait for confirmation.**
|
||||
|
||||
### Step 4: Wait for CI and Merge
|
||||
@@ -462,7 +492,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)
|
||||
|
||||
@@ -15,6 +15,9 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -65,10 +68,11 @@ 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 && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
|
||||
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
|
||||
|
||||
@@ -1,36 +1,13 @@
|
||||
# MedAssist-ng - AI Coding Instructions
|
||||
# MedAssist-ng - Copilot Entry Point
|
||||
|
||||
## Purpose
|
||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||
|
||||
This file is intentionally short.
|
||||
Use `AGENTS.md` as the canonical governance source and `.github/skills/*/SKILL.md` for detailed workflows.
|
||||
## Required Startup Steps
|
||||
|
||||
## Always-On Rules
|
||||
1. Read `AGENTS.md` first.
|
||||
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
||||
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||
|
||||
- English only for project artifacts.
|
||||
- No remote git/release actions by normal agent (`git push`, PR create/merge, tag/release).
|
||||
- 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.
|
||||
## Scope
|
||||
|
||||
## MedAssist Essentials
|
||||
|
||||
- Frontend calls backend through `/api/*`.
|
||||
- All UI text must use i18n keys (`t("...")`) with EN/DE entries.
|
||||
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
|
||||
|
||||
## Skill Routing
|
||||
|
||||
- Architecture/boundaries: `medassist-architecture-guard`
|
||||
- DB compatibility: `medassist-db-compat-check`
|
||||
- i18n rules: `medassist-i18n-enforcer`
|
||||
- UI consistency: `medassist-ui-consistency`
|
||||
- Testing delegation: `medassist-testing-handoff`
|
||||
- Release delegation: `medassist-release-handoff`
|
||||
|
||||
## Key References
|
||||
|
||||
- Canonical governance: `AGENTS.md`
|
||||
- Global engineering rules: see `AGENTS.md` (`Global Engineering Rules` section).
|
||||
- Project skills: `.github/skills/README.md`
|
||||
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
|
||||
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Backend dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Frontend dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Root dev dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
@@ -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.
|
||||
|
||||
@@ -47,18 +47,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for changelog generation
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
@@ -50,11 +50,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@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -62,7 +64,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
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@v8
|
||||
with:
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
script: |
|
||||
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: backend-coverage
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# =============================================================================
|
||||
# Frontend Build Validation (skipped if no frontend-related files changed)
|
||||
# Frontend Tests & Build (skipped if no frontend-related files changed)
|
||||
# =============================================================================
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
@@ -96,10 +96,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
@@ -111,5 +111,16 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: TypeScript type check & build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: frontend/coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.BADGE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
+3
-1
@@ -79,5 +79,7 @@ Thumbs.db
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
.claude/
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
docs/TECH_STACK.md
|
||||
doku
|
||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"vitest.root": "backend",
|
||||
"vitest.enable": true,
|
||||
"vitest.commandLine": "npm test --"
|
||||
"vitest.commandLine": "npm test --",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"test": true
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-518%2F518-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-879%2F879-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-771%2F771-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -123,6 +123,7 @@ Share your medication schedule with others via a public link.
|
||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
- Manual stock correction supports partial blisters and loose pills
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
@@ -132,6 +133,7 @@ Share your medication schedule with others via a public link.
|
||||
### 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
|
||||
@@ -143,6 +145,10 @@ Share your medication schedule with others via a public link.
|
||||
- Plan ahead for vacations, business trips, or hospital stays
|
||||
- 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
|
||||
@@ -244,7 +250,9 @@ Generate secrets with: `openssl rand -hex 32`
|
||||
|
||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||
|
||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
|
||||
|
||||
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
|
||||
|
||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||
|
||||
@@ -282,6 +290,7 @@ Get your keys at [pushover.net](https://pushover.net/):
|
||||
**Gotify** (self-hosted):
|
||||
```
|
||||
gotify://your-server.com/TOKEN
|
||||
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
||||
```
|
||||
|
||||
**Discord**:
|
||||
@@ -292,6 +301,7 @@ discord://TOKEN@WEBHOOK_ID
|
||||
**Telegram**:
|
||||
```
|
||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||
```
|
||||
|
||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
@@ -305,6 +315,17 @@ docker compose -f docker-compose.dev.yml up
|
||||
- Frontend: `http://localhost:5173` (hot reload)
|
||||
- Backend: `http://localhost:3000`
|
||||
|
||||
Playwright E2E recommendations:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
|
||||
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
|
||||
```
|
||||
|
||||
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
|
||||
- Data-heavy specs remain sequential via the `chromium-data` project config.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
||||
|
||||
@@ -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,8 @@
|
||||
ALTER TABLE `medications` ADD `prescription_enabled` integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE `medications` ADD `prescription_authorized_refills` integer;
|
||||
ALTER TABLE `medications` ADD `prescription_remaining_refills` integer;
|
||||
ALTER TABLE `medications` ADD `prescription_low_refill_threshold` integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE `medications` ADD `prescription_expiry_date` text;
|
||||
|
||||
ALTER TABLE `user_settings` ADD `email_prescription_reminders` integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE `user_settings` ADD `shoutrrr_prescription_reminders` integer NOT NULL DEFAULT 1;
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,27 @@
|
||||
"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_mean_spot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1041
-2039
File diff suppressed because it is too large
Load Diff
+18
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.10.2",
|
||||
"version": "1.16.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,31 +17,32 @@
|
||||
"check": "npx biome check . && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@libsql/client": "^0.10.0",
|
||||
"argon2": "^0.40.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"argon2": "^0.44.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.7.3",
|
||||
"nodemailer": "^7.0.11",
|
||||
"openid-client": "^6.8.1",
|
||||
"fastify": "^5.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"supertest": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
|
||||
import {
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDataDir,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
@@ -65,8 +63,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);
|
||||
}
|
||||
|
||||
+61
-20
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// If the error is about existing schema objects, the DB is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
||||
// or when tables were created before drizzle migrations were introduced
|
||||
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added for intake automation auditability (manual vs automatic taken)
|
||||
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
@@ -119,6 +121,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for soft-archiving medications (without deleting history)
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
// Added for explicit medication lifecycle start date
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
@@ -135,15 +142,32 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||
// Added for share stock visibility toggle
|
||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||
// Added for timeline visibility toggles (dashboard + shared schedule)
|
||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||
// Added for prescription refill tracking and reminders
|
||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||
// Added for refill history prescription tracking
|
||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
if (!(e as Error).message?.includes("duplicate column")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,10 +188,27 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create indexes that might be missing (silently fail if already exists)
|
||||
const createIndexMigrations = [
|
||||
// Added in v1.6.x - case-insensitive unique usernames
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||
];
|
||||
|
||||
for (const sql of createIndexMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "already exists" errors
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,8 +233,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
} catch (e: unknown) {
|
||||
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -220,8 +261,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
|
||||
"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}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
@@ -344,14 +385,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
args: [newDoseId, dose.id],
|
||||
});
|
||||
repaired++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Repair failed: ${e.message}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] {
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
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
|
||||
)`,
|
||||
|
||||
@@ -47,6 +47,14 @@ 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(""),
|
||||
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"),
|
||||
prescriptionLowRefillThreshold: integer("prescription_low_refill_threshold").notNull().default(1),
|
||||
prescriptionExpiryDate: text("prescription_expiry_date"),
|
||||
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -65,11 +73,15 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
notificationEmail: text("notification_email"),
|
||||
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
emailPrescriptionReminders: integer("email_prescription_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
// Push notifications (shoutrrr/ntfy)
|
||||
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
shoutrrrUrl: text("shoutrrr_url"),
|
||||
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
shoutrrrPrescriptionReminders: integer("shoutrrr_prescription_reminders", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
||||
@@ -88,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
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),
|
||||
// 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"),
|
||||
@@ -98,6 +114,10 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
lastStockReminderSent: text("last_stock_reminder_sent"),
|
||||
lastStockReminderChannel: text("last_stock_reminder_channel"),
|
||||
lastStockReminderMedNames: text("last_stock_reminder_med_names"),
|
||||
// Last prescription reminder tracking (separate from stock/intake)
|
||||
lastPrescriptionReminderSent: text("last_prescription_reminder_sent"),
|
||||
lastPrescriptionReminderChannel: text("last_prescription_reminder_channel"),
|
||||
lastPrescriptionReminderMedNames: text("last_prescription_reminder_med_names"),
|
||||
// Timestamps
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -143,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
@@ -159,5 +180,6 @@ export const refillHistory = sqliteTable("refill_history", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
packsAdded: integer("packs_added").notNull().default(0),
|
||||
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||
usedPrescription: integer("used_prescription", { mode: "boolean" }).notNull().default(false),
|
||||
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
});
|
||||
|
||||
@@ -123,6 +123,39 @@ type TranslationKeys = {
|
||||
criticalSection: string;
|
||||
lowStockSection: string;
|
||||
};
|
||||
// Prescription reminder (shared across email + push)
|
||||
prescriptionReminder: {
|
||||
subjectSingle: string;
|
||||
subjectMultiple: string;
|
||||
pushTitleLow: string;
|
||||
pushTitleEmpty: string;
|
||||
pushEmpty: string;
|
||||
pushEmptySingle: string;
|
||||
pushLow: string;
|
||||
pushLowSingle: string;
|
||||
pushRenewNow: string;
|
||||
pushEmptySection: string;
|
||||
pushLowSection: string;
|
||||
pushRefillsLeft: string;
|
||||
title: string;
|
||||
titleEmpty: string;
|
||||
descriptionLow: string;
|
||||
descriptionEmpty: string;
|
||||
alertLowSingle: string;
|
||||
alertLowMultiple: string;
|
||||
alertEmptySingle: string;
|
||||
alertEmptyMultiple: string;
|
||||
line: string;
|
||||
lineEmpty: string;
|
||||
expiresSuffix: string;
|
||||
repeatDailyNote: string;
|
||||
tableHeaders: {
|
||||
medication: string;
|
||||
refillsLeft: string;
|
||||
reminderThreshold: string;
|
||||
prescriptionExpires: string;
|
||||
};
|
||||
};
|
||||
// Demand calculator email
|
||||
demandCalculator: {
|
||||
subject: string;
|
||||
@@ -134,11 +167,13 @@ type TranslationKeys = {
|
||||
medication: string;
|
||||
usage: string;
|
||||
needed: string;
|
||||
prescriptionRefills: string;
|
||||
available: string;
|
||||
status: string;
|
||||
};
|
||||
statusEnough: string;
|
||||
statusEmpty: string;
|
||||
prescriptionNotApplicable: string;
|
||||
};
|
||||
// Common
|
||||
common: {
|
||||
@@ -156,8 +191,8 @@ type TranslationKeys = {
|
||||
const translations: Record<Language, TranslationKeys> = {
|
||||
en: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Critically Low",
|
||||
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
|
||||
subject: "MedAssist-ng: ⚠️ {count} Medication{s} Running Critically Low",
|
||||
title: "⚠️ MedAssist-ng: Automatic Reorder Reminder",
|
||||
description: "The following medications are running critically low and need to be reordered:",
|
||||
descriptionEmpty: "The following medications are empty and need to be reordered immediately:",
|
||||
descriptionMixed: "The following medications need to be reordered:",
|
||||
@@ -211,9 +246,41 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
criticalSection: "Running critically low",
|
||||
lowStockSection: "Running low",
|
||||
},
|
||||
prescriptionReminder: {
|
||||
subjectSingle: "MedAssist-ng: 🚨 Prescription Refill Reminder",
|
||||
subjectMultiple: "MedAssist-ng: 🚨 {count} Prescriptions Need Renewal Soon",
|
||||
pushTitleLow: "💊 MedAssist-ng: {count} prescriptions are running low",
|
||||
pushTitleEmpty: "💊 MedAssist-ng: {count} prescriptions need renewal now",
|
||||
pushEmpty: "prescriptions out of refills",
|
||||
pushEmptySingle: "prescription out of refills",
|
||||
pushLow: "prescriptions low on refills",
|
||||
pushLowSingle: "prescription low on refills",
|
||||
pushRenewNow: "Renew Now!",
|
||||
pushEmptySection: "Prescriptions with no refills left",
|
||||
pushLowSection: "Prescriptions running low on refills",
|
||||
pushRefillsLeft: "{count} refill(s) remaining on this prescription",
|
||||
title: "⚠️ MedAssist-ng - Prescription Reminder",
|
||||
titleEmpty: "🚨 MedAssist-ng - Prescription Reminder",
|
||||
descriptionLow: "Some prescriptions are low on remaining refills.",
|
||||
descriptionEmpty: "Some prescriptions have no refills left. Contact your doctor for renewal.",
|
||||
alertLowSingle: "⚠️ 1 prescription is low on refills",
|
||||
alertLowMultiple: "⚠️ {count} prescriptions are low on refills",
|
||||
alertEmptySingle: "🚨 1 prescription needs renewal now",
|
||||
alertEmptyMultiple: "🚨 {count} prescriptions need renewal now",
|
||||
line: "{name}: {refills} refill(s) remaining on this prescription{expirySuffix}",
|
||||
lineEmpty: "{name}: no refills remaining on this prescription{expirySuffix}",
|
||||
expiresSuffix: ", expires {date}",
|
||||
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
refillsLeft: "Prescription refills left",
|
||||
reminderThreshold: "Reminder threshold",
|
||||
prescriptionExpires: "Prescription expires",
|
||||
},
|
||||
},
|
||||
demandCalculator: {
|
||||
subject: "MedAssist-ng - Supply Overview ({from} - {until})",
|
||||
title: "MedAssist-ng - Demand Calculator",
|
||||
subject: "MedAssist-ng: Supply Overview ({from} - {until})",
|
||||
title: "MedAssist-ng: Demand Calculator",
|
||||
description: "Supply overview from {from} to {until}",
|
||||
summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.",
|
||||
summaryAllOk: "✓ All medications have sufficient supply for this period.",
|
||||
@@ -221,11 +288,13 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
medication: "Medication",
|
||||
usage: "Usage",
|
||||
needed: "Blisters needed",
|
||||
prescriptionRefills: "Prescription refills",
|
||||
available: "Available",
|
||||
status: "Status",
|
||||
},
|
||||
statusEnough: "✓ Enough",
|
||||
statusEmpty: "✗ Empty",
|
||||
prescriptionNotApplicable: "–",
|
||||
},
|
||||
common: {
|
||||
pill: "pill",
|
||||
@@ -240,8 +309,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
},
|
||||
de: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} kritisch niedrig",
|
||||
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
|
||||
subject: "MedAssist-ng: ⚠️ {count} Medikament{e} kritisch niedrig",
|
||||
title: "⚠️ MedAssist-ng: Automatische Nachbestell-Erinnerung",
|
||||
description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:",
|
||||
descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:",
|
||||
descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:",
|
||||
@@ -296,9 +365,43 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
criticalSection: "Kritisch niedrig",
|
||||
lowStockSection: "Niedrig",
|
||||
},
|
||||
prescriptionReminder: {
|
||||
subjectSingle: "MedAssist-ng: 🚨 Rezept-Nachfüll-Erinnerung",
|
||||
subjectMultiple: "MedAssist-ng: 🚨 {count} Rezepte müssen bald erneuert werden",
|
||||
pushTitleLow: "💊 MedAssist-ng: {count} Rezept(e) haben nur noch wenige Nachfüllungen",
|
||||
pushTitleEmpty: "💊 MedAssist-ng: {count} Rezept(e) müssen jetzt erneuert werden",
|
||||
pushEmpty: "Rezepte ohne verbleibende Nachfüllung",
|
||||
pushEmptySingle: "Rezept ohne verbleibende Nachfüllung",
|
||||
pushLow: "Rezepte mit wenigen verbleibenden Nachfüllungen",
|
||||
pushLowSingle: "Rezept mit wenigen verbleibenden Nachfüllungen",
|
||||
pushRenewNow: "Jetzt erneuern!",
|
||||
pushEmptySection: "Rezepte ohne Nachfüllungen",
|
||||
pushLowSection: "Rezepte mit bald aufgebrauchten Nachfüllungen",
|
||||
pushRefillsLeft: "{count} Nachfüllung(en) für dieses Rezept übrig",
|
||||
title: "⚠️ MedAssist-ng - Rezept-Erinnerung",
|
||||
titleEmpty: "🚨 MedAssist-ng - Rezept-Erinnerung",
|
||||
descriptionLow: "Einige Rezepte haben nur noch wenige Nachfüllungen.",
|
||||
descriptionEmpty:
|
||||
"Einige Rezepte haben keine Nachfüllungen mehr. Bitte kontaktieren Sie Ihren Arzt für eine Erneuerung.",
|
||||
alertLowSingle: "⚠️ 1 Rezept ist bei den Nachfüllungen niedrig",
|
||||
alertLowMultiple: "⚠️ {count} Rezepte sind bei den Nachfüllungen niedrig",
|
||||
alertEmptySingle: "🚨 1 Rezept muss jetzt erneuert werden",
|
||||
alertEmptyMultiple: "🚨 {count} Rezepte müssen jetzt erneuert werden",
|
||||
line: "{name}: {refills} Nachfüllung(en) für dieses Rezept übrig{expirySuffix}",
|
||||
lineEmpty: "{name}: keine Nachfüllung mehr für dieses Rezept{expirySuffix}",
|
||||
expiresSuffix: ", läuft ab {date}",
|
||||
repeatDailyNote:
|
||||
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
refillsLeft: "Rezept-Nachfüllungen übrig",
|
||||
reminderThreshold: "Erinnerungsschwelle",
|
||||
prescriptionExpires: "Rezeptablauf",
|
||||
},
|
||||
},
|
||||
demandCalculator: {
|
||||
subject: "MedAssist-ng - Bestandsübersicht ({from} - {until})",
|
||||
title: "MedAssist-ng - Bedarfsrechner",
|
||||
subject: "MedAssist-ng: Bestandsübersicht ({from} - {until})",
|
||||
title: "MedAssist-ng: Bedarfsrechner",
|
||||
description: "Bestandsübersicht von {from} bis {until}",
|
||||
summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.",
|
||||
summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.",
|
||||
@@ -306,11 +409,13 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
medication: "Medikament",
|
||||
usage: "Verbrauch",
|
||||
needed: "Blister benötigt",
|
||||
prescriptionRefills: "Rezept-Nachfüllungen",
|
||||
available: "Verfügbar",
|
||||
status: "Status",
|
||||
},
|
||||
statusEnough: "✓ Ausreichend",
|
||||
statusEmpty: "✗ Leer",
|
||||
prescriptionNotApplicable: "–",
|
||||
},
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
|
||||
+38
-4
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -20,6 +22,7 @@ import { medicationRoutes } from "./routes/medications.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";
|
||||
@@ -44,6 +47,23 @@ 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) {
|
||||
return {
|
||||
level,
|
||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create and configure Fastify app (without starting) */
|
||||
export async function createApp(options?: {
|
||||
logLevel?: string;
|
||||
@@ -71,7 +91,14 @@ export async function createApp(options?: {
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
logger: buildLoggerOptions(opts.logLevel),
|
||||
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
// Build config
|
||||
@@ -118,6 +145,7 @@ export async function createApp(options?: {
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -136,9 +164,14 @@ 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(),
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
@@ -190,6 +223,7 @@ await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
|
||||
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (err: any) {
|
||||
} 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")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// JWT verification failed
|
||||
|
||||
+35
-38
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import argon2 from "argon2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-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),
|
||||
});
|
||||
@@ -81,6 +89,8 @@ const updateProfileSchema = z.object({
|
||||
// Auth Routes
|
||||
// =============================================================================
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
@@ -129,7 +139,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
// Check if username already exists
|
||||
const [existingUser] = await db.select().from(users).where(eq(users.username, username));
|
||||
const [existingUser] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`);
|
||||
if (existingUser) {
|
||||
return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" });
|
||||
}
|
||||
@@ -190,7 +200,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
const { username, password, rememberMe } = parsed.data;
|
||||
|
||||
// Find user by username
|
||||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||
const [user] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`);
|
||||
|
||||
// Generic error to prevent user enumeration
|
||||
const invalidCredentialsError = () =>
|
||||
@@ -461,36 +471,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
|
||||
@@ -521,13 +530,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));
|
||||
@@ -554,13 +557,7 @@ 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
|
||||
|
||||
+129
-7
@@ -2,10 +2,11 @@ import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { doseTracking, medications, shareTokens } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
@@ -22,6 +23,13 @@ const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) return token;
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -38,6 +46,91 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) return null;
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
async function getActiveShareToken(token: string): Promise<{
|
||||
share: typeof shareTokens.$inferSelect | null;
|
||||
reason: "not_found" | "expired" | "ok";
|
||||
}> {
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) return { share: null, reason: "not_found" };
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
return { share: null, reason: "expired" };
|
||||
}
|
||||
|
||||
return { share, reason: "ok" };
|
||||
}
|
||||
|
||||
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const medTakenBy = parseTakenByJson(medication.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
if (!intake) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
|
||||
if (expectedPersons.length === 0) {
|
||||
return parsedDose.personSuffix === null;
|
||||
}
|
||||
|
||||
if (!parsedDose.personSuffix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
@@ -56,6 +149,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
@@ -94,6 +188,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null, // Marked by the user themselves
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
@@ -213,9 +308,9 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
@@ -227,6 +322,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
@@ -249,12 +345,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -262,6 +366,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
@@ -270,8 +375,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -282,12 +392,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in unmark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -296,9 +414,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
request.log.info(
|
||||
`[ShareDose] Dose unmarked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.0";
|
||||
const EXPORT_VERSION = "1.1";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
|
||||
packCount: z.number().int().min(0).default(1),
|
||||
blistersPerPack: z.number().int().min(1).default(1),
|
||||
pillsPerBlister: z.number().int().min(1).default(1),
|
||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
||||
@@ -49,9 +50,18 @@ const medicationExportSchema = z.object({
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
isObsolete: z.boolean().default(false),
|
||||
obsoleteAt: z.string().nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
||||
prescriptionExpiryDate: z.string().nullable().optional(),
|
||||
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
|
||||
image: z.string().nullable().optional(), // base64 data URL or null
|
||||
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||
});
|
||||
@@ -62,10 +72,19 @@ const doseHistorySchema = z.object({
|
||||
scheduledTime: z.string(), // ISO datetime
|
||||
takenAt: z.string(), // ISO datetime
|
||||
markedBy: z.string().nullable().optional(),
|
||||
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||
dismissed: z.boolean().default(false),
|
||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||
});
|
||||
|
||||
const refillHistoryExportSchema = z.object({
|
||||
medicationRef: z.string(), // References _exportId
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: z.number().int().min(0).default(0),
|
||||
usedPrescription: z.boolean().default(false),
|
||||
refillDate: z.string(), // ISO datetime
|
||||
});
|
||||
|
||||
const shareLinkSchema = z.object({
|
||||
takenBy: z.string().min(1),
|
||||
scheduleDays: z.number().int().min(1).default(30),
|
||||
@@ -80,11 +99,13 @@ const settingsExportSchema = z
|
||||
notificationEmail: z.string().nullable().optional(),
|
||||
emailStockReminders: z.boolean().default(true),
|
||||
emailIntakeReminders: z.boolean().default(true),
|
||||
emailPrescriptionReminders: z.boolean().default(true),
|
||||
// Push notifications
|
||||
shoutrrrEnabled: z.boolean().optional(),
|
||||
shoutrrrUrl: z.string().nullable().optional(),
|
||||
shoutrrrStockReminders: z.boolean().default(true),
|
||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: z.number().int().default(7),
|
||||
repeatDailyReminders: z.boolean().default(false),
|
||||
@@ -96,9 +117,11 @@ const settingsExportSchema = z
|
||||
lowStockDays: z.number().int().default(30),
|
||||
normalStockDays: z.number().int().default(90),
|
||||
highStockDays: z.number().int().default(180),
|
||||
expiryWarningDays: z.number().int().default(90),
|
||||
// UI preferences
|
||||
language: z.string().default("en"),
|
||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
shareStockStatus: z.boolean().default(true),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -108,6 +131,7 @@ const importDataSchema = z.object({
|
||||
includeSensitiveData: z.boolean().default(false),
|
||||
medications: z.array(medicationExportSchema).default([]),
|
||||
doseHistory: z.array(doseHistorySchema).default([]),
|
||||
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||
settings: settingsExportSchema,
|
||||
shareLinks: z.array(shareLinkSchema).default([]),
|
||||
});
|
||||
@@ -117,7 +141,7 @@ const importDataSchema = z.object({
|
||||
// =============================================================================
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
@@ -275,6 +299,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
packCount: med.packCount ?? 1,
|
||||
blistersPerPack: med.blistersPerPack ?? 1,
|
||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||
totalPills: med.totalPills ?? null,
|
||||
looseTablets: med.looseTablets ?? 0,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
packageType: med.packageType ?? "blister",
|
||||
@@ -282,9 +307,18 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||
dismissedUntil: med.dismissedUntil ?? null,
|
||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||
};
|
||||
@@ -331,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
scheduledTime: scheduledTimeIso,
|
||||
takenAt: takenAtIso,
|
||||
markedBy: dose.markedBy,
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
};
|
||||
@@ -346,11 +381,13 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
// Only include sensitive data if requested
|
||||
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
|
||||
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
@@ -360,8 +397,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
expiryWarningDays: settings.expiryWarningDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareStockStatus: settings.shareStockStatus,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -392,6 +431,39 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
// 5. Load refill history
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
|
||||
const exportRefillHistory = refills
|
||||
.map((refill) => {
|
||||
const exportId = medIdToExportId.get(refill.medicationId);
|
||||
if (!exportId) return null; // Orphaned refill, skip
|
||||
|
||||
// Safely convert refillDate to ISO string
|
||||
let refillDateIso: string;
|
||||
try {
|
||||
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||
refillDateIso = refill.refillDate.toISOString();
|
||||
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||
const d = new Date(refill.refillDate);
|
||||
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: refillDateIso,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
// Build export object
|
||||
const exportData = {
|
||||
version: EXPORT_VERSION,
|
||||
@@ -399,12 +471,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications: exportMedications,
|
||||
doseHistory: exportDoseHistory,
|
||||
refillHistory: exportRefillHistory,
|
||||
settings: exportSettings,
|
||||
shareLinks: exportShareLinks,
|
||||
};
|
||||
|
||||
// Set download headers
|
||||
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||
reply.header("Content-Type", "application/json");
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
|
||||
@@ -455,7 +532,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in order: doses, share tokens, medications, settings
|
||||
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await db.delete(medications).where(eq(medications.userId, userId));
|
||||
@@ -497,10 +575,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -508,6 +588,14 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null, // Will be set after image is saved
|
||||
})
|
||||
.returning();
|
||||
@@ -539,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
});
|
||||
}
|
||||
@@ -551,10 +640,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||
@@ -564,8 +655,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -583,11 +676,27 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Import refill history with remapped medication IDs
|
||||
for (const refill of importData.refillHistory) {
|
||||
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned refill records
|
||||
|
||||
await db.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: {
|
||||
medications: importData.medications.length,
|
||||
doseHistory: importData.doseHistory.length,
|
||||
refillHistory: importData.refillHistory.length,
|
||||
settings: importData.settings ? 1 : 0,
|
||||
shareLinks: importData.shareLinks.length,
|
||||
},
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { and, eq, like } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
@@ -32,10 +36,13 @@ const blisterSchema = z.object({
|
||||
|
||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
name: z.string().trim().max(100).default(""),
|
||||
genericName: z.string().trim().max(100).nullable().optional(),
|
||||
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
||||
packageType: packageTypeSchema,
|
||||
@@ -46,14 +53,59 @@ const medicationSchema = z
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||
doseUnit: doseUnitSchema,
|
||||
medicationStartDate: medicationStartDateSchema,
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
||||
prescriptionExpiryDate: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat)
|
||||
// Accept either new intakes format or legacy blisters format
|
||||
intakes: z.array(intakeSchema).min(1).max(12).optional(),
|
||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" });
|
||||
.refine((data) => (data.name && data.name.length > 0) || (data.genericName && data.genericName.length > 0), {
|
||||
message: "Either 'name' or 'genericName' must be provided",
|
||||
path: ["name"],
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDate = data.medicationStartDate ?? "";
|
||||
if (!startDate) return true;
|
||||
|
||||
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
|
||||
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
|
||||
},
|
||||
{
|
||||
message: "Medication start date must be on or before all intake dates",
|
||||
path: ["medicationStartDate"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
if (data.prescriptionAuthorizedRefills == null || data.prescriptionRemainingRefills == null) return false;
|
||||
return data.prescriptionRemainingRefills <= data.prescriptionAuthorizedRefills;
|
||||
},
|
||||
{
|
||||
message: "When prescription is enabled, remaining refills must be <= authorized refills",
|
||||
path: ["prescriptionRemainingRefills"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
if (data.prescriptionAuthorizedRefills == null) return false;
|
||||
return data.prescriptionLowRefillThreshold <= data.prescriptionAuthorizedRefills;
|
||||
},
|
||||
{
|
||||
message: "When prescription is enabled, low refill threshold must be <= authorized refills",
|
||||
path: ["prescriptionLowRefillThreshold"],
|
||||
}
|
||||
);
|
||||
|
||||
export async function medicationRoutes(app: FastifyInstance) {
|
||||
// All medication routes require auth
|
||||
@@ -76,9 +128,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const includeObsolete = request.query.includeObsolete === "true";
|
||||
const whereClause = includeObsolete
|
||||
? eq(medications.userId, userId)
|
||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||
return rows.map((row) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -102,6 +158,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
doseUnit: row.doseUnit ?? "mg",
|
||||
medicationStartDate: row.medicationStartDate || null,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
// Legacy blisters format (for backward compat with frontend during transition)
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
@@ -109,6 +166,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
expiryDate: row.expiryDate,
|
||||
notes: row.notes,
|
||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
||||
isObsolete: row.isObsolete ?? false,
|
||||
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: row.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: row.prescriptionExpiryDate ?? null,
|
||||
dismissedUntil: row.dismissedUntil ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -132,8 +196,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
prescriptionAuthorizedRefills,
|
||||
prescriptionRemainingRefills,
|
||||
prescriptionLowRefillThreshold,
|
||||
prescriptionExpiryDate,
|
||||
intakeRemindersEnabled,
|
||||
intakes: inputIntakes,
|
||||
blisters: inputBlisters,
|
||||
@@ -185,8 +255,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: prescriptionExpiryDate || null,
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
@@ -210,12 +286,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
doseUnit: inserted.doseUnit ?? "mg",
|
||||
medicationStartDate: inserted.medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: inserted.imageUrl,
|
||||
expiryDate: inserted.expiryDate,
|
||||
notes: inserted.notes,
|
||||
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
||||
isObsolete: inserted.isObsolete ?? false,
|
||||
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: inserted.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: inserted.prescriptionExpiryDate ?? null,
|
||||
updatedAt: inserted.updatedAt,
|
||||
};
|
||||
});
|
||||
@@ -247,8 +331,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
prescriptionAuthorizedRefills,
|
||||
prescriptionRemainingRefills,
|
||||
prescriptionLowRefillThreshold,
|
||||
prescriptionExpiryDate,
|
||||
intakeRemindersEnabled,
|
||||
intakes: inputIntakes,
|
||||
blisters: inputBlisters,
|
||||
@@ -310,8 +400,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: prescriptionEnabled ? (prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: prescriptionEnabled ? (prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: prescriptionExpiryDate || null,
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
@@ -459,19 +555,85 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
doseUnit: result[0].doseUnit ?? "mg",
|
||||
medicationStartDate: result[0].medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: result[0].imageUrl,
|
||||
expiryDate: result[0].expiryDate,
|
||||
notes: result[0].notes,
|
||||
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
||||
isObsolete: result[0].isObsolete ?? false,
|
||||
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: result[0].prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: result[0].prescriptionExpiryDate ?? null,
|
||||
updatedAt: result[0].updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: true,
|
||||
obsoleteAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
|
||||
"/medications/:id/stock-adjustment",
|
||||
async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
@@ -486,16 +648,32 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
||||
const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||
if (
|
||||
looseTablets !== undefined &&
|
||||
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||
) {
|
||||
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||
}
|
||||
|
||||
const updateFields: {
|
||||
stockAdjustment: number;
|
||||
lastStockCorrectionAt: Date;
|
||||
updatedAt: Date;
|
||||
looseTablets?: number;
|
||||
} = {
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (looseTablets !== undefined) {
|
||||
updateFields.looseTablets = looseTablets;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.set(updateFields)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
@@ -523,10 +701,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(imagePath)) unlinkSync(imagePath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
const deleted = await db
|
||||
.delete(medications)
|
||||
@@ -549,24 +724,31 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const data = await req.file();
|
||||
if (!data) return reply.badRequest("No file uploaded");
|
||||
if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.badRequest("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" });
|
||||
}
|
||||
|
||||
const ext = extname(data.filename) || ".jpg";
|
||||
const filename = `med-${idNum}-${Date.now()}${ext}`;
|
||||
const filepath = resolve(IMAGES_DIR, filename);
|
||||
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;
|
||||
}
|
||||
|
||||
await pipeline(data.file, createWriteStream(filepath));
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old image if exists
|
||||
if (existing.imageUrl) {
|
||||
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(oldPath)) unlinkSync(oldPath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
@@ -588,10 +770,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(filepath)) unlinkSync(filepath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
@@ -616,7 +795,17 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const [settingsRow] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
@@ -624,20 +813,26 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
// Create a map of medication ID to taken dose count
|
||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
takenDoses.forEach((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
||||
if (!takenDosesMap.has(medId)) {
|
||||
takenDosesMap.set(medId, []);
|
||||
}
|
||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
||||
}
|
||||
if (parts.length < 3) return;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) return;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
let takenAtMs: number;
|
||||
if (Number.isFinite(rawTakenAt)) {
|
||||
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||
} else {
|
||||
takenAtMs = new Date(dose.takenAt).getTime();
|
||||
}
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
});
|
||||
|
||||
// Use current time as the reference point for "available" stock
|
||||
@@ -664,69 +859,109 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
? looseTablets + stockAdjustment
|
||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||
|
||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
||||
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
// Build a Set of taken dose IDs for quick lookup
|
||||
const takenDoseIds = new Set(
|
||||
takenDoses
|
||||
.filter((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
||||
})
|
||||
.map((dose) => dose.doseId)
|
||||
);
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
// After a stock correction, start counting from the NEXT scheduled
|
||||
// dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart.getTime();
|
||||
}
|
||||
if (effectiveStart > now.getTime()) return;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intakePerson) {
|
||||
peopleForThisIntake = [intakePerson];
|
||||
} else if (fallbackPeople.length > 0) {
|
||||
peopleForThisIntake = fallbackPeople;
|
||||
} else {
|
||||
peopleForThisIntake = [null];
|
||||
}
|
||||
|
||||
// Get the people for this intake (from intakes array or medication takenBy)
|
||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const peopleForThisIntake: (string | null)[] = intakePerson
|
||||
? [intakePerson]
|
||||
: takenByJson.length > 0
|
||||
? takenByJson
|
||||
: [null];
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
// Generate expected dose IDs and check if they're taken
|
||||
for (let i = 0; i < occurrences; i++) {
|
||||
const doseDate = new Date(effectiveStart + i * period);
|
||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
// Check if each person has taken this dose
|
||||
for (const person of peopleForThisIntake) {
|
||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
if (takenDoseIds.has(doseId)) {
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumedUntilNow += blister.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||
|
||||
@@ -772,6 +1007,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
medicationId: row.id,
|
||||
medicationName: row.name,
|
||||
totalPills: currentStock,
|
||||
currentPills: currentStock,
|
||||
plannerUsage: usageTotal,
|
||||
blisterSize: pillsPerBlister,
|
||||
blistersNeeded,
|
||||
|
||||
+20
-14
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import * as client from "openid-client";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -63,7 +63,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/login - Initiates OIDC flow
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
app.get("/auth/oidc/login", async (request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
@@ -104,8 +104,8 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
});
|
||||
@@ -120,7 +120,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
@@ -131,14 +131,14 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
request.log.error("[OIDC] Missing sub claim in token response");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
@@ -167,11 +167,17 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// 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 });
|
||||
request.log.error(
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
@@ -210,8 +216,8 @@ 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);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +240,7 @@ async function findOrCreateOIDCUser(
|
||||
}
|
||||
|
||||
// Check if username already exists (potential collision)
|
||||
const [existingByUsername] = await db.select().from(users).where(eq(users.username, username));
|
||||
const [existingByUsername] = await db.select().from(users).where(sql`lower(${users.username}) = lower(${username})`);
|
||||
|
||||
if (existingByUsername) {
|
||||
// Username collision! Check if it's a local user without OIDC linked
|
||||
@@ -252,7 +258,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;
|
||||
}
|
||||
|
||||
|
||||
+306
-20
@@ -1,5 +1,8 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import {
|
||||
getDateLocale,
|
||||
getFooterHtml,
|
||||
@@ -61,6 +64,19 @@ type ReminderEmailBody = {
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
};
|
||||
|
||||
type PrescriptionReminderItem = {
|
||||
name: string;
|
||||
remainingRefills: number;
|
||||
threshold: number;
|
||||
expiryDate?: string | null;
|
||||
};
|
||||
|
||||
type PrescriptionReminderBody = {
|
||||
email: string;
|
||||
prescriptionLow: PrescriptionReminderItem[];
|
||||
language?: Language;
|
||||
};
|
||||
|
||||
export async function plannerRoutes(app: FastifyInstance) {
|
||||
// Add auth hook for all planner routes
|
||||
app.addHook("preHandler", requireAuth);
|
||||
@@ -87,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ id: medications.id })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||
if (activeRows.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -116,26 +142,45 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
})
|
||||
);
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const outOfStockCount = activeRows.filter((r) => !r.enough).length;
|
||||
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
|
||||
|
||||
// Load prescription data for medications referenced in planner rows
|
||||
const medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: medications.id,
|
||||
prescriptionEnabled: medications.prescriptionEnabled,
|
||||
prescriptionRemainingRefills: medications.prescriptionRemainingRefills,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId))
|
||||
: [];
|
||||
const prescriptionMap = new Map(allMeds.map((m) => [m.id, m]));
|
||||
|
||||
// Build plain text (shared between email and push)
|
||||
const plainText = `${dc.title}
|
||||
${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows
|
||||
${activeRows
|
||||
.map((r) => {
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
||||
const medPrescription = prescriptionMap.get(r.medicationId);
|
||||
const rxRefills = medPrescription?.prescriptionEnabled
|
||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||
: dc.prescriptionNotApplicable;
|
||||
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
||||
const available = isBottle
|
||||
? `${loosePills} ${tr.common.pills}`
|
||||
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
return `${r.medicationName}: ${usage}, ${needed}, ${available} - ${status}`;
|
||||
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
||||
})
|
||||
.join("\n")}
|
||||
|
||||
@@ -156,7 +201,7 @@ ${getFooterPlain(language)}`;
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
const tableRows = activeRows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
@@ -169,6 +214,12 @@ ${getFooterPlain(language)}`;
|
||||
// "Blisters needed" column: dash for bottles
|
||||
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
||||
|
||||
// "Prescription refills" column
|
||||
const medPrescription = prescriptionMap.get(row.medicationId);
|
||||
const rxCell = medPrescription?.prescriptionEnabled
|
||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||
: dc.prescriptionNotApplicable;
|
||||
|
||||
// "Available" column: match frontend format
|
||||
let availableCell: string;
|
||||
if (isBottle) {
|
||||
@@ -180,11 +231,14 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rowBg = row.enough ? "" : " background: #fef2f2;";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<tr style="${rowBg}">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
@@ -221,6 +275,7 @@ ${getFooterPlain(language)}`;
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.medication}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.usage}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.needed}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.prescriptionRefills}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.available}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.status}</th>
|
||||
</tr>
|
||||
@@ -267,7 +322,7 @@ ${getFooterPlain(language)}`;
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||
const pushMessage = `${summaryText}\n\n${rows
|
||||
const pushMessage = `${summaryText}\n\n${activeRows
|
||||
.map((r) => {
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
@@ -315,6 +370,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name, genericName: medications.genericName })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredLowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -329,9 +394,9 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Separate into 3 categories: empty, critical, and low stock
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
|
||||
// Build shared notification content (method-agnostic)
|
||||
const titleParts: string[] = [];
|
||||
@@ -344,7 +409,7 @@ ${getFooterPlain(language)}`;
|
||||
if (lowStockMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
}
|
||||
const notificationTitle = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
const notificationTitle = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
// Build description text
|
||||
let descriptionText: string;
|
||||
@@ -444,8 +509,10 @@ ${getFooterPlain(language)}`;
|
||||
const buildTableRow = (row: LowStockItem) => {
|
||||
const isEmpty = row.medsLeft <= 0;
|
||||
const isCritical = row.isCritical !== false;
|
||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
||||
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const safeName = escapeHtml(row.name);
|
||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||
@@ -459,7 +526,7 @@ ${getFooterPlain(language)}`;
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
const tableRows = filteredLowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
@@ -485,8 +552,7 @@ ${getFooterPlain(language)}`;
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 16px 0 0 0;">${getFooterHtml(language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -507,7 +573,7 @@ ${getFooterPlain(language)}`;
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `MedAssist-ng - ${subjectText}`,
|
||||
subject: `MedAssist-ng: ${subjectText}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
@@ -522,7 +588,7 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
||||
@@ -539,12 +605,12 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Update the reminder state to record this notification was sent
|
||||
if (results.email || results.push) {
|
||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||
const singleChannel = results.email ? "email" : "push";
|
||||
const channel = results.email && results.push ? "both" : singleChannel;
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
const firstMed = lowStock[0];
|
||||
const medNames = lowStock.length > 1 ? `${firstMed.name} (+${lowStock.length - 1})` : firstMed?.name;
|
||||
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(userId, "stock", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -564,4 +630,224 @@ ${getFooterPlain(language)}`;
|
||||
return reply.status(400).send({ error: "No notification channels configured" });
|
||||
}
|
||||
});
|
||||
|
||||
// Manual prescription reminder (supports email and push)
|
||||
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
|
||||
const { email, prescriptionLow } = request.body;
|
||||
|
||||
if (!prescriptionLow || prescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing prescription reminder data" });
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name, genericName: medications.genericName })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredPrescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const lines = filteredPrescriptionLow.map((item) => {
|
||||
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
|
||||
if (item.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
name: item.name,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
}
|
||||
return `- ${t(tr.prescriptionReminder.line, {
|
||||
name: item.name,
|
||||
refills: item.remainingRefills,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
if (userSettings.emailEnabled && userSettings.emailPrescriptionReminders && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const subject =
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const tableRows = filteredPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
const safeRefills = Number(item.remainingRefills) || 0;
|
||||
const safeThreshold = Number(item.threshold) || 0;
|
||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const emailTitle = emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title;
|
||||
const text = `${emailTitle}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emailTitle}</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${bodyText}</p>
|
||||
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${emptyRx.length > 0 ? "background: #fef2f2; border: 1px solid #dc2626;" : "background: #fffbeb; border: 1px solid #f59e0b;"}">
|
||||
<p style="margin: 0; ${emptyRx.length > 0 ? "color: #dc2626; font-weight: 600;" : "color: #b45309; font-weight: 500;"} font-size: 13px;">
|
||||
${alertText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 460px;">
|
||||
<thead>
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.medication}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.refillsLeft}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.reminderThreshold}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.prescriptionReminder.tableHeaders.prescriptionExpires}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
} else {
|
||||
results.errors.push(`Push: ${pushResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Push: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.email || results.push) {
|
||||
const singleChannel = results.email ? "email" : "push";
|
||||
const channel = results.email && results.push ? "both" : singleChannel;
|
||||
updateReminderSentTime("prescription", channel);
|
||||
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
||||
}
|
||||
|
||||
const sentChannels: string[] = [];
|
||||
if (results.email) sentChannels.push("email");
|
||||
if (results.push) sentChannels.push("push");
|
||||
|
||||
if (sentChannels.length > 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Prescription reminder sent via ${sentChannels.join(" and ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
return reply.status(500).send({ error: results.errors.join("; ") });
|
||||
}
|
||||
|
||||
return reply.status(400).send({ error: "No notification channels configured" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const refillSchema = z
|
||||
.object({
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: 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",
|
||||
@@ -50,19 +51,46 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
const { packsAdded, loosePillsAdded } = parsed.data;
|
||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
||||
const effectiveLoosePillsAdded = loosePillsAdded;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
|
||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||
}
|
||||
|
||||
if (usePrescription) {
|
||||
if (!(med.prescriptionEnabled ?? false)) {
|
||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||
}
|
||||
if (remainingPrescriptionRefills <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
}
|
||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
}
|
||||
}
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + packsAdded;
|
||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||
const newPackCount = med.packCount + effectivePacksAdded;
|
||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
|
||||
let consumedRefills = 0;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
.set({
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
stockAdjustment: 0, // Reset offset since we're adding to base stock
|
||||
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
|
||||
prescriptionRemainingRefills: newRemainingRefills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
@@ -73,15 +101,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
usedPrescription: usePrescription,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 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 totalPillsAdded = isBottle
|
||||
? effectiveLoosePillsAdded
|
||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||
const newTotalPills = isBottle
|
||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||
@@ -90,8 +120,8 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
@@ -100,6 +130,13 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: newTotalPills,
|
||||
},
|
||||
prescription: {
|
||||
used: usePrescription,
|
||||
remainingRefills: newRemainingRefills,
|
||||
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
enabled: med.prescriptionEnabled ?? false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -132,6 +169,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { eq } 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";
|
||||
|
||||
const reportDataSchema = z.object({
|
||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||
});
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
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", async (req, reply) => {
|
||||
const parsed = reportDataSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const { medicationIds } = parsed.data;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
if (!userMedIds.has(id)) {
|
||||
return reply.status(403).send({ error: "Access denied to medication" });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch dose tracking for all requested medications
|
||||
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||
const allDoses = await db
|
||||
.select({
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
dismissed: doseTracking.dismissed,
|
||||
takenSource: doseTracking.takenSource,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
|
||||
// Group doses by medication ID
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||
for (const dose of allDoses) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
dismissed: dose.dismissed,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch refill history for requested medications
|
||||
const result: Record<
|
||||
number,
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const medId of medicationIds) {
|
||||
const doses = dosesByMed.get(medId) ?? [];
|
||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||
|
||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||
|
||||
// Get refills for this medication
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
automaticDosesTaken: automaticTakenDoses.length,
|
||||
dosesDismissed: dismissedDoses.length,
|
||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||
refills: refills.map((r) => ({
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
+318
-39
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
@@ -15,10 +15,12 @@ export type UserSettings = {
|
||||
notificationEmail: string | null;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
emailPrescriptionReminders: boolean;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string | null;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
shoutrrrPrescriptionReminders: boolean;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
@@ -31,6 +33,9 @@ export type UserSettings = {
|
||||
language: Language;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
upcomingTodayOnly: boolean;
|
||||
shareScheduleTodayOnly: boolean;
|
||||
swapDashboardMainSections: boolean;
|
||||
lastAutoEmailSent: string | null;
|
||||
lastNotificationType: string | null;
|
||||
lastNotificationChannel: string | null;
|
||||
@@ -39,6 +44,9 @@ export type UserSettings = {
|
||||
lastStockReminderSent: string | null;
|
||||
lastStockReminderChannel: string | null;
|
||||
lastStockReminderMedNames: string | null;
|
||||
lastPrescriptionReminderSent: string | null;
|
||||
lastPrescriptionReminderChannel: string | null;
|
||||
lastPrescriptionReminderMedNames: string | null;
|
||||
};
|
||||
|
||||
type SettingsBody = {
|
||||
@@ -53,8 +61,10 @@ type SettingsBody = {
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
emailPrescriptionReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
shoutrrrPrescriptionReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
@@ -62,6 +72,9 @@ type SettingsBody = {
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
upcomingTodayOnly: boolean;
|
||||
shareScheduleTodayOnly: boolean;
|
||||
swapDashboardMainSections: boolean;
|
||||
};
|
||||
|
||||
type TestEmailBody = {
|
||||
@@ -72,6 +85,21 @@ type TestShoutrrrBody = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
function getNotificationProvider(url: string): string {
|
||||
if (url.startsWith("discord://")) return "discord";
|
||||
if (url.startsWith("telegram://")) return "telegram";
|
||||
if (url.startsWith("gotify://")) return "gotify";
|
||||
if (url.startsWith("pushover://")) return "pushover";
|
||||
if (url.startsWith("ntfy://")) return "ntfy";
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname || "https";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse boolean env vars
|
||||
function envBool(key: string, defaultVal: boolean): boolean {
|
||||
const val = process.env[key];
|
||||
@@ -94,10 +122,12 @@ function getDefaultSettings() {
|
||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
|
||||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
|
||||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||
@@ -110,6 +140,9 @@ function getDefaultSettings() {
|
||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
||||
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
||||
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
||||
swapDashboardMainSections: false,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
@@ -118,6 +151,9 @@ function getDefaultSettings() {
|
||||
lastStockReminderSent: null,
|
||||
lastStockReminderChannel: null,
|
||||
lastStockReminderMedNames: null,
|
||||
lastPrescriptionReminderSent: null,
|
||||
lastPrescriptionReminderChannel: null,
|
||||
lastPrescriptionReminderMedNames: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,10 +184,12 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
@@ -164,6 +202,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
@@ -172,6 +213,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
||||
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
||||
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
||||
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,10 +228,12 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
@@ -200,6 +246,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
@@ -208,6 +257,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
||||
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
||||
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
||||
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -217,7 +269,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
@@ -250,8 +302,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
shoutrrrUrl: settings.shoutrrrUrl ?? "",
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
@@ -259,6 +313,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
||||
@@ -276,6 +333,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
lastStockReminderSent: settings.lastStockReminderSent ?? null,
|
||||
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
|
||||
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
|
||||
// Prescription reminder tracking (separate from stock/intake)
|
||||
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
|
||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||
// Server settings (from .env, read-only)
|
||||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||||
});
|
||||
@@ -303,10 +364,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
notificationEmail: body.notificationEmail || null,
|
||||
emailStockReminders: body.emailStockReminders ?? true,
|
||||
emailIntakeReminders: body.emailIntakeReminders ?? true,
|
||||
emailPrescriptionReminders: body.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: body.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||
shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||
@@ -319,6 +382,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
language: body.language ?? "en",
|
||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: body.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -334,6 +400,30 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
|
||||
// Update only the language setting (lightweight, called on dropdown change)
|
||||
app.put<{ Body: { language: string } }>("/settings/language", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { language } = request.body;
|
||||
|
||||
if (!language || !["en", "de"].includes(language)) {
|
||||
return reply.status(400).send({ error: "Invalid language" });
|
||||
}
|
||||
|
||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (existingSettings.length > 0) {
|
||||
await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId));
|
||||
} else {
|
||||
await db.insert(userSettings).values({
|
||||
userId,
|
||||
...getDefaultSettings(),
|
||||
language,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
|
||||
// Test email - use SMTP settings from process.env
|
||||
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
@@ -392,6 +482,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getNotificationProvider(url);
|
||||
const result = await sendShoutrrrNotification(
|
||||
url,
|
||||
"MedAssist-ng Test",
|
||||
@@ -399,11 +490,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} else {
|
||||
request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed");
|
||||
return reply.status(500).send({ error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ provider: getNotificationProvider(url), error },
|
||||
"[Settings] Unexpected error while sending test push notification"
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||||
}
|
||||
@@ -416,6 +513,28 @@ function sanitizeNotificationUrl(
|
||||
urlStr: string
|
||||
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
||||
try {
|
||||
// Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID
|
||||
if (urlStr.startsWith("discord://")) {
|
||||
const parsedDiscord = new URL(urlStr);
|
||||
const webhookId = parsedDiscord.hostname;
|
||||
const webhookToken = parsedDiscord.username;
|
||||
|
||||
if (!webhookId || !webhookToken) {
|
||||
return { error: "Invalid Discord URL format" };
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(webhookId)) {
|
||||
return { error: "Invalid Discord webhook ID" };
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
|
||||
return { error: "Invalid Discord webhook token" };
|
||||
}
|
||||
|
||||
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
|
||||
return { url: discordWebhookUrl, isNtfy: false };
|
||||
}
|
||||
|
||||
// Convert ntfy:// to https:// for parsing, track if it was ntfy
|
||||
const isNtfy = urlStr.startsWith("ntfy://");
|
||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||
@@ -427,38 +546,9 @@ function sanitizeNotificationUrl(
|
||||
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
||||
}
|
||||
|
||||
// Block private/internal IP addresses
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return { error: "Localhost URLs are not allowed" };
|
||||
}
|
||||
|
||||
// Block private IP ranges (basic check)
|
||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipMatch) {
|
||||
const [, a, b] = ipMatch.map(Number);
|
||||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
||||
if (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
) {
|
||||
return { error: "Private IP addresses are not allowed" };
|
||||
}
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".lan") ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return { error: "Internal hostnames are not allowed" };
|
||||
const hostValidationError = validateNotificationHostname(parsed.hostname);
|
||||
if (hostValidationError) {
|
||||
return { error: hostValidationError };
|
||||
}
|
||||
|
||||
// Reconstruct URL from validated components - this breaks taint tracking
|
||||
@@ -475,6 +565,39 @@ function sanitizeNotificationUrl(
|
||||
}
|
||||
}
|
||||
|
||||
function validateNotificationHostname(hostnameRaw: string): string | null {
|
||||
const hostname = hostnameRaw.toLowerCase();
|
||||
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return "Localhost URLs are not allowed";
|
||||
}
|
||||
|
||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipMatch) {
|
||||
const [, a, b] = ipMatch.map(Number);
|
||||
if (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
) {
|
||||
return "Private IP addresses are not allowed";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".lan") ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return "Internal hostnames are not allowed";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||
export async function sendShoutrrrNotification(
|
||||
urlStr: string,
|
||||
@@ -482,6 +605,149 @@ export async function sendShoutrrrNotification(
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (urlStr.startsWith("pushover://")) {
|
||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||
const atIndex = pushoverAuthority.lastIndexOf("@");
|
||||
const credentialPart = atIndex >= 0 ? pushoverAuthority.slice(0, atIndex) : "";
|
||||
const userKey = atIndex >= 0 ? pushoverAuthority.slice(atIndex + 1) : "";
|
||||
|
||||
const tokenSeparatorIndex = credentialPart.indexOf(":");
|
||||
const apiToken = tokenSeparatorIndex >= 0 ? credentialPart.slice(tokenSeparatorIndex + 1) : "";
|
||||
|
||||
const parsedPushover = new URL(urlStr);
|
||||
|
||||
if (!apiToken || !userKey) {
|
||||
return { success: false, error: "Invalid Pushover URL format" };
|
||||
}
|
||||
|
||||
const pushoverBody = new URLSearchParams({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
const devices = parsedPushover.searchParams.get("devices");
|
||||
if (devices) {
|
||||
pushoverBody.set("device", devices);
|
||||
}
|
||||
|
||||
const priority = parsedPushover.searchParams.get("priority");
|
||||
if (priority && /^-?\d+$/.test(priority)) {
|
||||
pushoverBody.set("priority", priority);
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: pushoverBody.toString(),
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (response.ok) return { success: true };
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
}
|
||||
|
||||
if (urlStr.startsWith("telegram://")) {
|
||||
const parsedTelegram = new URL(urlStr);
|
||||
const token = parsedTelegram.username;
|
||||
if (!token || parsedTelegram.hostname !== "telegram") {
|
||||
return { success: false, error: "Invalid Telegram URL format" };
|
||||
}
|
||||
|
||||
const chatsRaw = parsedTelegram.searchParams.get("chats") ?? parsedTelegram.searchParams.get("channels") ?? "";
|
||||
const chats = chatsRaw
|
||||
.split(",")
|
||||
.map((chat) => chat.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (chats.length === 0) {
|
||||
return { success: false, error: "Telegram URL requires chats parameter" };
|
||||
}
|
||||
|
||||
const parseModeRaw = parsedTelegram.searchParams.get("parseMode")?.toLowerCase();
|
||||
let parseMode: "HTML" | "Markdown" | "MarkdownV2" | undefined;
|
||||
if (parseModeRaw === "html") {
|
||||
parseMode = "HTML";
|
||||
} else if (parseModeRaw === "markdown") {
|
||||
parseMode = "Markdown";
|
||||
} else if (parseModeRaw === "markdownv2") {
|
||||
parseMode = "MarkdownV2";
|
||||
}
|
||||
|
||||
const notificationRaw = parsedTelegram.searchParams.get("notification")?.toLowerCase();
|
||||
const disableNotification = notificationRaw === "no" || notificationRaw === "false";
|
||||
|
||||
const previewRaw = parsedTelegram.searchParams.get("preview")?.toLowerCase();
|
||||
const disablePreview = previewRaw === "no" || previewRaw === "false";
|
||||
|
||||
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
||||
return { success: false, error: "Invalid Telegram token format" };
|
||||
}
|
||||
|
||||
const telegramSendMessageUrl = new URL("/bot/sendMessage", "https://api.telegram.org");
|
||||
telegramSendMessageUrl.pathname = `/bot${token}/sendMessage`;
|
||||
|
||||
for (const chatId of chats) {
|
||||
const payload: Record<string, string | boolean> = {
|
||||
chat_id: chatId,
|
||||
text: `${title}\n\n${message}`,
|
||||
disable_notification: disableNotification,
|
||||
disable_web_page_preview: disablePreview,
|
||||
};
|
||||
if (parseMode) {
|
||||
payload.parse_mode = parseMode;
|
||||
}
|
||||
|
||||
// codeql[js/request-forgery]: host is fixed to api.telegram.org and token is pattern-validated.
|
||||
const response = await fetch(telegramSendMessageUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (urlStr.startsWith("gotify://")) {
|
||||
const parsedGotify = new URL(urlStr);
|
||||
const hostValidationError = validateNotificationHostname(parsedGotify.hostname);
|
||||
if (hostValidationError) {
|
||||
return { success: false, error: hostValidationError };
|
||||
}
|
||||
|
||||
const pathParts = parsedGotify.pathname
|
||||
.split("/")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
return { success: false, error: "Invalid Gotify URL format" };
|
||||
}
|
||||
|
||||
const token = pathParts[pathParts.length - 1];
|
||||
const basePath = pathParts.slice(0, -1).join("/");
|
||||
|
||||
const disableTlsRaw = parsedGotify.searchParams.get("disabletls")?.toLowerCase();
|
||||
const protocol = disableTlsRaw === "yes" || disableTlsRaw === "true" || disableTlsRaw === "1" ? "http" : "https";
|
||||
|
||||
const gotifyWebhookUrl = `${protocol}://${parsedGotify.host}${basePath ? `/${basePath}` : ""}/message?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const gotifyPriority = parsedGotify.searchParams.get("priority");
|
||||
const gotifyMessage = gotifyPriority ? `${message}\n\n(priority=${gotifyPriority})` : message;
|
||||
|
||||
// Reuse validated https webhook path to keep a single outbound request sink.
|
||||
return sendShoutrrrNotification(gotifyWebhookUrl, title, gotifyMessage);
|
||||
}
|
||||
|
||||
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
|
||||
// from validated components, breaking taint tracking
|
||||
const validation = sanitizeNotificationUrl(urlStr);
|
||||
@@ -490,7 +756,7 @@ export async function sendShoutrrrNotification(
|
||||
}
|
||||
|
||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
@@ -509,14 +775,17 @@ export async function sendShoutrrrNotification(
|
||||
// Use JSON format only for known webhook services that require it
|
||||
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
|
||||
let isJsonWebhook = false;
|
||||
let isDiscordWebhook = false;
|
||||
try {
|
||||
const parsedUrl = new URL(sanitizedUrl);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
const pathname = parsedUrl.pathname.toLowerCase();
|
||||
isDiscordWebhook =
|
||||
(hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks");
|
||||
|
||||
isJsonWebhook =
|
||||
// Discord webhooks
|
||||
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
|
||||
isDiscordWebhook ||
|
||||
// Slack webhooks
|
||||
hostname === "hooks.slack.com" ||
|
||||
hostname.endsWith(".hooks.slack.com") ||
|
||||
@@ -533,7 +802,10 @@ export async function sendShoutrrrNotification(
|
||||
// This works for ntfy, Apprise, and most simple push services
|
||||
if (!isJsonWebhook) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { Title: cleanTitle, Tags: "pill" };
|
||||
// Use RFC 2047 Base64 encoding for Title header to safely pass non-ASCII
|
||||
// characters (umlauts, accents, etc.) through HTTP headers
|
||||
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
||||
headers = { Title: encodedTitle, Tags: "pill" };
|
||||
body = message;
|
||||
|
||||
// Add auth if present (extracted during sanitization)
|
||||
@@ -543,9 +815,16 @@ export async function sendShoutrrrNotification(
|
||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
if (isDiscordWebhook) {
|
||||
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
||||
} else {
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
||||
return {
|
||||
success: false,
|
||||
error: "Unsupported URL format. Use ntfy://, discord://, pushover://, gotify://, telegram://, or https:// URL",
|
||||
};
|
||||
}
|
||||
|
||||
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
|
||||
|
||||
+42
-12
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -14,9 +14,6 @@ import {
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Share token validity: 1 year in milliseconds
|
||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
@@ -25,6 +22,11 @@ const createShareSchema = z.object({
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
});
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) return token;
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -54,6 +56,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// 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: ${maskToken(token)}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -62,6 +65,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||
);
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
return reply.status(410).send({
|
||||
@@ -154,6 +160,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,25 +203,47 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique token (8 bytes = 16 hex chars)
|
||||
// Keep exactly one active share link per person/user.
|
||||
// If a link already exists, return the same token and only update settings.
|
||||
const [existingShare] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||
|
||||
if (existingShare) {
|
||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
// Set expiration date (1 year from now)
|
||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
||||
|
||||
// Create share token
|
||||
await db.insert(shareTokens).values({
|
||||
userId: userId,
|
||||
userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt,
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
getTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type Intake,
|
||||
type IntakeReminderState,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
@@ -51,6 +50,114 @@ 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 autoMarkDueIntakesAsTaken(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
locale: string,
|
||||
tz: string,
|
||||
logger: ServiceLogger
|
||||
): Promise<number> {
|
||||
if (settings.stockCalculationMode !== "automatic") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const existingToday = await db
|
||||
.select({ doseId: doseTracking.doseId })
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, settings.userId),
|
||||
gte(doseTracking.takenAt, todayStart),
|
||||
lte(doseTracking.takenAt, todayEnd)
|
||||
)
|
||||
);
|
||||
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||
|
||||
let inserted = 0;
|
||||
|
||||
for (const med of rows) {
|
||||
if (med.isObsolete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
if (intakes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = med.name || med.genericName || "";
|
||||
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;
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
takenAt: intake.intakeTime,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
existingDoseIds.add(doseId);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function sendIntakeReminderEmail(
|
||||
email: string,
|
||||
intakes: UpcomingIntake[],
|
||||
@@ -247,6 +354,17 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||
|
||||
// Check if any intake reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||
@@ -263,11 +381,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
);
|
||||
|
||||
// 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) {
|
||||
@@ -281,9 +394,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
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 todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
@@ -306,9 +416,10 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
);
|
||||
// Medication-level takenBy (for fallback/display purposes)
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = med.name || med.genericName || "";
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
|
||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
|
||||
);
|
||||
|
||||
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
||||
@@ -321,7 +432,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
});
|
||||
|
||||
// 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)"}`
|
||||
@@ -329,7 +440,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// Always get upcoming intakes (15 min before) for first reminders
|
||||
const upcomingIntakes = getUpcomingIntakes(
|
||||
med.name,
|
||||
medDisplayName,
|
||||
[intake],
|
||||
REMINDER_MINUTES_BEFORE,
|
||||
medicationTakenBy,
|
||||
@@ -356,7 +467,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||
if (settings.repeatRemindersEnabled) {
|
||||
const allTodaysIntakes = getTodaysIntakes(
|
||||
med.name,
|
||||
medDisplayName,
|
||||
[intake],
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
@@ -684,7 +795,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -194,6 +194,29 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject duplicate username regardless of case", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "CaseUser",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "caseuser",
|
||||
password: "AnotherPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject short password", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -222,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should register with trimmed username when input has whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " trimuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json().user.username).toBe("trimuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on registration", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should reject duplicate username even with surrounding whitespace", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "spacedupe",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " spacedupe ",
|
||||
password: "AnotherPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject invalid username characters", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -271,8 +345,23 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
|
||||
// Should set cookies
|
||||
const cookies = response.cookies;
|
||||
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
||||
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
||||
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
|
||||
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should login case-insensitively with different username casing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: "LOGINUSER",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
expect(response.json().user.username).toBe("loginuser");
|
||||
});
|
||||
|
||||
it("should reject invalid password", async () => {
|
||||
@@ -303,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||
});
|
||||
|
||||
it("should login successfully when username has leading/trailing whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " loginuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
expect(response.json().user.username).toBe("loginuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on login", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should support rememberMe option", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -355,7 +473,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -418,7 +536,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -468,7 +586,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
@@ -566,7 +684,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -615,7 +733,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -651,7 +769,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -704,7 +822,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
// Delete account
|
||||
const response = await app.inject({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ClientTestOptions = {
|
||||
dirWritable?: boolean;
|
||||
authEnabled?: boolean;
|
||||
};
|
||||
|
||||
async function loadDbClientModule(options: ClientTestOptions = {}) {
|
||||
const { dirWritable = true, authEnabled = false } = options;
|
||||
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
process.env.AUTH_ENABLED = authEnabled ? "true" : "false";
|
||||
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||
|
||||
const existsSync = vi.fn().mockReturnValue(false);
|
||||
const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 });
|
||||
vi.doMock("node:fs", () => ({ existsSync, statSync }));
|
||||
|
||||
const dotenvConfig = vi.fn();
|
||||
vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } }));
|
||||
|
||||
const createClient = vi.fn().mockReturnValue({ execute: vi.fn() });
|
||||
vi.doMock("@libsql/client", () => ({ createClient }));
|
||||
|
||||
const drizzle = vi.fn().mockReturnValue({ __db: true });
|
||||
vi.doMock("drizzle-orm/libsql", () => ({ drizzle }));
|
||||
|
||||
const ensureDataDirectory = vi
|
||||
.fn()
|
||||
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||
const getDbPaths = vi.fn().mockReturnValue({
|
||||
dataDir: "/tmp/medassist-data",
|
||||
dbPath: "/tmp/medassist-data/medassist.db",
|
||||
url: "file:/tmp/medassist-data/medassist.db",
|
||||
});
|
||||
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||
const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
||||
|
||||
vi.doMock("../db/db-utils.js", () => ({
|
||||
buildDbUrl: vi.fn(),
|
||||
getDataDir: vi.fn(),
|
||||
ensureDataDirectory,
|
||||
getDbPaths,
|
||||
runDrizzleMigrations,
|
||||
runAlterMigrations,
|
||||
repairTrailingHyphenDoseIds,
|
||||
repairOrphanedDoseIds,
|
||||
ensureDefaultUser,
|
||||
}));
|
||||
|
||||
const log = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
vi.doMock("../utils/logger.js", () => ({ log }));
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
|
||||
const modulePromise = import("../db/client.js");
|
||||
|
||||
return {
|
||||
modulePromise,
|
||||
mocks: {
|
||||
existsSync,
|
||||
statSync,
|
||||
dotenvConfig,
|
||||
createClient,
|
||||
drizzle,
|
||||
ensureDataDirectory,
|
||||
getDbPaths,
|
||||
runDrizzleMigrations,
|
||||
runAlterMigrations,
|
||||
repairTrailingHyphenDoseIds,
|
||||
repairOrphanedDoseIds,
|
||||
ensureDefaultUser,
|
||||
log,
|
||||
exitSpy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("db/client bootstrap", () => {
|
||||
it("initializes db and runs migrations when directory is writable", async () => {
|
||||
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false });
|
||||
const mod = await modulePromise;
|
||||
|
||||
expect(mod.db).toBeTruthy();
|
||||
expect(mod.migrationsReady).toBeInstanceOf(Promise);
|
||||
await mod.migrationsReady;
|
||||
|
||||
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
||||
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false);
|
||||
});
|
||||
|
||||
it("passes auth-enabled flag to ensureDefaultUser", async () => {
|
||||
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true });
|
||||
const mod = await modulePromise;
|
||||
await mod.migrationsReady;
|
||||
|
||||
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true);
|
||||
});
|
||||
|
||||
it("exits when data directory is not writable", async () => {
|
||||
const { modulePromise } = await loadDbClientModule({ dirWritable: false });
|
||||
await expect(modulePromise).rejects.toThrow("process.exit:1");
|
||||
});
|
||||
});
|
||||
@@ -271,7 +271,7 @@ describe("Dose Tracking API", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(2);
|
||||
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
// Each dose should have a takenAt timestamp
|
||||
for (const dose of data.doses) {
|
||||
expect(dose.takenAt).toBeTypeOf("number");
|
||||
|
||||
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { settingsRoutes } = await import("../routes/settings.js");
|
||||
const { healthRoutes } = await import("../routes/health.js");
|
||||
const { refillRoutes } = await import("../routes/refills.js");
|
||||
const { reportRoutes } = await import("../routes/report.js");
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
|
||||
// =============================================================================
|
||||
@@ -99,6 +100,14 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
|
||||
prescription_expiry_date text,
|
||||
dismissed_until text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -110,10 +119,12 @@ async function createSchema(client: Client) {
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
email_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
@@ -127,6 +138,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
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,
|
||||
@@ -135,6 +149,9 @@ async function createSchema(client: Client) {
|
||||
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
|
||||
)`,
|
||||
@@ -154,6 +171,7 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
@@ -163,6 +181,7 @@ async function createSchema(client: Client) {
|
||||
user_id integer NOT NULL,
|
||||
packs_added integer NOT NULL DEFAULT 0,
|
||||
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||
used_prescription integer NOT NULL DEFAULT 0,
|
||||
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -247,11 +266,80 @@ describe("E2E Tests with Real Routes", () => {
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Real /medications/report-data route", () => {
|
||||
it("should return 400 for invalid payload", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 403 when requested medication is not owned by user", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [999999] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error).toBe("Access denied to medication");
|
||||
});
|
||||
|
||||
it("should aggregate taken/dismissed doses and refill history", async () => {
|
||||
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||
|
||||
// One taken dose and one dismissed dose for the same medication
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||
VALUES (?, ?, ?, 0)`,
|
||||
args: [userId, `${medId}-0-1735344000000`, 1735344000],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||
VALUES (?, ?, ?, 1)`,
|
||||
args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400],
|
||||
});
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [medId, userId, 2, 5, 1, 1735516800],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [medId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[medId].dosesTaken).toBe(1);
|
||||
expect(data[medId].dosesDismissed).toBe(1);
|
||||
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||
expect(data[medId].refills).toHaveLength(1);
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 2,
|
||||
loosePillsAdded: 5,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
@@ -730,6 +818,39 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const data = getResponse.json();
|
||||
expect(data.repeatDailyReminders).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid language in lightweight language endpoint", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "fr" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid language");
|
||||
});
|
||||
|
||||
it("should create and update language via lightweight language endpoint", async () => {
|
||||
let response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "de" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "en" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await app.inject({ method: "GET", url: "/settings" });
|
||||
expect(getResponse.json().language).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1621,6 +1742,83 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
|
||||
});
|
||||
|
||||
it("should decrement remaining refills and mark history when using prescription refill", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Prescription Refill Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
prescriptionEnabled: true,
|
||||
prescriptionAuthorizedRefills: 3,
|
||||
prescriptionRemainingRefills: 2,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
expect(refillData.prescription.used).toBe(true);
|
||||
expect(refillData.prescription.remainingRefills).toBe(1);
|
||||
|
||||
const medsResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
expect(medsResponse.statusCode).toBe(200);
|
||||
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.prescriptionRemainingRefills).toBe(1);
|
||||
|
||||
const historyResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
expect(historyResponse.statusCode).toBe(200);
|
||||
expect(historyResponse.json()[0].usedPrescription).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject prescription refill when no remaining prescription refills are available", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Prescription Empty Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
prescriptionEnabled: true,
|
||||
prescriptionAuthorizedRefills: 2,
|
||||
prescriptionRemainingRefills: 0,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0, usePrescription: true },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(409);
|
||||
expect(refillResponse.json().error).toContain("No remaining prescription refills");
|
||||
});
|
||||
|
||||
it("should return 400 when no packs or pills added", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1718,8 +1916,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refills = response.json();
|
||||
expect(refills).toHaveLength(2);
|
||||
// Check both refills exist (order may vary)
|
||||
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
|
||||
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||
const hasLooseRefill = refills.some(
|
||||
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
|
||||
);
|
||||
expect(hasPackRefill).toBe(true);
|
||||
expect(hasLooseRefill).toBe(true);
|
||||
});
|
||||
@@ -1797,7 +1997,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const meds = getResponse.json();
|
||||
const med = meds.find((m: any) => m.id === medId);
|
||||
const med = meds.find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med).toBeDefined();
|
||||
expect(med.stockAdjustment).toBe(-7);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
@@ -1843,7 +2043,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = getResponse.json().find((m: any) => m.id === medId);
|
||||
const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
@@ -1912,7 +2112,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// Verify adjustment is set
|
||||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
let med = getMeds.json().find((m: any) => m.id === medId);
|
||||
let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(-10);
|
||||
|
||||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||||
@@ -1931,7 +2131,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// stockAdjustment should be reset to 0
|
||||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
med = getMeds.json().find((m: any) => m.id === medId);
|
||||
med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(0);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
});
|
||||
@@ -1975,7 +2175,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// stockAdjustment should be preserved
|
||||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
const med = getMeds.json().find((m: any) => m.id === medId);
|
||||
const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Preserve Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
@@ -2023,7 +2223,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
const med = data.find((m: any) => m.medicationId === medId);
|
||||
const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
|
||||
expect(med).toBeDefined();
|
||||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||||
@@ -2110,6 +2310,87 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.settings).toBeDefined();
|
||||
expect(data.settings.emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should include sensitive settings when requested", async () => {
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "https://example.com/topic",
|
||||
emailStockReminders: false,
|
||||
emailIntakeReminders: false,
|
||||
emailPrescriptionReminders: false,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export?includeSensitive=true",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.settings.shoutrrrEnabled).toBe(true);
|
||||
expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic");
|
||||
});
|
||||
|
||||
it("should gracefully export malformed date-like DB values", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Date Edge Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id as number;
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`,
|
||||
args: [userId, `${medId}-0-1735344000000`, "not-a-date"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [medId, userId, 1, 0, 0, "still-not-a-date"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, "date-edge-token", "Daniel", 30, "broken-date"],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/export" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const data = response.json();
|
||||
expect(data.doseHistory).toHaveLength(1);
|
||||
expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false);
|
||||
expect(data.refillHistory).toHaveLength(1);
|
||||
expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false);
|
||||
expect(data.shareLinks).toHaveLength(1);
|
||||
expect(data.shareLinks[0].expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Real /import routes", () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("plugins/env runtime validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
DOTENV_PATH: "/tmp/medassist-nonexistent.env",
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it("loads with defaults when auth and oidc are disabled", async () => {
|
||||
delete process.env.AUTH_ENABLED;
|
||||
delete process.env.OIDC_ENABLED;
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.REFRESH_SECRET;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
|
||||
const mod = await import("../plugins/env.js");
|
||||
expect(mod.env.AUTH_ENABLED).toBe(false);
|
||||
expect(mod.env.OIDC_ENABLED).toBe(false);
|
||||
expect(mod.env.PORT).toBe(3000);
|
||||
});
|
||||
|
||||
it("exits when auth is enabled but secrets are missing", async () => {
|
||||
process.env.AUTH_ENABLED = "true";
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.REFRESH_SECRET;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
|
||||
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||
});
|
||||
|
||||
it("exits when oidc is enabled but required settings are missing", async () => {
|
||||
process.env.AUTH_ENABLED = "false";
|
||||
process.env.OIDC_ENABLED = "true";
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.OIDC_REDIRECT_URI;
|
||||
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as never);
|
||||
|
||||
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||
});
|
||||
|
||||
it("loads when auth and oidc settings are complete", async () => {
|
||||
process.env.AUTH_ENABLED = "true";
|
||||
process.env.JWT_SECRET = "jwt-secret-for-runtime-test";
|
||||
process.env.REFRESH_SECRET = "refresh-secret-runtime-test";
|
||||
process.env.COOKIE_SECRET = "cookie-secret-runtime-test";
|
||||
process.env.OIDC_ENABLED = "true";
|
||||
process.env.OIDC_ISSUER_URL = "https://auth.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "medassist";
|
||||
process.env.OIDC_CLIENT_SECRET = "super-secret-client";
|
||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback";
|
||||
|
||||
const mod = await import("../plugins/env.js");
|
||||
expect(mod.env.AUTH_ENABLED).toBe(true);
|
||||
expect(mod.env.OIDC_ENABLED).toBe(true);
|
||||
expect(mod.env.OIDC_CLIENT_ID).toBe("medassist");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
// Mock process.exit to prevent tests from exiting
|
||||
const mockExit = vi.fn();
|
||||
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
||||
vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
|
||||
|
||||
// Re-create the schema from env.ts for testing
|
||||
const EnvSchema = z.object({
|
||||
|
||||
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
const userId = 1; // Test user ID
|
||||
|
||||
// Helper to parse blisters from DB
|
||||
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
const usage = JSON.parse(row.usage_json || "[]") as number[];
|
||||
const every = JSON.parse(row.every_json || "[]") as number[];
|
||||
const start = JSON.parse(row.start_json || "[]") as string[];
|
||||
function parseBlisters(
|
||||
row: Record<string, unknown>
|
||||
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
|
||||
const every = JSON.parse((row.every_json as string) || "[]") as number[];
|
||||
const start = JSON.parse((row.start_json as string) || "[]") as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
usage: usage[i],
|
||||
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
let settings;
|
||||
let settings: Record<string, unknown> | undefined;
|
||||
if (settingsResult.rows.length > 0) {
|
||||
const s = settingsResult.rows[0];
|
||||
settings = {
|
||||
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
});
|
||||
|
||||
// POST /import
|
||||
app.post<{ Body: any }>("/import", async (request, reply) => {
|
||||
app.post("/import", async (request, reply) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||
const importData = request.body as any;
|
||||
|
||||
// Basic validation
|
||||
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
// Import medications
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
for (const med of importData.medications || []) {
|
||||
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
|
||||
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
|
||||
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
|
||||
const usageJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
|
||||
);
|
||||
const everyJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
|
||||
);
|
||||
const startJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
|
||||
);
|
||||
const takenByJson = JSON.stringify(med.takenBy || []);
|
||||
|
||||
const result = await client.execute({
|
||||
|
||||
@@ -94,6 +94,14 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
|
||||
prescription_expiry_date text,
|
||||
dismissed_until text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -105,10 +113,12 @@ async function createSchema(client: Client) {
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
email_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
@@ -122,6 +132,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
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,
|
||||
@@ -130,6 +143,9 @@ async function createSchema(client: Client) {
|
||||
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
|
||||
)`,
|
||||
@@ -149,6 +165,7 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
@@ -1320,8 +1337,8 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
});
|
||||
const meds = medsRes.json();
|
||||
const med1 = meds.find((m: any) => m.id === med1Id);
|
||||
const med2 = meds.find((m: any) => m.id === med2Id);
|
||||
const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
|
||||
const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
|
||||
|
||||
expect(med1.dismissedUntil).toBe("2025-01-15");
|
||||
expect(med2.dismissedUntil).toBe("2025-01-15");
|
||||
@@ -1363,7 +1380,7 @@ describe("Integration Tests", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
||||
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.dismissedUntil).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1433,7 +1450,7 @@ describe("Integration Tests", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
||||
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.dismissedUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type OidcMocks = {
|
||||
discovery: ReturnType<typeof vi.fn>;
|
||||
buildAuthorizationUrl: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
|
||||
const env = {
|
||||
OIDC_ENABLED: true,
|
||||
OIDC_ISSUER_URL: "https://issuer.example.com",
|
||||
OIDC_CLIENT_ID: "medassist-client",
|
||||
OIDC_CLIENT_SECRET: "medassist-client-secret",
|
||||
OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback",
|
||||
OIDC_SCOPES: "openid profile email",
|
||||
OIDC_AUTO_CREATE_USERS: true,
|
||||
OIDC_USERNAME_CLAIM: "preferred_username",
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
CORS_ORIGINS: "http://localhost:5173",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
...envOverrides,
|
||||
};
|
||||
|
||||
vi.doMock("../plugins/env.js", () => ({ env }));
|
||||
|
||||
vi.doMock("../db/client.js", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })),
|
||||
})),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })),
|
||||
},
|
||||
}));
|
||||
|
||||
const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" });
|
||||
const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => {
|
||||
const state = typeof params?.state === "string" ? params.state : "state";
|
||||
return new URL(`https://issuer.example.com/authorize?state=${state}`);
|
||||
});
|
||||
|
||||
vi.doMock("openid-client", () => ({
|
||||
discovery,
|
||||
buildAuthorizationUrl,
|
||||
authorizationCodeGrant: vi.fn(),
|
||||
fetchUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret-12345",
|
||||
refreshSecret: "test-refresh-secret-12345",
|
||||
accessTtl: 15 * 60,
|
||||
refreshTtl: 7 * 24 * 60 * 60,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" },
|
||||
});
|
||||
await app.register(oidcRoutes);
|
||||
await app.ready();
|
||||
|
||||
return {
|
||||
app,
|
||||
mocks: { discovery, buildAuthorizationUrl } as OidcMocks,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("OIDC routes", () => {
|
||||
it("returns 400 on login and callback when oidc is disabled", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: false });
|
||||
try {
|
||||
const login = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
expect(login.statusCode).toBe(400);
|
||||
expect(callback.statusCode).toBe(400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => {
|
||||
const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toContain("https://issuer.example.com/authorize");
|
||||
expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true);
|
||||
expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true);
|
||||
expect(mocks.discovery).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects with provider error when callback contains error params", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects when callback is missing required params", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects when callback state validation fails", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/oidc/callback?code=abc123&state=state123",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
|
||||
|
||||
// Mock sendShoutrrrNotification from settings
|
||||
vi.mock("../routes/settings.js", async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
const original = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
sendShoutrrrNotification: mockSendShoutrrr,
|
||||
@@ -86,6 +86,42 @@ async function createSchema(client: Client) {
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
package_type text NOT NULL DEFAULT 'blister',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
total_pills integer,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||
last_stock_correction_at integer,
|
||||
pill_weight_mg integer,
|
||||
dose_unit text DEFAULT 'mg',
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
intakes_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
|
||||
prescription_expiry_date text,
|
||||
dismissed_until text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -94,10 +130,12 @@ async function createSchema(client: Client) {
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
email_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
email_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_url text,
|
||||
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||
@@ -111,6 +149,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
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,
|
||||
@@ -119,6 +160,9 @@ async function createSchema(client: Client) {
|
||||
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
|
||||
)`,
|
||||
@@ -130,6 +174,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
@@ -150,6 +195,18 @@ describe("Planner Routes", () => {
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
// Insert test medications so active-medication filters pass
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
@@ -980,4 +1037,106 @@ describe("Planner Routes", () => {
|
||||
expect(message).toContain("Running critically low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /reminder/send-prescription", () => {
|
||||
it("should reject request with missing prescription data", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-prescription",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
prescriptionLow: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing prescription reminder data" });
|
||||
});
|
||||
|
||||
it("should return error when no notification channels configured", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-prescription",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
prescriptionLow: [{ name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
||||
});
|
||||
|
||||
it("should send prescription email reminder when email is enabled", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-prescription",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
prescriptionLow: [
|
||||
{ name: "Aspirin", remainingRefills: 0, threshold: 1, expiryDate: "2026-01-01" },
|
||||
{ name: "Ibuprofen", remainingRefills: 1, threshold: 2, expiryDate: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via email" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "email");
|
||||
expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith(
|
||||
999999999,
|
||||
"prescription",
|
||||
"email",
|
||||
"Aspirin, Ibuprofen"
|
||||
);
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should send prescription push reminder when shoutrrr is enabled", async () => {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/reminder/send-prescription",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
prescriptionLow: [{ name: "Aspirin", remainingRefills: 1, threshold: 2, expiryDate: "2026-01-01" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Prescription reminder sent via push" });
|
||||
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
|
||||
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
|
||||
expect(title).toContain("Renew Now");
|
||||
expect(message).toContain("Aspirin");
|
||||
expect(mockUpdateReminderSentTime).toHaveBeenCalledWith("prescription", "push");
|
||||
expect(mockUpdateUserReminderSentTime).toHaveBeenCalledWith(999999999, "prescription", "push", "Aspirin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
const env = {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: env,
|
||||
nodemailerSendMail: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: async () => 1,
|
||||
}));
|
||||
|
||||
vi.mock("nodemailer", () => ({
|
||||
default: {
|
||||
createTransport: () => ({
|
||||
sendMail: nodemailerSendMail,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
const { reportRoutes } = await import("../routes/report.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function seedAnonymousUser() {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [1, "anon", "anonymous"],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMedication(name = "Aspirin") {
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
stock_adjustment, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
"Acetylsalicylic acid",
|
||||
JSON.stringify(["Daniel"]),
|
||||
"blister",
|
||||
2,
|
||||
2,
|
||||
10,
|
||||
3,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify(["2026-01-01T08:00:00.000Z"]),
|
||||
JSON.stringify([
|
||||
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", takenBy: "Daniel", intakeRemindersEnabled: true },
|
||||
]),
|
||||
0,
|
||||
1,
|
||||
],
|
||||
});
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
|
||||
describe("Real route coverage: settings/export/report", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(reportRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await clearTables();
|
||||
await seedAnonymousUser();
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_TOKEN;
|
||||
delete process.env.SMTP_PASS;
|
||||
delete process.env.SMTP_FROM;
|
||||
delete process.env.SMTP_PORT;
|
||||
delete process.env.SMTP_SECURE;
|
||||
});
|
||||
|
||||
it("GET /settings creates defaults for anonymous user", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.language).toBe("en");
|
||||
expect(body.shareStockStatus).toBe(true);
|
||||
expect(body.upcomingTodayOnly).toBe(false);
|
||||
expect(body.shareScheduleTodayOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: true,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const stored = await testClient.execute({
|
||||
sql: "SELECT repeat_daily_reminders FROM user_settings WHERE user_id = 1",
|
||||
});
|
||||
expect(stored.rows[0].repeat_daily_reminders).toBe(0);
|
||||
});
|
||||
|
||||
it("PUT /settings/language validates supported language", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "fr" },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid language");
|
||||
});
|
||||
|
||||
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-email",
|
||||
payload: { email: "person@example.com" },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("SMTP not configured");
|
||||
});
|
||||
|
||||
it("POST /settings/test-email sends email when SMTP is configured", async () => {
|
||||
process.env.SMTP_HOST = "smtp.example.com";
|
||||
process.env.SMTP_USER = "mailer@example.com";
|
||||
process.env.SMTP_TOKEN = "secret";
|
||||
nodemailerSendMail.mockResolvedValue(undefined);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-email",
|
||||
payload: { email: "person@example.com" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/settings/test-shoutrrr",
|
||||
payload: { url: "" },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not allowed");
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/mytopic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sendShoutrrrNotification uses JSON payload for webhook URLs", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
const result = await sendShoutrrrNotification("https://hooks.slack.com/services/a/b/c", "Title", "Body");
|
||||
expect(result.success).toBe(true);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(call[1].headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
||||
});
|
||||
|
||||
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
||||
await seedMedication("Owned Med");
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [9999] },
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /medications/report-data aggregates doses and refills", async () => {
|
||||
const medId = await seedMedication("Report Med");
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, 0],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||
args: [1, `${medId}-0-1700000600000-Daniel`, 1700000600, 1],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
args: [medId, 1, 1, 2, 1, 1700001200],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [medId] },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body[medId].dosesTaken).toBe(1);
|
||||
expect(body[medId].dosesDismissed).toBe(1);
|
||||
expect(body[medId].refills).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||
const medId = await seedMedication("Export Med");
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)",
|
||||
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, "Daniel"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
args: [medId, 1, 1, 3, 0, 1700000000],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO user_settings (user_id, email_enabled, notification_email, share_stock_status, language) VALUES (?, ?, ?, ?, ?)",
|
||||
args: [1, 1, "x@example.com", 1, "de"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)",
|
||||
args: [1, "abc123", "Daniel", 30],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export?includeSensitive=true&includeImages=false",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.medications).toHaveLength(1);
|
||||
expect(body.doseHistory).toHaveLength(1);
|
||||
expect(body.refillHistory).toHaveLength(1);
|
||||
expect(body.settings.language).toBe("de");
|
||||
expect(body.shareLinks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("POST /import validates payload and imports minimal valid structure", async () => {
|
||||
const invalid = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: { foo: "bar" },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
|
||||
const validImport = {
|
||||
version: "1.1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: false,
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Imported Med",
|
||||
genericName: null,
|
||||
takenBy: ["Daniel"],
|
||||
inventory: {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: null,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
packageType: "blister",
|
||||
},
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
schedules: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", remind: false, takenBy: "Daniel" }],
|
||||
medicationStartDate: "",
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
intakeRemindersEnabled: false,
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: null,
|
||||
prescriptionRemainingRefills: null,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
prescriptionExpiryDate: null,
|
||||
dismissedUntil: null,
|
||||
image: null,
|
||||
lastStockCorrectionAt: null,
|
||||
},
|
||||
],
|
||||
doseHistory: [],
|
||||
refillHistory: [],
|
||||
settings: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
emailPrescriptionReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
expiryWarningDays: 30,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
},
|
||||
shareLinks: [],
|
||||
};
|
||||
|
||||
const valid = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import",
|
||||
payload: validImport,
|
||||
});
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(valid.json().imported.medications).toBe(1);
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||
});
|
||||
expect(rows.rows[0].name).toBe("Imported Med");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Import from utils to avoid index.ts import side effects (server start)
|
||||
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
expect((app as any).config.accessTtl).toBe(15);
|
||||
expect((app as any).config.refreshTtl).toBe(7);
|
||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
const appWithConfig = app as unknown as {
|
||||
config: {
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: { httpOnly: boolean };
|
||||
refreshCookieOptions: { maxAge: number };
|
||||
};
|
||||
};
|
||||
expect(appWithConfig.config.accessTtl).toBe(15);
|
||||
expect(appWithConfig.config.refreshTtl).toBe(7);
|
||||
expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true);
|
||||
expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: any) => {
|
||||
const healthRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
};
|
||||
|
||||
const authRoutes = async (app: any) => {
|
||||
const authRoutes = async (app: FastifyInstance) => {
|
||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||
};
|
||||
|
||||
const medicationRoutes = async (app: any) => {
|
||||
const medicationRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/medications", async () => []);
|
||||
};
|
||||
|
||||
|
||||
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
||||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
||||
const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
|
||||
const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
|
||||
|
||||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: async () => 1,
|
||||
}));
|
||||
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function seedAnonymousUser() {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [1, "anon", "anonymous"],
|
||||
});
|
||||
}
|
||||
|
||||
async function setStockMode(mode: "automatic" | "manual") {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||
VALUES (?, ?, 7, 365, 'en')`,
|
||||
args: [1, mode],
|
||||
});
|
||||
}
|
||||
|
||||
async function createMedication(options: {
|
||||
name: string;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: number | null;
|
||||
isObsolete?: boolean;
|
||||
takenBy?: string[];
|
||||
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
stockAdjustment = 0,
|
||||
lastStockCorrectionAt = null,
|
||||
isObsolete = false,
|
||||
takenBy = [],
|
||||
intakes,
|
||||
} = options;
|
||||
|
||||
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||
const intakesJson = JSON.stringify(
|
||||
intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
takenBy: i.takenBy ?? null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, taken_by_json, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
intakesJson,
|
||||
isObsolete ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function markDoseTaken(options: {
|
||||
medicationId: number;
|
||||
blisterIdx: number;
|
||||
doseDateOnlyMs: number;
|
||||
takenAtMs: number;
|
||||
personSuffix?: string;
|
||||
}) {
|
||||
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||
});
|
||||
}
|
||||
|
||||
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate, endDate },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const rows = response.json();
|
||||
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||
expect(row).toBeDefined();
|
||||
return row;
|
||||
}
|
||||
|
||||
function toDateOnlyMs(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
}
|
||||
|
||||
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(medicationRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
await seedAnonymousUser();
|
||||
});
|
||||
|
||||
it("keeps automatic mode current stock in sync", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Auto Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(10);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Correction";
|
||||
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
lastStockCorrectionAt: correctionMs,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan5DateOnly,
|
||||
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||
});
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan6DateOnly,
|
||||
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("counts early taken dose in automatic mode without drift", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Early Taken";
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
tomorrow.setHours(20, 0, 0, 0);
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||
});
|
||||
|
||||
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: tomorrowDateOnly,
|
||||
takenAtMs: now.getTime(),
|
||||
});
|
||||
|
||||
const rangeStart = new Date(now);
|
||||
rangeStart.setDate(now.getDate() - 1);
|
||||
const rangeEnd = new Date(now);
|
||||
rangeEnd.setDate(now.getDate() + 7);
|
||||
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(9);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Mixed TakenBy";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||
],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
expect(usageRow.currentPills).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||
await setStockMode("automatic");
|
||||
await createMedication({
|
||||
name: "Obsolete Med",
|
||||
isObsolete: true,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe("Translations Module", () => {
|
||||
|
||||
// Stock reminder subject
|
||||
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low");
|
||||
expect(subject).toBe("MedAssist-ng: ⚠️ 3 Medications Running Critically Low");
|
||||
|
||||
// Intake reminder description
|
||||
const description = t(translations.intakeReminder.description, { minutes: 30 });
|
||||
@@ -113,7 +113,7 @@ describe("Translations Module", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig");
|
||||
expect(subject).toBe("MedAssist-ng: ⚠️ 2 Medikamente kritisch niedrig");
|
||||
|
||||
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
|
||||
expect(takenBy).toBe("für Daniel");
|
||||
|
||||
Vendored
+1
@@ -22,6 +22,7 @@ declare module "fastify" {
|
||||
|
||||
interface FastifyRequest {
|
||||
user?: AuthUser | null;
|
||||
correlationId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function getThumbFilename(imageFilename: string): string {
|
||||
const ext = extname(imageFilename);
|
||||
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
|
||||
return `${base}-thumb.webp`;
|
||||
}
|
||||
|
||||
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
|
||||
const fullPath = resolve(imagesDir, imageFilename);
|
||||
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||
|
||||
const thumbFilename = getThumbFilename(imageFilename);
|
||||
if (thumbFilename !== imageFilename) {
|
||||
const thumbPath = resolve(imagesDir, thumbFilename);
|
||||
if (existsSync(thumbPath)) unlinkSync(thumbPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalSize += buffer.length;
|
||||
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
throw new Error("IMAGE_TOO_LARGE");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function writeOptimizedImageSet(
|
||||
imagesDir: string,
|
||||
filePrefix: string,
|
||||
uploadBuffer: Buffer,
|
||||
options?: {
|
||||
maxEdgePx?: number;
|
||||
thumbSizePx?: number;
|
||||
fullQuality?: number;
|
||||
thumbQuality?: number;
|
||||
}
|
||||
): Promise<{ filename: string; thumbFilename: string }> {
|
||||
const maxEdgePx = options?.maxEdgePx ?? 1600;
|
||||
const thumbSizePx = options?.thumbSizePx ?? 96;
|
||||
const fullQuality = options?.fullQuality ?? 82;
|
||||
const thumbQuality = options?.thumbQuality ?? 76;
|
||||
|
||||
const filename = `${filePrefix}-${Date.now()}.webp`;
|
||||
const thumbFilename = getThumbFilename(filename);
|
||||
|
||||
const filepath = resolve(imagesDir, filename);
|
||||
const thumbFilepath = resolve(imagesDir, thumbFilename);
|
||||
|
||||
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: fullQuality })
|
||||
.toBuffer();
|
||||
|
||||
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
|
||||
.webp({ quality: thumbQuality })
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(filepath, optimizedBuffer);
|
||||
await writeFile(thumbFilepath, thumbBuffer);
|
||||
|
||||
return { filename, thumbFilename };
|
||||
}
|
||||
@@ -122,7 +122,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
||||
/** Calculate milliseconds until next check at the given reminder hour */
|
||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
const msUntilNext = next.getTime() - Date.now();
|
||||
if (msUntilNext <= 0) {
|
||||
return msUntilNext + 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return msUntilNext;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -191,7 +195,7 @@ export function parseIntakesJson(
|
||||
try {
|
||||
const parsed = JSON.parse(intakesJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((intake: any) => ({
|
||||
return parsed.map((intake: Record<string, unknown>) => ({
|
||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||
every: typeof intake.every === "number" ? intake.every : 1,
|
||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||
@@ -312,7 +316,7 @@ export type UpcomingIntake = {
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
@@ -388,7 +392,7 @@ export function getUpcomingIntakes(
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
minutesBefore: number,
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
@@ -483,9 +487,10 @@ export function getUpcomingIntakes(
|
||||
export type ReminderState = {
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
lastStockSchedulerCheckDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
};
|
||||
|
||||
@@ -505,6 +510,7 @@ export function createDefaultReminderState(): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
lastStockSchedulerCheckDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
@@ -524,6 +530,7 @@ export function parseReminderState(json: string): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
|
||||
@@ -14,5 +14,25 @@ export default defineConfig({
|
||||
},
|
||||
// Timeout for longer integration tests
|
||||
testTimeout: 10000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/test/**",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/index.ts",
|
||||
"src/services/**",
|
||||
"src/utils/logger.ts",
|
||||
],
|
||||
thresholds: {
|
||||
global: {
|
||||
lines: 60,
|
||||
functions: 65,
|
||||
branches: 50,
|
||||
statements: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+12
-3
@@ -2,14 +2,22 @@
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css", "frontend/e2e/**/*.ts", "frontend/playwright.config.ts"]
|
||||
"includes": [
|
||||
"backend/src/**/*.ts",
|
||||
"frontend/src/**/*.ts",
|
||||
"frontend/src/**/*.tsx",
|
||||
"frontend/src/**/*.css",
|
||||
"frontend/e2e/**/*.ts",
|
||||
"frontend/playwright.config.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
"noForEach": "off",
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
@@ -21,7 +29,8 @@
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useConst": "error",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"noNestedTernary": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_URL=http://backend-dev:3000
|
||||
ports:
|
||||
|
||||
@@ -35,6 +35,8 @@ services:
|
||||
frontend:
|
||||
image: ghcr.io/danielvolz/medassist-ng-frontend:latest
|
||||
container_name: medassist-ng-frontend
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_URL=backend:3000
|
||||
ports:
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# GitHub Project Setup
|
||||
|
||||
This repository includes a GitHub Actions workflow that automatically adds new issues to a GitHub Project for tracking feature requests and bugs.
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Create a GitHub Project
|
||||
|
||||
1. Go to your GitHub profile → **Projects** → **New project**
|
||||
2. Choose the **Board** template (recommended for feature tracking)
|
||||
3. Name it e.g. **MedAssist-ng Roadmap**
|
||||
4. Configure the default columns:
|
||||
- **Triage** – New issues land here
|
||||
- **Backlog** – Accepted but not yet started
|
||||
- **In Progress** – Currently being worked on
|
||||
- **Done** – Completed
|
||||
|
||||
### 2. Create a Personal Access Token (PAT)
|
||||
|
||||
The workflow needs a token with project permissions. The built-in `GITHUB_TOKEN` does not support GitHub Projects.
|
||||
|
||||
1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Fine-grained tokens**
|
||||
2. Click **Generate new token**
|
||||
3. Set:
|
||||
- **Token name**: `add-to-project`
|
||||
- **Expiration**: Choose an appropriate duration
|
||||
- **Repository access**: Select **Only select repositories** → `DanielVolz/medassist-ng`
|
||||
- **Permissions**:
|
||||
- Repository permissions: **Issues** → Read
|
||||
- Organization permissions (if applicable): **Projects** → Read and write
|
||||
- For **user-owned projects**, you need a **classic** token with the `project` scope instead
|
||||
4. Copy the generated token
|
||||
|
||||
### 3. Add Repository Secrets and Variables
|
||||
|
||||
1. Go to the repository → **Settings** → **Secrets and variables** → **Actions**
|
||||
2. Add a **secret**:
|
||||
- Name: `ADD_TO_PROJECT_PAT`
|
||||
- Value: The PAT from step 2
|
||||
3. Add a **variable** (under the **Variables** tab):
|
||||
- Name: `PROJECT_URL`
|
||||
- Value: The full URL of your GitHub Project (e.g. `https://github.com/users/DanielVolz/projects/1`)
|
||||
|
||||
### 4. Verify
|
||||
|
||||
1. Create a test issue using the **✨ Feature Request** template
|
||||
2. Check the **Actions** tab to see the workflow run
|
||||
3. Verify the issue appears in your GitHub Project under **Triage**
|
||||
|
||||
## How It Works
|
||||
|
||||
The workflow (`.github/workflows/add-to-project.yml`) triggers when:
|
||||
- A new issue is **opened**
|
||||
- A label is **added** to an existing issue
|
||||
|
||||
Issues with any of these labels are automatically added to the project:
|
||||
- `enhancement` – Feature requests
|
||||
- `bug` – Bug reports
|
||||
- `triage` – New issues needing review
|
||||
|
||||
Both the feature request and bug report issue templates automatically apply the `triage` label, so all new issues from templates are captured.
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding more labels
|
||||
|
||||
Edit `.github/workflows/add-to-project.yml` and add labels to the `labeled` field:
|
||||
|
||||
```yaml
|
||||
labeled: enhancement, bug, triage, documentation
|
||||
```
|
||||
|
||||
### Restricting to feature requests only
|
||||
|
||||
Change the `labeled` field to only include `enhancement`:
|
||||
|
||||
```yaml
|
||||
labeled: enhancement
|
||||
label-operator: OR
|
||||
```
|
||||
+5
-1
@@ -41,6 +41,9 @@ RUN sed -i 's|include /etc/nginx/conf.d/\*.conf;|include /tmp/default.conf;|' /e
|
||||
# nginx-unprivileged automatically substitutes env vars in .template files
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Copy entrypoint wrapper (translates LOG_LEVEL → nginx access log control)
|
||||
COPY --chmod=755 nginx-entrypoint.sh /nginx-entrypoint.sh
|
||||
|
||||
# Copy built static files with correct ownership (nginx user = uid 101)
|
||||
COPY --from=builder --chown=101:101 /app/dist /usr/share/nginx/html
|
||||
|
||||
@@ -50,5 +53,6 @@ EXPOSE 8080
|
||||
# Already runs as non-root (nginx user, uid 101)
|
||||
USER nginx
|
||||
|
||||
# Start nginx (entrypoint processes templates automatically)
|
||||
# Use wrapper entrypoint that maps LOG_LEVEL to nginx config
|
||||
ENTRYPOINT ["/nginx-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { expect, test as setup } from "@playwright/test";
|
||||
import { TEST_USER } from "./fixtures";
|
||||
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||
|
||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||
|
||||
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
|
||||
* 4. Log in via the UI.
|
||||
*/
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await applyVideoSafetyMode(page);
|
||||
|
||||
// Create .auth directory if it doesn't exist
|
||||
const authDir = path.dirname(authFile);
|
||||
if (!fs.existsSync(authDir)) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
|
||||
await expect(ibuprofenRow).toBeVisible();
|
||||
const rowText = await ibuprofenRow.textContent();
|
||||
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
||||
expect(rowText).toContain("59");
|
||||
expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should show today block in timeline", async ({ page }) => {
|
||||
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
|
||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||
|
||||
await takeBtn.click();
|
||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
||||
@@ -154,20 +153,23 @@ test.describe("Dashboard with medications", () => {
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Normalize state first: if a dose is already taken, undo it so we can
|
||||
// always execute the same take -> undo flow deterministically.
|
||||
const existingUndo = todayBlock.locator("button.dose-btn.undo").first();
|
||||
if (await existingUndo.isVisible().catch(() => false)) {
|
||||
await existingUndo.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
// Mark a dose as taken first
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
await expect(takeBtn).toBeVisible({ timeout: 10000 });
|
||||
await takeBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for undo button to appear (confirms the take succeeded)
|
||||
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
||||
try {
|
||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
// Take might have been rate-limited — skip this test gracefully
|
||||
return;
|
||||
}
|
||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||
await undoBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce visual flashing in recorded videos by forcing a dark first paint and
|
||||
* disabling most animations/transitions in test mode.
|
||||
*/
|
||||
export async function applyVideoSafetyMode(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
|
||||
await page.addInitScript(() => {
|
||||
const style = document.createElement("style");
|
||||
style.id = "pw-video-safety-style";
|
||||
style.textContent = `
|
||||
html, body {
|
||||
background: #111111 !important;
|
||||
color-scheme: dark !important;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`;
|
||||
document.documentElement.appendChild(style);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture that automatically mocks /auth/me on every page
|
||||
* using user data from the JWT in the stored auth file.
|
||||
@@ -70,8 +93,9 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||
* since it tests the unauthenticated flow.
|
||||
*/
|
||||
export const test = base.extend<{}>({
|
||||
export const test = base.extend<object>({
|
||||
page: async ({ page }, use) => {
|
||||
await applyVideoSafetyMode(page);
|
||||
await setupAuthMeMock(page);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
@@ -38,58 +38,58 @@ async function fillAndSaveMedication(
|
||||
intakes?: { usage: string; every: string }[];
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
||||
const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
|
||||
if (await openCreateBtn.isVisible().catch(() => false)) {
|
||||
await openCreateBtn.click();
|
||||
}
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
|
||||
if (opts.genericName) {
|
||||
await page.getByLabel(/Generic Name/i).fill(opts.genericName);
|
||||
await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
|
||||
}
|
||||
|
||||
const packageTypeSelect = form.locator("select.package-type-select");
|
||||
if (opts.packageType === "bottle") {
|
||||
await page.locator("select.package-type-select").selectOption("bottle");
|
||||
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
|
||||
await packageTypeSelect.selectOption("bottle");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.totalCapacity)
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||
} else {
|
||||
await page.locator("select.package-type-select").selectOption("blister");
|
||||
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
|
||||
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
|
||||
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
|
||||
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
|
||||
}
|
||||
|
||||
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
|
||||
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
|
||||
|
||||
// Fill intake schedules
|
||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||
for (let i = 0; i < intakes.length; i++) {
|
||||
if (i > 0) {
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
}
|
||||
const row = page.locator(".blister-row").nth(i);
|
||||
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
|
||||
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every);
|
||||
}
|
||||
|
||||
// Click Save — handle potential rate-limiting by retrying
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("form.form-grid button[type='submit']").click();
|
||||
|
||||
// Wait for the form to reset: commercial name becomes empty after successful save
|
||||
try {
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
|
||||
break; // Save succeeded
|
||||
} catch {
|
||||
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
|
||||
// Save might have been rate-limited — wait and retry
|
||||
await page.waitForTimeout(3000);
|
||||
// Re-fill the name in case form was partially reset
|
||||
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
|
||||
if (!currentValue) {
|
||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
||||
await packageTypeSelect.selectOption("blister");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
|
||||
if (opts.blistersPerPack)
|
||||
await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
|
||||
if (opts.pillsPerBlister)
|
||||
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
|
||||
if (opts.loosePills) {
|
||||
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
|
||||
if (await looseField.isVisible().catch(() => false)) {
|
||||
await looseField.fill(opts.loosePills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
|
||||
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
|
||||
|
||||
// Fill intake schedules
|
||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
for (let i = 0; i < intakes.length; i++) {
|
||||
if (i > 0) {
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
}
|
||||
const row = form.locator(".blister-row").nth(i);
|
||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||
}
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await form.locator("button[type='submit']").click();
|
||||
|
||||
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
||||
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
||||
try {
|
||||
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
|
||||
* Helper: save after editing (PUT) and wait for success.
|
||||
*/
|
||||
async function saveEdit(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("form.form-grid button[type='submit']").click();
|
||||
const submitBtn = form.locator("button[type='submit']");
|
||||
if (
|
||||
(await submitBtn.count()) > 0 &&
|
||||
(await submitBtn
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
await submitBtn.first().click();
|
||||
} else {
|
||||
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||
if (await closeBtn.isVisible().catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
}
|
||||
}
|
||||
// Wait for the list to update with the new name — retry with reload if rate-limited
|
||||
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
||||
try {
|
||||
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
|
||||
|
||||
test("should not save with empty commercial name", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Leave name empty — save button should be disabled
|
||||
// Saving without name should not create a medication row.
|
||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await saveBtn.click();
|
||||
await expect(page.locator(".med-row")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("should reset form after saving a medication", async ({ page }) => {
|
||||
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
|
||||
pillsPerBlister: "10",
|
||||
});
|
||||
|
||||
// Form should reset — title should say "New medication"
|
||||
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
|
||||
// Commercial name should be empty
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
|
||||
// Opening a fresh form after save should start with an empty commercial name.
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => {
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Form title should say "Edit medication"
|
||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
|
||||
// Form title should say "Edit entry" (or legacy "Edit medication").
|
||||
await expect(
|
||||
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
|
||||
).toBeVisible();
|
||||
|
||||
// The name field should have the current value
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit");
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("After Edit");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
|
||||
|
||||
// Save the edit
|
||||
await saveEdit(page, "After Edit");
|
||||
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("Modified Name");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name");
|
||||
|
||||
// Click Cancel
|
||||
await page.locator("form.form-grid button.ghost").click();
|
||||
await page
|
||||
.getByRole("button", { name: /Close|Cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Original name should still be in the list
|
||||
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show refill section in edit mode", async ({ page }) => {
|
||||
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
// Click Edit
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Refill section should be visible
|
||||
const refillSection = page.locator(".refill-section");
|
||||
await expect(refillSection).toBeVisible();
|
||||
await expect(refillSection.locator("button.success")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Delete medication", () => {
|
||||
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Accept the native confirm() dialog
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await medRow.locator("button.danger").click();
|
||||
await page
|
||||
.locator(".confirm-modal-overlay, .modal-overlay")
|
||||
.getByRole("button", { name: /Delete/i })
|
||||
.click();
|
||||
|
||||
// Medication should be removed
|
||||
await expect(medRow).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(medRow).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
// Already deleted via UI — clear tracked list
|
||||
createdMeds.length = 0;
|
||||
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
|
||||
test.describe("Intake schedule management", () => {
|
||||
test("should add and remove intake schedule rows", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
|
||||
expect(await page.locator(".blister-row").count()).toBe(1);
|
||||
expect(await form.locator(".blister-row").count()).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(3);
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
expect(await form.locator(".blister-row").count()).toBe(3);
|
||||
|
||||
const removeBtn = page
|
||||
.locator(".blister-row")
|
||||
.locator("form.form-grid:visible .blister-row")
|
||||
.last()
|
||||
.getByRole("button", { name: /Remove/i });
|
||||
await removeBtn.click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
||||
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
||||
}
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: save edit and verify success */
|
||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
// Wait for any pending network before clicking save
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click save
|
||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||
await saveBtn.click();
|
||||
const submitBtn = form.locator("button[type='submit']");
|
||||
if (
|
||||
(await submitBtn.count()) > 0 &&
|
||||
(await submitBtn
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
await submitBtn.first().click();
|
||||
} else {
|
||||
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||
if (await closeBtn.isVisible().catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for save request + re-fetch to complete
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
|
||||
await clickEditMed(page, "Edit GenName Med");
|
||||
|
||||
// Generic name should be empty initially
|
||||
const genericField = page.getByLabel(/Generic Name/i);
|
||||
const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||
await expect(genericField).toHaveValue("");
|
||||
|
||||
// Add a generic name
|
||||
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Click edit again and verify the generic name was saved
|
||||
await clickEditMed(page, "Edit GenName Med");
|
||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid");
|
||||
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid");
|
||||
});
|
||||
|
||||
test("should add notes to an existing medication", async ({ page }) => {
|
||||
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Edit Notes Med");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Notes should be empty initially
|
||||
const notesField = page.getByLabel(/Notes/i);
|
||||
const notesField = page.getByLabel(/(Notes|form\.notes)/i);
|
||||
await expect(notesField).toHaveValue("");
|
||||
|
||||
// Add notes text
|
||||
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Verify notes were saved by clicking edit again
|
||||
await clickEditMed(page, "Edit Notes Med");
|
||||
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast");
|
||||
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast");
|
||||
});
|
||||
|
||||
test("should add taken-by person to a medication", async ({ page }) => {
|
||||
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Expiry Date Med");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Set expiry date to 6 months from now
|
||||
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
const expiryField = page.getByLabel(/Expiry Date/i);
|
||||
const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i);
|
||||
await expiryField.fill(expiryDate);
|
||||
await expect(expiryField).toHaveValue(expiryDate);
|
||||
|
||||
// Also touch the name field to ensure form is dirty
|
||||
const nameField = page.getByLabel(/Commercial Name/i);
|
||||
const currentName = await nameField.inputValue();
|
||||
await nameField.fill(currentName);
|
||||
// Expiry change itself is enough to persist in the current edit flow.
|
||||
|
||||
await saveEditAndVerify(page, "Expiry Date Med");
|
||||
|
||||
// Verify expiry date was saved
|
||||
await clickEditMed(page, "Expiry Date Med");
|
||||
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate);
|
||||
});
|
||||
|
||||
test("should use refill feature to add stock in edit mode", async ({ page }) => {
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: "Refill Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
})
|
||||
);
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Refill Test Med");
|
||||
|
||||
// Refill section should be visible in edit mode
|
||||
const refillSection = page.locator(".refill-section");
|
||||
await expect(refillSection).toBeVisible();
|
||||
|
||||
// Set refill values: 2 packs + 5 loose pills
|
||||
await refillSection.getByLabel(/Packs/i).fill("2");
|
||||
await refillSection.getByLabel(/Loose pills/i).fill("5");
|
||||
|
||||
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
|
||||
const preview = refillSection.locator(".refill-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
expect(await preview.textContent()).toContain("45");
|
||||
|
||||
// Click the refill button
|
||||
await refillSection.locator("button.success").click();
|
||||
|
||||
// Wait for the refill to be processed
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
|
||||
});
|
||||
|
||||
test("should edit intake schedule usage and interval", async ({ page }) => {
|
||||
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Edit Intake Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
|
||||
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
|
||||
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
||||
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||
|
||||
await usageField.fill("2");
|
||||
await everyField.fill("7");
|
||||
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
|
||||
// Verify the changes persisted
|
||||
await clickEditMed(page, "Edit Intake Med");
|
||||
const savedRow = page.locator(".blister-row").first();
|
||||
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
|
||||
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
|
||||
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||
});
|
||||
|
||||
test("should add a second intake schedule row", async ({ page }) => {
|
||||
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Add Intake Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Should have 1 intake row initially
|
||||
await expect(page.locator(".blister-row")).toHaveCount(1);
|
||||
|
||||
// Add a second intake
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
await expect(page.locator(".blister-row")).toHaveCount(2);
|
||||
|
||||
// Fill the new intake row
|
||||
const secondRow = page.locator(".blister-row").nth(1);
|
||||
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
|
||||
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
|
||||
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
||||
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||
|
||||
await saveEditAndVerify(page, "Add Intake Med");
|
||||
|
||||
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Reminder Toggle Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Find the remind checkbox in the intake row
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "PackType Change Med");
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
|
||||
// Should be blister type initially
|
||||
const packageSelect = page.locator("select.package-type-select");
|
||||
const packageSelect = form.locator("select.package-type-select");
|
||||
await expect(packageSelect).toHaveValue("blister");
|
||||
|
||||
// Blister-specific fields should be visible
|
||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
||||
// Blister-specific fields are shown in the Package tab.
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to bottle
|
||||
await packageSelect.selectOption("bottle");
|
||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||
|
||||
// Fill bottle-specific fields
|
||||
await page.getByLabel(/Total Capacity/i).fill("120");
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
||||
|
||||
await saveEditAndVerify(page, "PackType Change Med");
|
||||
|
||||
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
|
||||
await clickEditMed(page, "Multi Edit Med");
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med");
|
||||
|
||||
// Add generic name
|
||||
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
|
||||
await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
|
||||
|
||||
// Add notes
|
||||
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water.");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water.");
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Add a taken-by person
|
||||
const takenByInput = page.locator(".tag-input-container input");
|
||||
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Verify all changes persisted
|
||||
await clickEditMed(page, "Fully Edited Med");
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
|
||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
|
||||
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
|
||||
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
|
||||
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
|
||||
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { authFile, navigateTo, test } from "./fixtures";
|
||||
|
||||
/**
|
||||
@@ -10,6 +10,20 @@ import { authFile, navigateTo, test } from "./fixtures";
|
||||
test.describe("Medications Page", () => {
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
|
||||
|
||||
async function openMedicationForm(page: Page) {
|
||||
await navigateTo(page, "/medications");
|
||||
const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
if (await nameField.isVisible().catch(() => false)) return;
|
||||
|
||||
const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i });
|
||||
if (await newEntryButton.isVisible().catch(() => false)) {
|
||||
await newEntryButton.click();
|
||||
await expect(nameField).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
test("should display medications page", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
@@ -21,8 +35,8 @@ test.describe("Medications Page", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
// Should show either medication entries or the new medication form
|
||||
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i });
|
||||
const formTitle = page.locator("h2").filter({ hasText: /New medication/i });
|
||||
const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
|
||||
const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
|
||||
|
||||
const hasList = await listTitle.isVisible().catch(() => false);
|
||||
const hasForm = await formTitle.isVisible().catch(() => false);
|
||||
@@ -31,87 +45,93 @@ test.describe("Medications Page", () => {
|
||||
});
|
||||
|
||||
test("should display the medication form with required fields", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
// The form should always be visible on the medications page
|
||||
const commercialName = page.getByLabel(/Commercial Name/i);
|
||||
const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
await expect(commercialName).toBeVisible();
|
||||
|
||||
// Package type selector should exist
|
||||
await expect(page.getByText(/Package Type/i)).toBeVisible();
|
||||
await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible();
|
||||
|
||||
// Intake schedule section should exist
|
||||
await expect(page.getByText(/Intake schedule/i)).toBeVisible();
|
||||
// Tabbed form should expose navigation to Package/Schedule sections
|
||||
await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should fill in medication details", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
const nameField = page.getByLabel(/Commercial Name/i);
|
||||
const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
await nameField.fill("Test Aspirin");
|
||||
await expect(nameField).toHaveValue("Test Aspirin");
|
||||
|
||||
const genericField = page.getByLabel(/Generic Name/i);
|
||||
const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||
await genericField.fill("Acetylsalicylic acid");
|
||||
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
||||
});
|
||||
|
||||
test("should have stock inventory fields", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Stock fields should be visible
|
||||
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
|
||||
// Package tab should expose stock-related fields for at least one package mode.
|
||||
const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first();
|
||||
const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first();
|
||||
|
||||
// Either blister or bottle fields depending on package type
|
||||
const blistersField = page.getByLabel(/Blisters per pack/i);
|
||||
const pillsField = page.getByLabel(/Pills per blister/i);
|
||||
const capacityField = page.getByLabel(/Total Capacity/i);
|
||||
const hasPacks = await packsField.isVisible().catch(() => false);
|
||||
const hasTotal = await totalField.isVisible().catch(() => false);
|
||||
|
||||
const hasBlister = await blistersField.isVisible().catch(() => false);
|
||||
const hasBottle = await capacityField.isVisible().catch(() => false);
|
||||
|
||||
expect(hasBlister || hasBottle).toBeTruthy();
|
||||
expect(hasPacks || hasTotal).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Find the package type radio buttons or selector
|
||||
const blisterOption = page.getByText(/Blister Pack/i);
|
||||
const bottleOption = page.getByText(/Pill Bottle/i);
|
||||
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
||||
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
||||
|
||||
if (await blisterOption.isVisible().catch(() => false)) {
|
||||
// Switch to bottle
|
||||
await bottleOption.click();
|
||||
// Bottle-specific fields should appear
|
||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
||||
|
||||
// Switch back to blister
|
||||
await blisterOption.click();
|
||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("should have intake schedule with add button", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Intake schedule section
|
||||
const scheduleSection = page.getByText(/Intake schedule/i);
|
||||
await expect(scheduleSection).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
|
||||
|
||||
// Should have at least one intake entry
|
||||
await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible();
|
||||
await expect(
|
||||
form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first()
|
||||
).toBeVisible();
|
||||
|
||||
// Should have an add intake button
|
||||
const addIntake = page.getByRole("button", { name: /Intake/i });
|
||||
const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
|
||||
await expect(addIntake).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have save and cancel buttons", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
// Fill in a name to make the form dirty
|
||||
await page.getByLabel(/Commercial Name/i).fill("Test");
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test");
|
||||
|
||||
// Save button
|
||||
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
||||
@@ -119,10 +139,11 @@ test.describe("Medications Page", () => {
|
||||
});
|
||||
|
||||
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
// Fill in the form to create unsaved changes
|
||||
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication");
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication");
|
||||
|
||||
// Try to navigate away
|
||||
await page.locator('button.pill:has-text("Dashboard")').click();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
|
||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||
|
||||
await takeBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { authFile, navigateTo, test } from "./fixtures";
|
||||
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Schedule / Timeline E2E Tests
|
||||
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
|
||||
test.describe("Schedule Timeline", () => {
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
const seededName = "Schedule Smoke Seed";
|
||||
const startThreeDaysAgo = (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 3);
|
||||
d.setHours(8, 0, 0, 0);
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
})();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
await createMedicationViaAPI({
|
||||
name: seededName,
|
||||
packageType: "blister",
|
||||
packCount: 2,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Daniel"],
|
||||
intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }],
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
});
|
||||
|
||||
test("should have timeline container in DOM", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
|
||||
test("should show past days toggle when medications exist", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// Past days toggle only appears when there are scheduled medications
|
||||
// Past days toggle appears when there are scheduled medications
|
||||
const pastToggle = page.locator(".past-days-toggle");
|
||||
const hasPastToggle = await pastToggle.isVisible().catch(() => false);
|
||||
|
||||
// Just verify it doesn't crash — visibility depends on medication data
|
||||
expect(typeof hasPastToggle).toBe("boolean");
|
||||
await expect(pastToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test("should expand/collapse past days on click", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const pastToggle = page.locator(".past-days-toggle");
|
||||
if (!(await pastToggle.isVisible().catch(() => false))) {
|
||||
// No medications — past days toggle not shown
|
||||
return;
|
||||
}
|
||||
await expect(pastToggle).toBeVisible();
|
||||
|
||||
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
||||
|
||||
@@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => {
|
||||
test("should show future days toggle when medications exist", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// Future days toggle only appears when there are scheduled medications
|
||||
// Future days toggle appears when there are scheduled medications
|
||||
const futureToggle = page.locator(".future-days-toggle");
|
||||
const hasFutureToggle = await futureToggle.isVisible().catch(() => false);
|
||||
expect(typeof hasFutureToggle).toBe("boolean");
|
||||
await expect(futureToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display day blocks in timeline", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// There should be at least one day block (today)
|
||||
// With medications there should be day blocks; otherwise empty-state is expected.
|
||||
const dayBlocks = page.locator(".day-block");
|
||||
expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0);
|
||||
const dayBlockCount = await dayBlocks.count();
|
||||
if (dayBlockCount === 0) {
|
||||
await expect(page.getByText(/No medications/i)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
expect(dayBlockCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should highlight today block", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// If there are medications, today should be highlighted
|
||||
// With medications, today should be highlighted
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
const hasTodayBlock = await todayBlock.isVisible().catch(() => false);
|
||||
|
||||
// Today block exists only if there are medications with schedules
|
||||
if (hasTodayBlock) {
|
||||
await expect(todayBlock).toBeVisible();
|
||||
// Should have a day divider with date text
|
||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||
}
|
||||
await expect(todayBlock).toBeVisible();
|
||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show day summary with progress", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
if (await todayBlock.isVisible().catch(() => false)) {
|
||||
const summary = todayBlock.locator(".day-summary");
|
||||
await expect(summary).toBeVisible();
|
||||
}
|
||||
await expect(todayBlock).toBeVisible();
|
||||
const summary = todayBlock.locator(".day-summary");
|
||||
await expect(summary).toBeVisible();
|
||||
});
|
||||
|
||||
test("should collapse/expand a day block", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
if (await todayBlock.isVisible().catch(() => false)) {
|
||||
const dayDivider = todayBlock.locator(".day-divider");
|
||||
await dayDivider.click();
|
||||
await expect(todayBlock).toBeVisible();
|
||||
const dayDivider = todayBlock.locator(".day-divider");
|
||||
await dayDivider.click();
|
||||
|
||||
// Check if it toggled collapsed state
|
||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
|
||||
// Click again to restore
|
||||
await dayDivider.click();
|
||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
await dayDivider.click();
|
||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
|
||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||
}
|
||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||
});
|
||||
|
||||
test("should show overview table with stock status", async ({ page }) => {
|
||||
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
|
||||
|
||||
// Overview table has class .table.table-7
|
||||
const overviewTable = page.locator(".table.table-7");
|
||||
const hasTable = await overviewTable.isVisible().catch(() => false);
|
||||
|
||||
// Table only visible if medications exist
|
||||
if (hasTable) {
|
||||
// Table should have a header row
|
||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||
}
|
||||
await expect(overviewTable).toBeVisible();
|
||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display share button in schedules section", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
||||
|
||||
const shareBtn = page.locator("button.share-btn");
|
||||
// Share button only visible if there are takenBy users
|
||||
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
|
||||
|
||||
// Just verify it's either visible or not (no crash)
|
||||
expect(typeof hasShareBtn).toBe("boolean");
|
||||
await expect(shareBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe("Settings Page", () => {
|
||||
test("should display settings form", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
await expect(page.locator("form.settings-form")).toBeVisible();
|
||||
await expect(page.locator("div.settings-form")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show language section with select", async ({ page }) => {
|
||||
@@ -60,7 +60,7 @@ test.describe("Settings Page", () => {
|
||||
await expect(thresholdGroup).toBeVisible();
|
||||
|
||||
// Should have three threshold number inputs
|
||||
const thresholdInputs = thresholdGroup.locator('input[type="number"]');
|
||||
const thresholdInputs = thresholdGroup.locator('input[type="text"]');
|
||||
await expect(thresholdInputs).toHaveCount(3);
|
||||
});
|
||||
|
||||
@@ -97,11 +97,11 @@ test.describe("Settings Page", () => {
|
||||
await expect(otherCard).toHaveClass(/selected/);
|
||||
});
|
||||
|
||||
test("should have save button in form footer", async ({ page }) => {
|
||||
test("should have export action button", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
const saveButton = page.locator('div.form-footer > button[type="submit"]');
|
||||
await expect(saveButton).toBeVisible();
|
||||
const exportButton = page.getByRole("button", { name: /Export Data|Daten exportieren/i });
|
||||
await expect(exportButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show export/import section", async ({ page }) => {
|
||||
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabledToggle) {
|
||||
// All toggles disabled (no notification channels configured) — skip
|
||||
return;
|
||||
}
|
||||
test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
|
||||
|
||||
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
||||
const initialState = await checkbox.isChecked();
|
||||
@@ -156,7 +153,7 @@ test.describe("Settings Page", () => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
const thresholdGroup = page.locator("div.threshold-chips-group");
|
||||
const inputs = thresholdGroup.locator('input[type="number"]');
|
||||
const inputs = thresholdGroup.locator('input[type="text"]');
|
||||
|
||||
// Set an invalid value (critical > low)
|
||||
const criticalInput = inputs.first();
|
||||
@@ -182,6 +179,6 @@ test.describe("Settings Page", () => {
|
||||
await settingsOption.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
await expect(page.locator("form.settings-form")).toBeVisible();
|
||||
await expect(page.locator("div.settings-form")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ test.describe("Share Schedule", () => {
|
||||
|
||||
// Should show the shared schedule page (not the login page)
|
||||
// Wait for either the schedule content or an error
|
||||
const sharedContent = page.locator(".shared-schedule, .share-page");
|
||||
const _sharedContent = page.locator(".shared-schedule, .share-page");
|
||||
const dayBlock = page.locator(".day-block");
|
||||
const medName = page.getByText(MED_ALICE);
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
test,
|
||||
} from "./fixtures";
|
||||
|
||||
/**
|
||||
* Tooltip Visibility Regression Tests
|
||||
*
|
||||
* Ensures that tooltip pseudo-elements on MedDetail footer icon buttons
|
||||
* are not clipped by ancestor overflow or hidden behind modal overlays.
|
||||
* This is a regression guard — tooltips have repeatedly broken due to
|
||||
* CSS overflow/z-index changes on modal containers.
|
||||
*/
|
||||
test.describe("MedDetail footer tooltip visibility", () => {
|
||||
test.use({ storageState: authFile });
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
const MED_NAME = "Tooltip Test Med";
|
||||
const createdMeds: TestMedication[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: MED_NAME,
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: new Date().toISOString().slice(0, 16),
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the MedDetail modal by clicking a medication row in the Dashboard overview table.
|
||||
*/
|
||||
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
||||
await navigateTo(page, "/dashboard");
|
||||
const overviewTable = page.locator(".table.table-7");
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
||||
await medRow.click();
|
||||
|
||||
const modal = page.locator(".modal-overlay.med-detail-overlay");
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
return modal;
|
||||
}
|
||||
|
||||
test("no ancestor of footer tooltip buttons has overflow:hidden", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const footer = modal.locator(".med-detail-footer");
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Walk up from footer through modal-content to modal-overlay and check overflow
|
||||
const overflowHiddenAncestors = await page.evaluate(() => {
|
||||
const footer = document.querySelector(".med-detail-footer");
|
||||
if (!footer) return ["footer not found"];
|
||||
|
||||
const problems: string[] = [];
|
||||
let el: HTMLElement | null = footer as HTMLElement;
|
||||
while (el && !el.classList.contains("modal-overlay")) {
|
||||
const computed = window.getComputedStyle(el);
|
||||
const overflowX = computed.overflowX;
|
||||
const overflowY = computed.overflowY;
|
||||
if (overflowX === "hidden" || overflowY === "hidden") {
|
||||
const id = el.id ? `#${el.id}` : "";
|
||||
const cls = el.className ? `.${el.className.split(" ").join(".")}` : "";
|
||||
problems.push(`${el.tagName.toLowerCase()}${id}${cls} has overflow: ${overflowX}/${overflowY}`);
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
return problems;
|
||||
});
|
||||
|
||||
expect(
|
||||
overflowHiddenAncestors,
|
||||
`Tooltip ancestors must not clip with overflow:hidden: ${overflowHiddenAncestors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tooltip z-index is above modal overlay", async ({ page }) => {
|
||||
const _modal = await openMedDetailModal(page);
|
||||
|
||||
// Get modal overlay z-index and tooltip pseudo-element z-index from CSS
|
||||
const { modalZIndex, tooltipZIndex, arrowZIndex } = await page.evaluate(() => {
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
const overlayZ = overlay ? Number.parseInt(window.getComputedStyle(overlay).zIndex, 10) : 0;
|
||||
|
||||
// Read the tooltip ::after z-index from stylesheets
|
||||
let ttZ = 0;
|
||||
let arrZ = 0;
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
const cssRule = rule as CSSStyleRule;
|
||||
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::after")) {
|
||||
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||
if (z > ttZ) ttZ = z;
|
||||
}
|
||||
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::before")) {
|
||||
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||
if (z > arrZ) arrZ = z;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// cross-origin sheets — skip
|
||||
}
|
||||
}
|
||||
return { modalZIndex: overlayZ, tooltipZIndex: ttZ, arrowZIndex: arrZ };
|
||||
});
|
||||
|
||||
expect(
|
||||
tooltipZIndex,
|
||||
`Tooltip ::after z-index (${tooltipZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||
).toBeGreaterThan(modalZIndex);
|
||||
expect(
|
||||
arrowZIndex,
|
||||
`Tooltip ::before z-index (${arrowZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||
).toBeGreaterThan(modalZIndex);
|
||||
});
|
||||
|
||||
test("edit button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const editBtn = modal.locator(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||
await expect(editBtn).toBeVisible();
|
||||
|
||||
// Hover to activate tooltip
|
||||
await editBtn.hover();
|
||||
// Small wait for CSS transition
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify the tooltip pseudo-element is visible and within viewport
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Edit tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("stock correction button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const stockBtn = modal.locator(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||
await expect(stockBtn).toBeVisible();
|
||||
|
||||
await stockBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Stock correction tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("export button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const exportBtn = modal.locator(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||
// Export button only shows when blisters exist — skip if not present
|
||||
if (!(await exportBtn.isVisible().catch(() => false))) {
|
||||
test.skip(true, "Export button not visible (no blisters)");
|
||||
return;
|
||||
}
|
||||
|
||||
await exportBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Export tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("close button tooltip in header is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const closeBtn = modal.locator("button.modal-close.tooltip-trigger");
|
||||
await expect(closeBtn).toBeVisible();
|
||||
|
||||
await closeBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-overlay button.modal-close.tooltip-trigger");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Close button tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -6,7 +6,6 @@
|
||||
<title>MedAssist-ng</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
@@ -14,7 +13,7 @@
|
||||
|
||||
<!-- Theme color -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Frontend entrypoint wrapper
|
||||
# Translates LOG_LEVEL into nginx access log control before
|
||||
# delegating to the standard nginx-unprivileged entrypoint.
|
||||
#
|
||||
# LOG_LEVEL=debug|info → access logs enabled (default)
|
||||
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
||||
# =============================================================================
|
||||
|
||||
# Normalize: lowercase + trim whitespace
|
||||
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||
|
||||
case "$level" in
|
||||
warn|error|fatal|silent)
|
||||
export NGINX_ACCESS_LOG="off"
|
||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
||||
;;
|
||||
*)
|
||||
export NGINX_ACCESS_LOG="/dev/stdout"
|
||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Delegate to the original nginx-unprivileged entrypoint
|
||||
exec /docker-entrypoint.sh "$@"
|
||||
@@ -6,11 +6,16 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Access log control (suppressed when LOG_LEVEL is warn or higher)
|
||||
access_log ${NGINX_ACCESS_LOG};
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||
|
||||
# Allow larger file uploads (for medication images and data import/export)
|
||||
client_max_body_size 50M;
|
||||
@@ -40,5 +45,9 @@ server {
|
||||
# Timeout for uploads
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
# Prevent buffering upstream responses to temp files (images can be large)
|
||||
# nginx streams directly to client instead of buffering the full response
|
||||
proxy_max_temp_file_size 0;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+352
-334
File diff suppressed because it is too large
Load Diff
+23
-15
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.10.2",
|
||||
"version": "1.16.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,35 +14,43 @@
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||
"test:e2e:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e",
|
||||
"test:e2e:all:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:all",
|
||||
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
||||
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
|
||||
"test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui",
|
||||
"test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed",
|
||||
"test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"zod": "^3.23.8"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||
|
||||
export default buildPlaywrightConfig(true);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test";
|
||||
|
||||
export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
||||
const env =
|
||||
typeof globalThis === "object" && "process" in globalThis
|
||||
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
||||
: {};
|
||||
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
||||
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
|
||||
|
||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
retries: 1,
|
||||
},
|
||||
{
|
||||
name: "chromium-data",
|
||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
fullyParallel: false,
|
||||
retries: 1,
|
||||
},
|
||||
];
|
||||
|
||||
if (runAllBrowsers) {
|
||||
projects.push(
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.spec.ts",
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!env.CI,
|
||||
retries: env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: env.CI
|
||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
navigationTimeout: 30000,
|
||||
actionTimeout: 5000,
|
||||
},
|
||||
projects,
|
||||
outputDir: "test-results/",
|
||||
webServer: [
|
||||
{
|
||||
command: "cd ../backend && npm run dev",
|
||||
url: "http://localhost:3000/health",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,153 +1,3 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||
|
||||
/**
|
||||
* Playwright E2E Testing Configuration
|
||||
*
|
||||
* Run E2E tests with:
|
||||
* npm run test:e2e - Run tests in headless mode
|
||||
* npm run test:e2e:ui - Run tests with Playwright UI
|
||||
* npm run test:e2e:headed - Run tests in headed mode
|
||||
*
|
||||
* Before running tests, ensure both backend and frontend are running:
|
||||
* docker compose -f docker-compose.dev.yml up
|
||||
*
|
||||
* Or run them separately:
|
||||
* cd backend && npm run dev
|
||||
* cd frontend && npm run dev
|
||||
*/
|
||||
|
||||
// Base URL for the frontend dev server
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
|
||||
export default defineConfig({
|
||||
// Directory containing test files
|
||||
testDir: "./e2e",
|
||||
|
||||
// Test file pattern
|
||||
testMatch: "**/*.spec.ts",
|
||||
|
||||
// Maximum time one test can run
|
||||
timeout: 30 * 1000,
|
||||
|
||||
// Maximum time to wait for expect assertions
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry failed tests (more retries on CI)
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Opt out of parallel tests on CI
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: process.env.CI
|
||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||
|
||||
// Shared settings for all projects
|
||||
use: {
|
||||
// Base URL for page.goto() calls
|
||||
baseURL,
|
||||
|
||||
// Collect trace on first retry
|
||||
trace: "on-first-retry",
|
||||
|
||||
// Capture screenshot on failure
|
||||
screenshot: "only-on-failure",
|
||||
|
||||
// Record video for every test so runs can be reviewed
|
||||
video: "on",
|
||||
|
||||
// Default viewport size
|
||||
viewport: { width: 1280, height: 720 },
|
||||
|
||||
// Wait for network idle before considering navigation complete
|
||||
navigationTimeout: 30000,
|
||||
|
||||
// Accept cookies and local storage
|
||||
actionTimeout: 5000,
|
||||
},
|
||||
|
||||
// Configure projects for multiple browsers
|
||||
projects: [
|
||||
// Setup project for authentication state
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Desktop Chrome — primary test browser, always runs
|
||||
// Excludes data/crud tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
retries: 1,
|
||||
},
|
||||
|
||||
// Desktop Firefox — runs locally and optionally in CI
|
||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// Desktop Safari — runs locally and optionally in CI
|
||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// Data tests — only Chromium, run serially to avoid DB conflicts
|
||||
// These tests create/edit/delete medications and must not run concurrently
|
||||
// across browsers since all share the same backend database.
|
||||
{
|
||||
name: "chromium-data",
|
||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
fullyParallel: false,
|
||||
retries: 1,
|
||||
},
|
||||
],
|
||||
|
||||
// Directory for test output files (screenshots, traces, videos)
|
||||
outputDir: "test-results/",
|
||||
|
||||
// Web server configuration — automatically start dev servers in CI
|
||||
webServer: [
|
||||
{
|
||||
command: "cd ../backend && npm run dev",
|
||||
url: "http://localhost:3000/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
export default buildPlaywrightConfig(false);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||
|
||||
export default buildPlaywrightConfig(false);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.9 MiB |
+197
-93
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AboutModal,
|
||||
Lightbox,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
||||
import { useScrollLock } from "./hooks/useScrollLock";
|
||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
||||
|
||||
// Vite injects this at build time from package.json
|
||||
@@ -112,14 +113,14 @@ function AppRouter() {
|
||||
// =============================================================================
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Get shared state from AppContext
|
||||
const ctx = useAppContext();
|
||||
const {
|
||||
// Medications
|
||||
meds,
|
||||
loadMeds,
|
||||
// Settings
|
||||
settings,
|
||||
// Refill
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
@@ -127,6 +128,8 @@ function AppContent() {
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
usePrescriptionRefill,
|
||||
setUsePrescriptionRefill,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
@@ -137,7 +140,10 @@ function AppContent() {
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockLoosePills,
|
||||
setEditStockLoosePills,
|
||||
editStockSaving,
|
||||
editStockMedication,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
@@ -184,59 +190,24 @@ function AppContent() {
|
||||
// Local-only state (not shared across components)
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [routeTransitionMaskActive, setRouteTransitionMaskActive] = useState(false);
|
||||
const routeTransitionMinEndRef = useRef(0);
|
||||
const routeTransitionFallbackTimerRef = useRef<number | null>(null);
|
||||
const closeProfile = useCallback(() => {
|
||||
if (showProfile) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showProfile]);
|
||||
|
||||
const closeAbout = useCallback(() => {
|
||||
if (showAbout) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showAbout]);
|
||||
|
||||
// Get centralized stockThresholds from context
|
||||
const { stockThresholds } = ctx;
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Close modals in order of priority (topmost first)
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
} else if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
} else if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
} else if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
} else if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
} else if (showAbout) {
|
||||
closeAbout();
|
||||
} else if (showProfile) {
|
||||
closeProfile();
|
||||
} else if (selectedUser) {
|
||||
closeUserFilter();
|
||||
} else if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
showProfile,
|
||||
showAbout,
|
||||
showShareDialog,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
closeAbout,
|
||||
closeEditStockModal,
|
||||
closeImageLightbox,
|
||||
closeMedDetail,
|
||||
closeProfile,
|
||||
closeRefillModal,
|
||||
closeScheduleLightbox,
|
||||
closeShareDialog,
|
||||
closeUserFilter,
|
||||
]);
|
||||
|
||||
// Handle browser back button to close modals (in priority order)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
@@ -287,18 +258,25 @@ function AppContent() {
|
||||
// Close tooltips on scroll/touch (for mobile)
|
||||
useEffect(() => {
|
||||
const closeAllTooltips = () => {
|
||||
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => {
|
||||
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
||||
el.classList.remove("tooltip-active");
|
||||
});
|
||||
};
|
||||
|
||||
const handleTooltipClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains("info-tooltip")) {
|
||||
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
||||
if (tooltipTrigger) {
|
||||
// Close other tooltips first
|
||||
closeAllTooltips();
|
||||
// Toggle this one
|
||||
target.classList.add("tooltip-active");
|
||||
tooltipTrigger.classList.add("tooltip-active");
|
||||
// Position tooltip above the icon on mobile
|
||||
if (window.innerWidth <= 640) {
|
||||
const rect = tooltipTrigger.getBoundingClientRect();
|
||||
// Place tooltip bottom edge just above the icon
|
||||
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
||||
}
|
||||
} else {
|
||||
closeAllTooltips();
|
||||
}
|
||||
@@ -318,21 +296,86 @@ function AppContent() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prevent background scroll when modal is open
|
||||
// Global Escape handling in priority order.
|
||||
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||
useEffect(() => {
|
||||
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
|
||||
if (isModalOpen) {
|
||||
document.documentElement.classList.add("modal-open");
|
||||
document.body.classList.add("modal-open");
|
||||
} else {
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
return;
|
||||
}
|
||||
if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
return;
|
||||
}
|
||||
if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
return;
|
||||
}
|
||||
if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
return;
|
||||
}
|
||||
if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
return;
|
||||
}
|
||||
if (showAbout) {
|
||||
closeAbout();
|
||||
return;
|
||||
}
|
||||
if (showProfile) {
|
||||
closeProfile();
|
||||
return;
|
||||
}
|
||||
if (selectedUser) {
|
||||
closeUserFilter();
|
||||
return;
|
||||
}
|
||||
if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
};
|
||||
}, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]);
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
showEditStockModal,
|
||||
showRefillModal,
|
||||
showShareDialog,
|
||||
showAbout,
|
||||
showProfile,
|
||||
selectedUser,
|
||||
selectedMed,
|
||||
closeImageLightbox,
|
||||
closeScheduleLightbox,
|
||||
closeEditStockModal,
|
||||
closeRefillModal,
|
||||
closeShareDialog,
|
||||
closeAbout,
|
||||
closeProfile,
|
||||
closeUserFilter,
|
||||
closeMedDetail,
|
||||
]);
|
||||
|
||||
// Prevent background scroll when any modal is open
|
||||
useScrollLock(
|
||||
!!(
|
||||
selectedMed ||
|
||||
selectedUser ||
|
||||
showProfile ||
|
||||
showAbout ||
|
||||
showShareDialog ||
|
||||
showRefillModal ||
|
||||
showEditStockModal ||
|
||||
showImageLightbox ||
|
||||
scheduleLightboxImage
|
||||
)
|
||||
);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
@@ -349,42 +392,90 @@ function AppContent() {
|
||||
}
|
||||
}, [meds, selectedMed, setSelectedMed]);
|
||||
|
||||
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
|
||||
|
||||
const handleSubmitStockCorrection = async (medId: number) => {
|
||||
if (!selectedMed) return;
|
||||
await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
|
||||
if (!stockCorrectionMed) return;
|
||||
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
|
||||
};
|
||||
|
||||
// For MedDetailModal: refill without form update (not editing)
|
||||
const handleSubmitRefill = async (medId: number) => {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds);
|
||||
const handleSubmitRefill = async (medId: number, usePrescription: boolean = false) => {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||
};
|
||||
|
||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
||||
const handleOpenEditStockModal = () => {
|
||||
if (selectedMed) {
|
||||
openEditStockModal(selectedMed, coverage);
|
||||
useEffect(() => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
if (location.pathname !== "/medications") return;
|
||||
|
||||
const hasEditMedIdParam = new URLSearchParams(location.search).has("editMedId");
|
||||
if (hasEditMedIdParam) return;
|
||||
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
const timer = window.setTimeout(() => setRouteTransitionMaskActive(false), remaining);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [location.pathname, location.search, routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEditTransitionReady = () => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}
|
||||
}, remaining);
|
||||
};
|
||||
|
||||
window.addEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
return () => {
|
||||
window.removeEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
};
|
||||
}, [routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationEdit = () => {
|
||||
if (!selectedMed) return;
|
||||
const medId = selectedMed.id;
|
||||
routeTransitionMinEndRef.current = performance.now() + 80;
|
||||
setRouteTransitionMaskActive(true);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
routeTransitionFallbackTimerRef.current = window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}, 700);
|
||||
setShowImageLightbox(false);
|
||||
setShowRefillModal(false);
|
||||
setShowEditStockModal(false);
|
||||
setSelectedMed(null);
|
||||
navigate(`/medications?editMedId=${medId}`);
|
||||
};
|
||||
|
||||
function openProfile() {
|
||||
const handleOpenEditStockFromDetail = () => {
|
||||
if (!selectedMed) return;
|
||||
openEditStockModal(selectedMed, coverage);
|
||||
};
|
||||
|
||||
const openProfile = useCallback(() => {
|
||||
setShowProfile(true);
|
||||
window.history.pushState({ modal: "profile" }, "");
|
||||
}
|
||||
function closeProfile() {
|
||||
if (showProfile) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function openAbout() {
|
||||
const openAbout = useCallback(() => {
|
||||
setShowAbout(true);
|
||||
window.history.pushState({ modal: "about" }, "");
|
||||
}
|
||||
function closeAbout() {
|
||||
if (showAbout) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
@@ -413,23 +504,27 @@ function AppContent() {
|
||||
|
||||
{/* Medication Detail Modal */}
|
||||
<MedDetailModal
|
||||
selectedMed={selectedMed}
|
||||
selectedMed={stockCorrectionMed}
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
showImageLightbox={showImageLightbox}
|
||||
showRefillModal={showRefillModal}
|
||||
showEditStockModal={showEditStockModal}
|
||||
editStockOnly={showEditStockModal && !selectedMed}
|
||||
onClose={closeMedDetail}
|
||||
onOpenImageLightbox={openImageLightbox}
|
||||
onCloseImageLightbox={closeImageLightbox}
|
||||
onOpenRefillModal={openRefillModal}
|
||||
onCloseRefillModal={closeRefillModal}
|
||||
onOpenEditStockModal={handleOpenEditStockModal}
|
||||
onOpenMedicationEdit={handleOpenMedicationEdit}
|
||||
onOpenEditStockModal={handleOpenEditStockFromDetail}
|
||||
onCloseEditStockModal={closeEditStockModal}
|
||||
refillPacks={refillPacks}
|
||||
onRefillPacksChange={setRefillPacks}
|
||||
refillLoose={refillLoose}
|
||||
onRefillLooseChange={setRefillLoose}
|
||||
usePrescriptionRefill={usePrescriptionRefill}
|
||||
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
|
||||
refillSaving={refillSaving}
|
||||
refillHistory={refillHistory}
|
||||
refillHistoryExpanded={refillHistoryExpanded}
|
||||
@@ -439,6 +534,8 @@ function AppContent() {
|
||||
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
||||
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
||||
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
||||
editStockLoosePills={editStockLoosePills}
|
||||
onEditStockLoosePillsChange={setEditStockLoosePills}
|
||||
editStockSaving={editStockSaving}
|
||||
onSubmitStockCorrection={handleSubmitStockCorrection}
|
||||
/>
|
||||
@@ -450,6 +547,11 @@ function AppContent() {
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onClearUser={() => {
|
||||
setSelectedUser(null);
|
||||
// Replace the userFilter history entry so it doesn't remain on the stack
|
||||
window.history.replaceState(null, "");
|
||||
}}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
|
||||
@@ -475,6 +577,8 @@ function AppContent() {
|
||||
{scheduleLightboxImage && (
|
||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||
)}
|
||||
|
||||
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: "up-to-date" | "update-available" | "error";
|
||||
@@ -17,6 +18,8 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Reset check result when modal opens so stale results are never shown
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -51,14 +54,26 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content about-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" />
|
||||
<img src="/app-logo.png" alt="MedAssist-ng" />
|
||||
</div>
|
||||
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
||||
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useUnsavedChanges } from "../context";
|
||||
import type { ThemePreference } from "../hooks";
|
||||
import { useTheme } from "../hooks";
|
||||
import { useAuth } from "./Auth";
|
||||
|
||||
@@ -74,7 +73,7 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
return (
|
||||
<header className="hero">
|
||||
<div className="hero-title">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
|
||||
<img src="/app-logo.png" alt="MedAssist-ng" className="hero-logo" />
|
||||
<div>
|
||||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||||
<h1>{pageInfo.title}</h1>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { withCorrelation } from "../utils/correlation";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
|
||||
@@ -59,7 +64,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
// Track if initial fetch has been done to prevent duplicate calls
|
||||
const initialFetchDone = useRef(false);
|
||||
|
||||
@@ -69,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
initialFetchDone.current = true;
|
||||
fetchAuthState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [fetchAuthState]);
|
||||
|
||||
// Proactively refresh token every 10 minutes to prevent expiration
|
||||
useEffect(() => {
|
||||
@@ -88,15 +92,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return () => clearInterval(refreshInterval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, authState?.authEnabled]);
|
||||
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
|
||||
|
||||
async function fetchAuthState(retryCount = 0) {
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 1000; // 1 second
|
||||
let correlationId: string | null = null;
|
||||
|
||||
try {
|
||||
setAuthError(null);
|
||||
const res = await fetch("/api/auth/state");
|
||||
const correlated = withCorrelation(undefined, "fe-auth-state");
|
||||
correlationId = correlated.correlationId;
|
||||
const res = await fetch("/api/auth/state", correlated.init);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server error: ${res.status}`);
|
||||
}
|
||||
@@ -109,7 +116,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err);
|
||||
log.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err, {
|
||||
correlationId,
|
||||
});
|
||||
|
||||
// Retry on connection errors or 5xx errors (server might be restarting)
|
||||
if (retryCount < maxRetries) {
|
||||
@@ -124,27 +133,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
async function refreshUser() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const { correlationId, init } = withCorrelation({ credentials: "include" }, "fe-auth-me");
|
||||
const res = await fetch("/api/auth/me", init);
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
||||
} else if (res.status === 401) {
|
||||
// Access token expired - try to refresh it
|
||||
log.info("[Auth] Access token invalid, attempting refresh", { correlationId });
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) {
|
||||
// Retry /auth/me with new token
|
||||
const retryRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const retry = withCorrelation({ credentials: "include" }, "fe-auth-me-retry");
|
||||
const retryRes = await fetch("/api/auth/me", retry.init);
|
||||
if (retryRes.ok) {
|
||||
const userData = await retryRes.json();
|
||||
setUser(userData);
|
||||
log.info("[Auth] Session restored after token refresh", {
|
||||
userId: userData.id,
|
||||
correlationId: retry.correlationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
||||
setUser(null);
|
||||
} else {
|
||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error("[Auth] Failed to refresh user", { error });
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
@@ -152,31 +172,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Try to refresh the access token using the refresh token
|
||||
async function tryRefreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
},
|
||||
"fe-auth-refresh"
|
||||
);
|
||||
const res = await fetch("/api/auth/refresh", init);
|
||||
if (!res.ok) {
|
||||
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
||||
}
|
||||
return res.ok;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error("[Auth] Token refresh request failed", { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string, rememberMe: boolean = false) {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
},
|
||||
"fe-auth-login"
|
||||
);
|
||||
log.info("[Auth] Login requested", { username, rememberMe, correlationId });
|
||||
const res = await fetch("/api/auth/login", init);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
log.warn("[Auth] Login failed", { username, status: res.status, code: data.code, correlationId });
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
|
||||
}
|
||||
|
||||
async function register(username: string, password: string) {
|
||||
@@ -200,11 +235,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
},
|
||||
"fe-auth-logout"
|
||||
);
|
||||
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||
await fetch("/api/auth/logout", init);
|
||||
setUser(null);
|
||||
log.info("[Auth] Logout completed", { correlationId });
|
||||
}
|
||||
|
||||
async function updateProfile(data: { currentPassword?: string; newPassword?: string }) {
|
||||
@@ -235,8 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
throw new Error(err.error || "Upload failed");
|
||||
let code = "UNKNOWN";
|
||||
try {
|
||||
const body = (await res.json()) as { code?: string };
|
||||
if (typeof body?.code === "string" && body.code.trim().length > 0) {
|
||||
code = body.code;
|
||||
}
|
||||
} catch {
|
||||
// No JSON body
|
||||
}
|
||||
throw new Error(code);
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
@@ -573,34 +622,32 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [avatarError, setAvatarError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setAvatarError(t("form.imageUploadErrors.tooLarge"));
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
@@ -609,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
async function handleAvatarDelete() {
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await deleteAvatar();
|
||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
@@ -709,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="profile-username">{user.username}</span>
|
||||
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdate} className="profile-form">
|
||||
@@ -755,7 +804,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
{t("common.close", "Close")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -12,7 +13,8 @@ export interface ConfirmModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
confirmVariant?: "primary" | "danger" | "success";
|
||||
confirmVariant?: "primary" | "danger" | "success" | "warning";
|
||||
overlayClassName?: string;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
@@ -24,10 +26,26 @@ export function ConfirmModal({
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
useEscapeKey(true, onCancel);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<div
|
||||
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user