Compare commits

...

72 Commits

Author SHA1 Message Date
Daniel Volz 37fc2b8e66 chore: release v1.21.0 (#467) 2026-03-20 21:02:28 +01:00
github-actions[bot] d434131d02 chore: update test count badges [skip ci] 2026-03-20 19:43:52 +00:00
Daniel Volz b796e03bcb feat: add medication enrichment lookup to the medication editor
* feat: add medication enrichment lookup

* fix: avoid double unescape in enrichment sanitization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 20:39:38 +01:00
github-actions[bot] e1b47e82b2 chore: update test count badges [skip ci] 2026-03-20 14:04:44 +00:00
Daniel Volz 68ab79c713 feat: enable weekday-based medication scheduling
Closes #463
2026-03-20 14:58:25 +01:00
Daniel Volz 29f4c4e48d chore: release v1.20.2 (#462) 2026-03-17 05:55:29 +01:00
Daniel Volz 934519767a fix: restore obsolete actions in timeline views 2026-03-16 21:39:22 +01:00
Daniel Volz 9e224c0441 fix: improve shared schedule stock overview display 2026-03-16 21:33:55 +01:00
github-actions[bot] a0b0febe85 chore: update test count badges [skip ci] 2026-03-16 20:33:15 +00:00
Daniel Volz 5138d784cd chore: improve intake reminder observability 2026-03-16 21:28:53 +01:00
Daniel Volz 5b019f942d chore: clean up repo automation governance 2026-03-16 21:23:58 +01:00
Daniel Volz 14e783f111 fix: exclude obsolete medications from share flows 2026-03-16 21:21:41 +01:00
dependabot[bot] fb62227154 build(deps-dev): bump jsdom from 28.1.0 to 29.0.0 in /frontend
Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.0.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/v29.0.0/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v29.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:36:53 +01:00
dependabot[bot] 9b95be851c build(deps): bump dorny/paths-filter from 3 to 4
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:32:03 +01:00
Daniel Volz 0f9458b7cb chore: align vite 8 and plugin-react 6 stack
* chore: align vite 8 and plugin-react 6 stack

* fix: remove array index keys from intake rows

* chore: format shared schedule test fix
2026-03-16 08:26:50 +01:00
dependabot[bot] 01b59e66ca build(deps-dev): bump the minor-and-patch group with 2 updates
Squash merge Dependabot root minor-and-patch dependency updates from PR #438 after rebasing onto updated main.
2026-03-16 07:53:18 +01:00
dependabot[bot] 9180783c42 build(deps): bump the minor-and-patch group in /backend with 5 updates
Squash merge Dependabot backend minor-and-patch dependency updates from PR #443.
2026-03-16 07:51:28 +01:00
Daniel Volz cc636eb98b chore: release v1.20.1 (#437) 2026-03-15 20:01:58 +01:00
github-actions[bot] 8c77a87bc5 chore: update test count badges [skip ci] 2026-03-15 18:31:55 +00:00
Daniel Volz 908e4e724f fix: remove dead shareStockStatus gating from shared medication overview (#436)
The shareStockStatus UI toggle was replaced by shareMedicationOverview in
commit e0fb77d, but the backend gating logic was left intact. Users who
had previously set shareStockStatus=false were stuck with empty stock
values ('-') on the shared medication overview with no UI to change it.

- Remove showStockStatus parameter from buildSharedMedicationOverview()
- Remove visibility gating that nullified stock fields
- Remove shareStockStatus from settings API responses and PUT schema
- Remove shareStockStatus from frontend types, hooks, and context
- Clean up all related test fixtures and dead test cases
- DB column share_stock_status retained (never remove columns)
2026-03-15 19:27:39 +01:00
Daniel Volz ef78e51b4e chore: release v1.20.0 (#434) 2026-03-14 22:13:00 +01:00
dependabot[bot] b57dc0fb35 build(deps): bump undici from 7.21.0 to 7.24.1 in /frontend
Fixes multiple security vulnerabilities: CVE-2026-1525, CVE-2026-1528, CVE-2026-2581, CVE-2026-1527, CVE-2026-2229, CVE-2026-1526
2026-03-14 22:04:55 +01:00
Daniel Volz 99160c14ed fix: out-of-stock button styling and schedule visual cleanup (#431)
- Add distinct styling for out-of-stock dose buttons (.dose-btn.take.out-of-stock)
- Remove redundant .time-row.taken opacity dimming
- Include Playwright regression test for stock status visuals
2026-03-14 21:53:58 +01:00
github-actions[bot] 63b07e0da8 chore: update test count badges [skip ci] 2026-03-14 20:53:08 +00:00
Daniel Volz 8ec7d3ae3d fix: include shareMedicationOverview in unsaved settings detection (#433)
Add shareMedicationOverview to the settingsChanged memo in AppContext
so toggling the shared medication overview setting correctly triggers
the unsaved-changes indicator.

Includes regression test for the fix.
2026-03-14 21:49:37 +01:00
Daniel Volz c38c6efb6d chore: sync agent docs, gitignore, and VS Code tasks (#432)
- Migrate release-manager from gh CLI to GitHub MCP tool usage
- Add workspace hygiene and source-of-truth audit rules
- Add pre-PR local quality gate and no-CI-first-failures policy
- Update testing-manager with enhanced validation workflow
- Add scheduler lock files to .gitignore
- Add E2E test task configurations to VS Code tasks
2026-03-14 21:47:06 +01:00
github-actions[bot] 9d605a1855 chore: update test count badges [skip ci] 2026-03-14 19:53:07 +00:00
Daniel Volz 0160ef3ddf fix: restore schedule interaction correctness
* fix: restore schedule interaction correctness

* fix: use scheduled stock timing for historical doses
2026-03-14 20:49:13 +01:00
github-actions[bot] 816888a697 chore: update test count badges [skip ci] 2026-03-14 19:32:31 +00:00
Daniel Volz e0fb77d494 feat: embed medication overview into shared links
Closes #424
2026-03-14 20:26:17 +01:00
github-actions[bot] fd3134be24 chore: update test count badges [skip ci] 2026-03-12 20:36:56 +00:00
Daniel Volz d0837a7281 feat: stack related date fields and clarify share stock labels (#422)
* feat: stack related date fields and clarify share stock labels

* test: cover stacked date pairs and share labels
2026-03-12 21:32:56 +01:00
Daniel Volz 3fda41e501 fix: restore automatic intake auto-marking (#420) 2026-03-12 21:32:51 +01:00
Daniel Volz c13bfad16f feat: improve OpenAPI request contracts and examples (#418)
* feat: improve OpenAPI request contracts and examples

* fix: align AJV docs plugin typing

* fix: preserve runtime behavior for OpenAPI schemas

* fix: align medication OpenAPI body schema with app payloads
2026-03-11 14:50:42 +01:00
Daniel Volz dd8ddb64e6 feat: expose optional API docs through frontend ingress (#416) 2026-03-11 10:03:34 +01:00
Daniel Volz 75196e5fa8 docs: extract complete default user settings reference (#414) 2026-03-10 20:43:46 +01:00
Daniel Volz 5264c761cf feat: standardize OpenAPI route docs metadata (#412) 2026-03-10 16:52:31 +01:00
dependabot[bot] e0a50d01bb build(deps): bump react-i18next from 15.7.4 to 16.5.6 in /frontend (#395)
* build(deps): bump react-i18next from 15.7.4 to 16.5.6 in /frontend

Bumps [react-i18next](https://github.com/i18next/react-i18next) from 15.7.4 to 16.5.6.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v15.7.4...v16.5.6)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 16.5.6
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* test(e2e): harden selectors for PR #395 checks

* test(e2e): harden API key and share button assertions

* test: stabilize flaky playwright checks for settings and schedule

* test: skip env-dependent e2e assertions when controls are unavailable

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-10 13:53:16 +01:00
dependabot[bot] 4d5edb7c76 build(deps): bump docker/setup-buildx-action from 3 to 4 (#398)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-10 13:24:29 +01:00
dependabot[bot] 07bfa78386 build(deps): bump docker/login-action from 3 to 4 (#399)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 13:23:19 +01:00
dependabot[bot] 8d37fd0cb5 build(deps): bump docker/build-push-action from 6 to 7 (#400)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 12:16:49 +01:00
dependabot[bot] 890449d756 build(deps-dev): bump the minor-and-patch group with 2 updates (#393)
Bumps the minor-and-patch group with 2 updates: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [lint-staged](https://github.com/lint-staged/lint-staged).


Updates `@biomejs/biome` from 2.4.4 to 2.4.6
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.6/packages/@biomejs/biome)

Updates `lint-staged` from 16.3.1 to 16.3.2
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.3.1...v16.3.2)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lint-staged
  dependency-version: 16.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-10 12:15:33 +01:00
dependabot[bot] 192e611668 build(deps): bump actions/stale from 9 to 10 (#397)
Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9...v10)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '10'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 12:15:29 +01:00
dependabot[bot] 4de3b80aba build(deps): bump docker/metadata-action from 5 to 6 (#401)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 12:15:08 +01:00
Daniel Volz fd17288109 chore: gate Playwright E2E to app-relevant changes (#410)
* ci: gate Playwright E2E to app-relevant changes

* ci: skip app e2e for workflow-only PRs
2026-03-10 11:52:54 +01:00
github-actions[bot] c59fdfb92b chore: update test count badges [skip ci] 2026-03-10 05:32:41 +00:00
Daniel Volz c0507c4c4b feat: backend API key auth context and settings hardening (#406)
* feat: add backend api-key auth context and settings hardening

* fix: harden api key token hashing
2026-03-10 06:26:20 +01:00
Daniel Volz 105eb7bc0d feat: add shared overview and harden frontend session state (#407) 2026-03-10 06:26:03 +01:00
Daniel Volz 733fe2f38a fix: stabilize e2e suite and align dev runtime config (#408)
* fix: stabilize e2e suite and align dev runtime config

* fix: harden forbidden-settings e2e assertion

* fix: make forbidden settings e2e assertion robust
2026-03-10 06:25:46 +01:00
dependabot[bot] 2db49e427a build(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [i18next](https://github.com/i18next/i18next), [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `i18next` from 25.8.13 to 25.8.14
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.8.13...v25.8.14)

Updates `lucide-react` from 0.575.0 to 0.577.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.577.0/packages/lucide-react)

Updates `@biomejs/biome` from 2.4.4 to 2.4.6
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.6/packages/@biomejs/biome)

Updates `@types/node` from 25.3.3 to 25.3.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 0.577.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-09 22:11:32 +01:00
dependabot[bot] 0e4d7f71e4 build(deps): bump the minor-and-patch group in /backend with 3 updates
Bumps the minor-and-patch group in /backend with 3 updates: [fastify](https://github.com/fastify/fastify), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `fastify` from 5.8.1 to 5.8.2
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.1...v5.8.2)

Updates `@biomejs/biome` from 2.4.4 to 2.4.6
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.6/packages/@biomejs/biome)

Updates `@types/node` from 25.3.3 to 25.3.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 22:08:13 +01:00
Daniel Volz 8594e175f1 feat: improve CI and project automation workflows (#390)
- Harden docker/release workflow with manual release guardrails and concurrency
- Add stale issue cleanup workflow (issues only)
- Add project field sync workflow from issue labels
- Add weekly triage report workflow
- Add CODEOWNERS for automatic review routing
2026-03-08 00:49:08 +01:00
Daniel Volz 8e29219cd1 chore: refine issue template chooser and metadata\n\n- simplify issue chooser to template-focused structure\n- add default bug assignee\n- add [Bug]/[Feature] title prefixes (#392) 2026-03-08 00:49:04 +01:00
Daniel Volz 0be472bf38 Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  chore: release v1.19.0 (#388)
2026-03-06 20:41:33 +01:00
Daniel Volz e8279bd521 chore: release v1.19.0 (#388)
* docs: require explicit issue comment when closing issues via PR

* chore: release v1.19.0
2026-03-06 20:16:42 +01:00
Daniel Volz 4136252a20 Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  build(deps): bump fastify from 5.7.4 to 5.8.1 in /backend (#387)
2026-03-06 20:13:46 +01:00
dependabot[bot] 36d50c0736 build(deps): bump fastify from 5.7.4 to 5.8.1 in /backend (#387)
Bumps [fastify](https://github.com/fastify/fastify) from 5.7.4 to 5.8.1.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.7.4...v5.8.1)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.8.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 20:02:08 +01:00
Daniel Volz d7d4bf39a0 docs: require explicit issue comment when closing issues via PR 2026-03-06 19:59:59 +01:00
Daniel Volz 5b6c6abb69 feat: display actual reminder schedule from server config (#386)
- Expose REMINDER_HOUR and REMINDER_MINUTES_BEFORE env values via settings API
- Add reminderHour and reminderMinutesBefore to frontend Settings interface
- Replace hardcoded i18n strings with parameterized translations
- Settings page now shows configured schedule instead of static 6:00 / 15 min
2026-03-06 19:51:19 +01:00
Daniel Volz 30c97e2f0d fix: use per-intake reminder setting as single source of truth (#384)
- Filter intakes by per-intake intakeRemindersEnabled instead of falling
  back to medication-level setting (fixes #383)
- Add SMTP delivery validation with accepted/rejected recipient checks
- Enhance email success logging with recipient, messageId, SMTP response
- Simplify MedDetailModal reminder icon logic to match backend behavior
- Sync lockfile versions to 1.18.2
2026-03-06 19:50:45 +01:00
Daniel Volz de1a508e52 chore: ignore copilot tracking artifacts (#382) 2026-03-04 21:15:42 +01:00
Daniel Volz 54d26e0241 fix: remove redundant flaky schedule timeline assertion (#381)
* test: remove redundant flaky schedule timeline assertion

* fix(frontend): fix schedule-data e2e formatting for CI gate
2026-03-04 21:15:29 +01:00
Daniel Volz ac47fc001d fix: adapt e2e medication flows to all package profiles (#380)
* test: adapt e2e medication flows to all package profiles

* fix(frontend): resolve frontend build lint blockers
2026-03-04 21:15:18 +01:00
Daniel Volz 4936929849 feat: replace hardcoded package assumptions with profile abstraction (#379) 2026-03-04 21:15:05 +01:00
Daniel Volz 6672fb78c9 chore: stop tracking doku memory/report files 2026-03-02 23:41:57 +01:00
Daniel Volz b349e26833 chore: release v1.18.2 (#374) 2026-03-02 23:34:18 +01:00
Daniel Volz 56d244aa61 fix: stabilize frontend e2e selectors and auth/session reliability (#373) 2026-03-02 23:21:57 +01:00
dependabot[bot] 1a348c62f5 build(deps-dev): bump lint-staged in the minor-and-patch group (#369)
Bumps the minor-and-patch group with 1 update: [lint-staged](https://github.com/lint-staged/lint-staged).


Updates `lint-staged` from 16.2.7 to 16.3.1
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.2.7...v16.3.1)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 16.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-02 13:22:32 +01:00
dependabot[bot] 067a8c166b build(deps-dev): bump @types/node (#371)
Bumps the minor-and-patch group in /backend with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 25.3.2 to 25.3.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 12:56:23 +01:00
dependabot[bot] 8fdd79ff33 build(deps-dev): bump @types/node (#370)
Bumps the minor-and-patch group in /frontend with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 25.3.2 to 25.3.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 12:56:16 +01:00
Daniel Volz cd8263e607 fix: align desktop intake labels and form field pairing (#368)
* fix: align desktop intake labels and form field pairing

* chore: align frontend lockfile version and include remaining local changes
2026-03-02 01:33:28 +01:00
Daniel Volz e6a097d81d chore: release v1.18.1 (#366) 2026-03-02 01:16:08 +01:00
155 changed files with 24211 additions and 15107 deletions
+14 -1
View File
@@ -12,6 +12,7 @@ PGID=1000
PORT=3000
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
@@ -28,6 +29,14 @@ LOG_LEVEL=warn
# Increase for development/testing environments
# RATE_LIMIT_MAX=100
# API documentation UI + OpenAPI JSON
# Default behavior: enabled outside production, disabled in production
# When enabled, docs are available on /docs and /docs/json.
# Recommended:
# development/staging: OPENAPI_DOCS_ENABLED=true
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
TZ=Europe/Berlin
@@ -113,12 +122,14 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# DEFAULT_NOTIFICATION_EMAIL=
# DEFAULT_EMAIL_STOCK_REMINDERS=true
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
# Push notifications (ntfy/gotify via Shoutrrr)
# DEFAULT_SHOUTRRR_ENABLED=false
# DEFAULT_SHOUTRRR_URL=
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
# DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS=true
# Repeat/nagging reminders for missed doses
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
@@ -137,4 +148,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+11
View File
@@ -0,0 +1,11 @@
# MedAssist ownership
# This routes review requests automatically to the maintainer.
* @DanielVolz
# Explicit domains for clarity
/backend/ @DanielVolz
/frontend/ @DanielVolz
/.github/ @DanielVolz
/doku/ @DanielVolz
/docs/ @DanielVolz
+4 -1
View File
@@ -1,6 +1,9 @@
name: 🐛 Bug Report
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "triage"]
assignees:
- DanielVolz
body:
- type: markdown
attributes:
-7
View File
@@ -1,8 +1 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discussions
url: https://github.com/DanielVolz/medassist-ng/discussions
about: Ask questions or share ideas in Discussions
- name: 📖 Documentation
url: https://github.com/DanielVolz/medassist-ng#readme
about: Check the README for setup and usage instructions
+2 -1
View File
@@ -1,5 +1,6 @@
name: Feature Request
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement", "triage"]
body:
- type: markdown
+62 -57
View File
@@ -15,11 +15,15 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
- **Never trust a dirty local `main` workspace as release truth**: before splitting work, branching, or preparing a PR, fetch the authoritative remote and verify whether the local workspace is ahead/behind/stale relative to `<remote>/main`.
- **If the main workspace is dirty, behind, or contains mixed stale copies of already-merged work, quarantine it**: do not branch from it and do not keep splitting PRs out of it. Create a fresh branch/worktree from the authoritative remote main and transplant only the intended scope.
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
@@ -48,15 +52,26 @@ This repository intentionally uses only two operational agents for CI/CD handoff
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
## GitHub CLI Safety (Non-Interactive Only)
## GitHub Operations (GitHub MCP + gh CLI Exception)
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
- 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>'`
- `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>'`
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
## Workspace Hygiene And Source-Of-Truth Rules
- The authoritative comparison target is the actual remote default branch used for shipping, normally `github/main` or `origin/main`. Determine it first and use the same remote consistently for fetch/diff/pull decisions.
- Before any PR split or branch creation, run a source-of-truth audit:
1. fetch the authoritative remote
2. inspect `git status`
3. compare local `main` against `<remote>/main`
4. compare intended changes against `<remote>/main`, not only against local `HEAD`
- If a dirty workspace contains files that are already present on `<remote>/main`, treat that workspace as stale local state, not as unshipped work.
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
---
@@ -121,10 +136,13 @@ When code changes (features or bug fixes) are complete:
### Step 1: Verify Readiness
1. Check for uncommitted changes: `git status`
2. Confirm testing has been completed by `@testing-manager`.
3. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
4. Only after local gate is confirmed, proceed to push/create PR and then monitor CI.
1. Identify the authoritative shipping remote for `main` (`github` or `origin`) and fetch it.
2. Check for uncommitted changes: `git status`.
3. Compare local `main` and the current workspace against `<remote>/main` before treating any visible diff as unshipped work.
4. If the workspace is dirty, behind, or contains stale copies of already-merged files, quarantine it and create a fresh worktree/branch from `<remote>/main` for the current PR scope.
5. Confirm testing has been completed by `@testing-manager`.
6. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
7. Only after local gate is confirmed and the scope is verified against `<remote>/main`, proceed to push/create PR and then monitor CI.
### Step 2: Create Feature Branch
@@ -132,11 +150,13 @@ When code changes (features or bug fixes) are complete:
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
- Chore: `chore/short-description`
2. Create and switch to the branch:
2. Create the branch from a clean base that matches `<remote>/main`. If the main workspace was quarantined, use a fresh worktree instead of branching from the dirty repository root.
3. Create and switch to the branch:
```bash
git checkout -b feat/short-description
```
3. Stage and commit changes with a conventional commit message:
4. Move only the intended scope into that branch/worktree. Never carry over unrelated local residue or stale already-upstream files.
5. Stage and commit changes with a conventional commit message:
```bash
git add .
git commit -m "fix: short description of what was fixed"
@@ -150,35 +170,24 @@ When code changes (features or bug fixes) are complete:
```bash
git push -u origin feat/short-description
```
3. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
```bash
gh pr create \
--title "fix: short description" \
--body "Closes #<ISSUE_NUMBER>
Description of changes" \
--assignee DanielVolz \
--label bug \
--project "@DanielVolz's MedAssist-ng project"
```
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
3. Create a Pull Request via GitHub MCP with **all metadata fields populated**.
- Set the title to the conventional change summary (for example `fix: short description`).
- Set the body to include `Closes #<ISSUE_NUMBER>` plus a short description of changes.
- Set assignee to `DanielVolz`.
- Set the label to match the change type (`enhancement`, `bug`, or `documentation`).
- Link the PR to `@DanielVolz's MedAssist-ng project`.
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
- The `--project` flag links the PR to the Project board.
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
4. **Present the PR URL to the user and wait for confirmation.**
### Step 4: Wait for CI and Merge
1. Monitor CI status:
```bash
gh pr checks <PR_NUMBER> --watch
```
1. Monitor CI status via GitHub MCP until all required checks complete.
Required checks: all repository-required checks must pass.
2. If CI fails: analyze the failure, fix it, push again, and re-check.
3. Once CI is green, **ask the user for merge confirmation**, then:
```bash
gh pr merge <PR_NUMBER> --squash --delete-branch
```
4. Switch back to main and pull:
3. Once CI is green, **ask the user for merge confirmation**, then merge the PR via GitHub MCP using squash merge and branch deletion.
4. Re-sync the authoritative local `main` before using it again as a source of truth for any next PR or release step. Do not continue from a previously dirty workspace without another source-of-truth audit.
5. Switch back to main and pull:
```bash
git checkout main
git pull origin main
@@ -247,6 +256,8 @@ The script performs these steps in order:
6. Merges the PR (squash + delete branch)
7. Creates a signed tag `vX.Y.Z` and pushes it
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
@@ -391,11 +402,18 @@ Existing installations need to:
### Step 3: Publish
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish via:
Publish the release via `gh` CLI:
```bash
gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE"
# Write notes to a temp file first, then:
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
# If the release was already auto-created (e.g. by pushing a tag), update it:
gh release edit vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
```
**Present the published release URL to the user for verification.**
---
## Task 5: README Update Check (MANDATORY for new features)
@@ -444,30 +462,17 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
### Workflow During PRs
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one:
```bash
gh issue create --title "fix: description" --label bug
```
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
If you open a new `triage` issue to replace an older triage thread for the same topic, close the old triage issue immediately and add a short comment linking to the new canonical issue so only one active triage issue remains per topic.
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
```bash
GH_PAGER=cat gh issue view <ISSUE_NUMBER> --json state,projectItems --jq '{state, projects: [.projectItems[] | {title: .title, status: .status.name}]}'
```
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify issue/project status via GitHub MCP.
**Manual fallback** — if the workflow fails or the item wasn't moved, use GraphQL:
```bash
GH_PAGER=cat gh api graphql -f query='mutation {
updateProjectV2ItemFieldValue(input: {
projectId: "PVT_kwHOADH82s4BO2OT"
itemId: "<ITEM_ID>"
fieldId: "PVTSSF_lAHOADH82s4BO2OTzg9bdkE"
value: { singleSelectOptionId: "ca45af98" }
}) { projectV2Item { id } }
}'
```
**Manual fallback** — if the workflow fails or the item wasn't moved, use GitHub MCP GraphQL/project mutation support with the project/item/field IDs below.
**Known Project field IDs (Status):**
| Status | Option ID |
+39 -6
View File
@@ -21,6 +21,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
- **Use GitHub MCP for all GitHub workflow/PR inspection. Never use `gh` CLI.** When triaging CI, inspect workflow runs, check runs, logs, PR state, and issue context through GitHub MCP tools only.
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
@@ -37,6 +38,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
- **Static quality gates**: TypeScript via `tsc --noEmit` and Biome via `npx biome check .`
Primary locations:
@@ -44,14 +46,24 @@ Primary locations:
- Frontend tests: `frontend/src/test/**`
- Playwright E2E: `frontend/e2e/**`
## Testing Strategy Defaults
- **Default to targeted validation, not shotgun runs**: start with the smallest test command that exercises the changed behavior.
- **Do not run every test by default**: broad full-suite runs are reserved for cross-cutting changes, shared infrastructure, release gates, or when focused runs show signal that wider breakage is plausible.
- **Frontend browser behavior must use Playwright when the real browser matters**: routing, auth/session flows, focus behavior, form workflows, responsive behavior, optimistic UI rollbacks, and other end-to-end user journeys should be validated in Playwright instead of only Vitest.
- **Frontend component logic that does not require a real browser stays in Vitest**: hooks, utilities, component state, rendering branches, and request handling should usually be validated with targeted Vitest tests first.
- **Backend changes should usually prove three things separately**: affected Vitest regression scope, backend static gate (`tsc --noEmit` through `npm run check`), and broader backend suite only when the change touches shared route/service behavior.
- **Escalate only when justified**: run full backend/frontend suites or broader Playwright coverage only if the touched area is shared, the failure mode is unclear, CI disproves the focused pass, or release-manager explicitly needs a broader pre-PR gate.
## Required Test Workflow
1. Identify changed behavior and expected outcomes.
2. Add/update tests near the affected feature.
3. Run the smallest relevant subset first.
4. Expand to broader suites if subset passes.
5. Run lint + required local test/build gates before PR handoff.
6. Report what was run, what passed, and any remaining known failures.
2. Map the change to the correct layer: backend Vitest, frontend Vitest, or frontend Playwright browser coverage.
3. Add/update tests near the affected feature.
4. Run the smallest relevant subset first.
5. Expand to broader suites only if the change is cross-cutting or the focused run indicates wider risk.
6. Run lint + required local test/build gates before PR handoff.
7. Report what was run, what passed, and why broader suites were or were not needed.
## Lint and Quality Gates
@@ -60,6 +72,7 @@ Primary locations:
- If lint fails, fix root causes first, then re-run affected tests.
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
- Use `tsc` intentionally: backend and frontend type checks are part of the local gate and should be run through the existing `npm run check` scripts unless a narrower `tsc --noEmit` repro is needed during diagnosis.
Recommended commands:
@@ -74,24 +87,36 @@ cd frontend && npm run check
### Backend
```bash
cd backend && npx tsc --version
cd backend && npx vitest --version
cd backend && CI=true npm run test:run -- src/test/doses.test.ts
cd backend && CI=true npm run test:run
cd backend && CI=true npm run test:coverage
cd backend && CI=true npm run test:run -- src/test/doses.test.ts src/test/integration.test.ts
cd backend && CI=true npm run test:run -- -t "test name"
```
### Frontend
```bash
cd frontend && npx tsc --version
cd frontend && npx vitest --version
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx
cd frontend && CI=true npm run test:run
cd frontend && CI=true npm run test:coverage
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx src/test/hooks/useDoses.test.ts
cd frontend && CI=true npm run test:run -- -t "test name"
cd frontend && npm run lint
cd frontend && npm run check
cd frontend && npm run build
```
### Playwright E2E
```bash
cd frontend && npx playwright --version
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --grep "schedule"
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- frontend/e2e/schedule.spec.ts
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
@@ -113,8 +138,16 @@ cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
- Use stable selectors and explicit assertions.
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
- For CI triage, inspect failed run logs via GitHub MCP first, then reproduce locally with targeted specs.
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
- Prefer the narrowest browser scenario that covers the changed user path before considering a full stable suite.
## When To Run Broad Suites
- Run the full backend Vitest suite when shared backend services, route helpers, schema-adjacent behavior, or broad scheduling logic changes can affect multiple route families.
- Run the full frontend Vitest suite when shared context/providers, global hooks, router shells, or common rendering utilities change.
- Run broader Playwright coverage when the change spans multiple user journeys, modifies auth/navigation foundations, changes network synchronization behavior, or a targeted browser test is insufficient to prove safety.
- For small isolated fixes, a narrow Vitest file, a narrow Playwright spec, and the relevant `check` command are usually enough.
## Test Validity Checklist
-4
View File
@@ -11,7 +11,6 @@ updates:
open-pull-requests-limit: 10
labels:
- "dependencies"
- "backend"
groups:
minor-and-patch:
update-types:
@@ -28,7 +27,6 @@ updates:
open-pull-requests-limit: 10
labels:
- "dependencies"
- "frontend"
groups:
minor-and-patch:
update-types:
@@ -45,7 +43,6 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
- "root"
groups:
minor-and-patch:
update-types:
@@ -62,7 +59,6 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
groups:
minor-and-patch:
update-types:
@@ -0,0 +1,27 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Mark and close stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: stale
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: pinned,security
operations-per-run: 200
+58 -15
View File
@@ -13,9 +13,18 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag (leave empty for "latest")'
description: 'Image/release tag (e.g. v1.19.1 or latest)'
required: false
default: ''
create_release:
description: 'Create GitHub release entry (requires tag starting with v)'
required: false
default: false
type: boolean
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
# Default minimal permissions
permissions:
@@ -54,10 +63,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -65,7 +74,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
tags: |
@@ -76,7 +85,7 @@ jobs:
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -89,12 +98,12 @@ jobs:
sbom: false
# =============================================================================
# Create GitHub Release (only on tag push)
# Create GitHub Release (on tag push or manual dispatch with create_release)
# =============================================================================
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
permissions:
contents: write
@@ -104,10 +113,31 @@ jobs:
with:
fetch-depth: 0 # Fetch all history for changelog generation
- name: Resolve current tag
id: current_tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CURRENT_TAG="${{ github.event.inputs.tag }}"
else
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
fi
if [ -z "$CURRENT_TAG" ]; then
echo "Release tag is required. Provide workflow_dispatch input 'tag'."
exit 1
fi
if [[ "$CURRENT_TAG" != v* ]]; then
echo "Release tag must start with 'v' (example: v1.19.1)."
exit 1
fi
echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
- name: Check if release exists
id: check_release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if gh release view "$CURRENT_TAG" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $CURRENT_TAG already exists, skipping creation"
@@ -121,25 +151,36 @@ jobs:
if: steps.check_release.outputs.exists == 'false'
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true)
else
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
fi
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
echo "## What's Changed" > changelog.md
echo "## What's New" > changelog.md
echo "" >> changelog.md
echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md
echo "" >> changelog.md
echo "### Highlights" >> changelog.md
echo "" >> changelog.md
if [ -n "$PREV_TAG" ]; then
# Get commits between tags
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md
else
# First release - get recent commits
git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Recent shipped commits:" >> changelog.md
git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md
fi
echo "" >> changelog.md
@@ -157,6 +198,8 @@ jobs:
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.current_tag.outputs.value }}
target_commitish: ${{ github.sha }}
body_path: changelog.md
generate_release_notes: false
draft: false
+19 -4
View File
@@ -3,18 +3,33 @@ name: E2E Tests
on:
pull_request:
branches: [main]
paths:
- 'frontend/**'
- 'backend/**'
- '.github/workflows/e2e.yml'
# Minimal permissions for security
permissions:
contents: read
jobs:
changes:
name: Detect E2E relevance
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
e2e_relevant:
- 'frontend/**'
- 'backend/**'
e2e:
name: Playwright E2E
needs: changes
if: needs.changes.outputs.e2e_relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
+144
View File
@@ -0,0 +1,144 @@
name: Sync Project Fields
on:
issues:
types: [opened, labeled, unlabeled, reopened]
permissions: {}
jobs:
sync-fields:
name: Sync Type/Priority fields from labels
runs-on: ubuntu-latest
steps:
- name: Sync fields
uses: actions/github-script@v8
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
const projectId = 'PVT_kwHOADH82s4BO2OT';
const issueNodeId = context.payload.issue.node_id;
const issueNumber = context.payload.issue.number;
const labels = (context.payload.issue.labels || []).map(l => l.name.toLowerCase());
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const getProjectItem = async () => {
const data = await github.graphql(`
query($nodeId: ID!) {
node(id: $nodeId) {
... on Issue {
projectItems(first: 20) {
nodes {
id
project { id }
}
}
}
}
}
`, { nodeId: issueNodeId });
const items = data.node?.projectItems?.nodes || [];
return items.find(item => item.project.id === projectId) || null;
};
let projectItem = await getProjectItem();
// add-to-project may run in parallel; retry briefly before giving up
for (let i = 0; !projectItem && i < 6; i++) {
console.log(`Issue #${issueNumber} not in project yet. Retry ${i + 1}/6...`);
await sleep(10000);
projectItem = await getProjectItem();
}
if (!projectItem) {
console.log(`Issue #${issueNumber} is not in project board. Skipping field sync.`);
return;
}
const fieldsData = await github.graphql(`
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
`, { projectId });
const singleSelectFields = fieldsData.node?.fields?.nodes || [];
const byName = new Map(singleSelectFields.map(f => [f.name, f]));
const typeField = byName.get('Type');
const priorityField = byName.get('Priority');
if (!typeField && !priorityField) {
console.log('Neither Type nor Priority field found. Nothing to update.');
return;
}
const pickOptionId = (field, optionName) => {
if (!field || !optionName) return null;
const opt = (field.options || []).find(o => o.name.toLowerCase() === optionName.toLowerCase());
return opt?.id || null;
};
let typeName = null;
if (labels.includes('bug')) typeName = 'Bug';
else if (labels.includes('enhancement')) typeName = 'Feature';
else if (labels.includes('documentation')) typeName = 'Documentation';
let priorityName = null;
if (labels.includes('priority/high')) priorityName = 'High';
else if (labels.includes('priority/low')) priorityName = 'Low';
else if (labels.includes('priority/medium')) priorityName = 'Medium';
else if (labels.includes('triage')) priorityName = 'Medium';
const updates = [];
const typeOptionId = pickOptionId(typeField, typeName);
if (typeField && typeOptionId) {
updates.push({ fieldId: typeField.id, optionId: typeOptionId, fieldName: 'Type', valueName: typeName });
}
const priorityOptionId = pickOptionId(priorityField, priorityName);
if (priorityField && priorityOptionId) {
updates.push({ fieldId: priorityField.id, optionId: priorityOptionId, fieldName: 'Priority', valueName: priorityName });
}
for (const update of updates) {
await github.graphql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`, {
projectId,
itemId: projectItem.id,
fieldId: update.fieldId,
optionId: update.optionId
});
console.log(`Issue #${issueNumber}: set ${update.fieldName} = ${update.valueName}`);
}
if (updates.length === 0) {
console.log(`Issue #${issueNumber}: no matching field updates for labels [${labels.join(', ')}]`);
}
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -0,0 +1,77 @@
name: Weekly Triage Report
on:
schedule:
- cron: '0 7 * * 1'
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
weekly-report:
runs-on: ubuntu-latest
steps:
- name: Build weekly summary
id: summary
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const weekLabel = new Date().toISOString().split('T')[0];
const q = async (query) => {
const res = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 });
return res.data.total_count;
};
const openIssues = await q(`repo:${owner}/${repo} is:issue is:open`);
const newIssues = await q(`repo:${owner}/${repo} is:issue created:>=${since}`);
const bugs = await q(`repo:${owner}/${repo} is:issue is:open label:bug`);
const enhancements = await q(`repo:${owner}/${repo} is:issue is:open label:enhancement`);
const triage = await q(`repo:${owner}/${repo} is:issue is:open label:triage`);
const stale = await q(`repo:${owner}/${repo} is:issue is:open label:stale`);
const unassigned = await q(`repo:${owner}/${repo} is:issue is:open no:assignee`);
const body = [
`## Weekly Triage Report (${weekLabel})`,
'',
`- Open issues: **${openIssues}**`,
`- New issues (last 7 days): **${newIssues}**`,
`- Open bugs: **${bugs}**`,
`- Open enhancements: **${enhancements}**`,
`- In triage: **${triage}**`,
`- Stale: **${stale}**`,
`- Unassigned: **${unassigned}**`,
'',
'### Quick Links',
`- Triage queue: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Atriage`,
`- Stale issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Astale`,
`- Unassigned issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`,
].join('\n');
core.setOutput('title', `Weekly Triage Report - ${weekLabel}`);
core.setOutput('body', body);
- name: Publish report issue
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const title = `${{ steps.summary.outputs.title }}`;
const body = `${{ steps.summary.outputs.body }}`;
await github.rest.issues.create({
owner,
repo,
title,
body,
labels: ['triage']
});
+6 -2
View File
@@ -82,5 +82,9 @@ Thumbs.db
.claude/
AGENTS.md
docs/TECH_STACK.md
doku
plan
doku/
doku/memory_notes.md
doku/report.md
plan/
.copilot-tracking/
.playwright-cli/
+87 -48
View File
@@ -1,49 +1,88 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "E2E stable",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:with-video"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all:with-video"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
}
]
}
"version": "2.0.0",
"tasks": [
{
"label": "E2E stable",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable + merged video",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:all"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers + merged video",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:all:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable non-interactive",
"type": "shell",
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
"isBackground": false,
"group": "test"
},
{
"label": "Targeted frontend vitest",
"type": "shell",
"command": "cd frontend && npm test -- --run src/test/context/AppContext.test.tsx src/test/utils/schedule.test.ts",
"isBackground": false,
"group": "test"
},
{
"label": "Focused frontend shared schedule test",
"type": "shell",
"command": "cd frontend && npm run test:run -- --maxWorkers=1 src/test/components/SharedSchedule.test.tsx",
"isBackground": false,
"group": "test"
},
{
"label": "PR3 targeted validation",
"type": "shell",
"command": "git --no-pager diff --check -- .github/agents/release-manager.agent.md .github/agents/testing-manager.agent.md .gitignore .vscode/tasks.json && node -e \"JSON.parse(require('fs').readFileSync('.vscode/tasks.json','utf8')); console.log('tasks.json valid')\"",
"isBackground": false
}
]
}
+75 -24
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-577%2F577-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-833%2F833-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
@@ -119,11 +119,17 @@ Share your medication schedule with others via a public link.
</blockquote>
</details>
### Medication Setup
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`
- Explicit review-and-apply flow with low-risk suggestions only
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
### Smart Inventory
- Track exact stock: packs, blisters, bottles, and loose pills
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
- Display remaining days of supply
- Automatic calculation based on intake schedule
- Manual stock correction supports partial blisters and loose pills
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
### Medication Refill
- One-click refill with pack or loose pill options
@@ -141,7 +147,7 @@ Share your medication schedule with others via a public link.
- Intake reminders via push notifications
### Trip Planner
- Calculate how many pills you need for a trip or date range
- Calculate medication demand for a trip or date range with package-aware units
- Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
@@ -152,6 +158,7 @@ Share your medication schedule with others via a public link.
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
@@ -177,7 +184,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
git clone https://github.com/DanielVolz/medassist-ng.git
cd medassist-ng
cp .env.example .env
docker compose up -d
docker compose -p medassist-ng up -d
```
Open `http://localhost:4174` and start tracking your medications.
@@ -195,8 +202,23 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
Recommended values for API docs by environment:
| Environment | Recommendation |
|-------------|----------------|
| Development | `OPENAPI_DOCS_ENABLED=true` |
| Staging/Test | `OPENAPI_DOCS_ENABLED=true` |
| Production | leave it unset, or set `OPENAPI_DOCS_ENABLED=false` |
Notes:
- If `OPENAPI_DOCS_ENABLED` is not set, docs are enabled outside production and disabled in production.
- If `OPENAPI_DOCS_ENABLED=true`, docs are available on `/docs` and `/docs/json`.
- If `OPENAPI_DOCS_ENABLED=false`, only the docs are disabled. The API still works normally.
### Authentication
| Variable | Default | Description |
@@ -211,6 +233,43 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
Generate secrets with: `openssl rand -hex 32`
### API Keys (Programmatic API Access)
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
```bash
Authorization: Bearer ma_...
```
Available scopes:
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
- `write`: read + write access
Essential notes:
- Create keys in the app when authentication is enabled.
- The token is shown only once after creation.
- Creating a new key automatically deactivates previously active keys for the same user.
- API keys are stored hashed in the database.
Example usage:
```bash
curl http://localhost:3000/settings \
-H "Authorization: Bearer ma_..."
```
API reference:
- Interactive docs: `/docs`
- OpenAPI JSON: `/docs/json`
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
- Key management endpoints for authenticated users:
- `GET /auth/api-keys`
- `POST /auth/api-keys`
- `DELETE /auth/api-keys/:id`
### OIDC / SSO
| Variable | Default | Description |
@@ -267,9 +326,9 @@ Configure push notifications in Settings → Push, or set defaults via environme
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
Complete list and details:
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
#### URL Examples
@@ -309,30 +368,22 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
# Development
```bash
docker compose -f docker-compose.dev.yml up
docker compose -p medassist-dev -f docker-compose.dev.yml up
```
- Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
Playwright E2E recommendations:
Useful local commands:
```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
npm run lint
cd backend && npm run test:run
cd frontend && npm run test:run
```
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
- Data-heavy specs remain sequential via the `chromium-data` project config.
# Dependency Updates
- Dependabot checks dependencies weekly for `frontend`, `backend`, repository root tooling, and GitHub Actions.
- Minor and patch updates are grouped to reduce PR noise.
- Dependabot minor/patch PRs are configured for auto-merge after required CI checks pass.
- Major updates still require manual review before merge.
# Acknowledgements
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
@@ -0,0 +1,18 @@
CREATE TABLE `api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`key_hash` text(128) NOT NULL,
`token_prefix` text(24) DEFAULT '' NOT NULL,
`scope` text(10) DEFAULT 'write' NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`last_used_at` integer,
`expires_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -76,14 +76,28 @@
"idx": 10,
"version": "6",
"when": 1771694832866,
"tag": "0010_mean_spot",
"tag": "0010_add_dose_tracking_taken_source",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1772219947541,
"tag": "0011_stiff_randall_flagg",
"tag": "0011_add_medication_form_lifecycle_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1772881208026,
"tag": "0012_add_api_keys_and_package_amount_columns",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1773348659979,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
}
]
+745 -401
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.18.0",
"version": "1.21.0",
"private": true,
"type": "module",
"scripts": {
@@ -25,22 +25,24 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@libsql/client": "^0.17.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.7.4",
"nodemailer": "^8.0.1",
"fastify": "^5.8.2",
"nodemailer": "^8.0.2",
"openid-client": "^6.8.2",
"sharp": "^0.34.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@types/node": "^25.3.2",
"@biomejs/biome": "^2.4.7",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/coverage-v8": "^4.1.0",
"drizzle-kit": "^0.31.9",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
+31 -6
View File
@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import {
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getScheduleMatchWindowMs,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
@@ -149,6 +155,8 @@ 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 integrated share overview visibility on shared links
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
// Added for timeline visibility toggles (dashboard + shared schedule)
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
@@ -189,7 +197,21 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
)`,
// Added in v1.20.x - API key authentication for programmatic access
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'write',
is_active INTEGER NOT NULL DEFAULT 1,
last_used_at INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
@@ -207,6 +229,9 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
const createIndexMigrations = [
// Added in v1.6.x - case-insensitive unique usernames
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
// Added in v1.20.x - fast API key lookup and ownership filtering
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
];
for (const sql of createIndexMigrations) {
@@ -344,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>();
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
});
validDatesByIntake.set(idx, validDates);
}
@@ -369,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
const intake = intakes[intakeIdx];
if (!intake) continue;
const halfInterval = (intake.every * MS_PER_DAY) / 2;
const halfInterval = getScheduleMatchWindowMs(intake);
let bestMatch: number | null = null;
let bestDist = Infinity;
+21
View File
@@ -109,6 +109,8 @@ 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),
// Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
@@ -146,6 +148,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// API Keys - Personal access tokens for programmatic API access
// =============================================================================
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
keyHash: text("key_hash", { length: 128 }).notNull().unique(),
tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""),
scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write'
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Share Tokens - For public schedule sharing by takenBy person
// =============================================================================
+75 -1
View File
@@ -10,14 +10,18 @@ import fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import fastifyStatic from "@fastify/static";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { getDataDir } from "./db/db-utils.js";
import { env } from "./plugins/env.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
@@ -26,7 +30,9 @@ import { reportRoutes } from "./routes/report.js";
import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
// Re-export utilities from server-config for external use
export {
@@ -58,12 +64,13 @@ function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
}
function buildLoggerOptions(level: string) {
const runtimeEnv = process.env.NODE_ENV ?? "production";
const base = {
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
};
// Human-readable logs in development, structured JSON in production/test
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") {
if (runtimeEnv === "development") {
return {
...base,
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
@@ -72,6 +79,56 @@ function buildLoggerOptions(level: string) {
return base;
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "MedAssist-ng API",
description: "MedAssist-ng backend API",
version: process.env.npm_package_version ?? "dev",
},
servers: [{ url: "/", description: "Current server" }],
tags: [
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key or JWT",
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "access_token",
description: "Session cookie set by login.",
},
},
},
},
hideUntagged: false,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
staticCSP: true,
transformSpecificationClone: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
@@ -84,6 +141,7 @@ export async function createApp(options?: {
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
openApiDocsEnabled?: boolean;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
@@ -96,11 +154,13 @@ export async function createApp(options?: {
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
};
const app = Fastify({
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
@@ -132,6 +192,7 @@ export async function createApp(options?: {
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await registerApiDocs(app, opts.openApiDocsEnabled);
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
@@ -145,8 +206,10 @@ export async function createApp(options?: {
// Register routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
@@ -174,6 +237,7 @@ const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: buildLoggerOptions(env.LOG_LEVEL),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
@@ -215,6 +279,7 @@ const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
await app.register(fastifyStatic, {
root: imagesDir,
prefix: "/images/",
@@ -223,8 +288,10 @@ await app.register(fastifyStatic, {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
@@ -245,6 +312,13 @@ const start = async () => {
error: (msg) => app.log.error(msg),
});
startMedicationEnrichmentCatalogRefresh({
info: (msg: string) => app.log.info(msg),
debug: (msg: string) => app.log.debug(msg),
warn: (msg: string) => app.log.warn(msg),
error: (msg: string) => app.log.error(msg),
});
// Start the intake reminder scheduler (checks every minute)
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
+121 -3
View File
@@ -1,7 +1,8 @@
import { count, eq, sql } from "drizzle-orm";
import { pbkdf2Sync } from "node:crypto";
import { and, count, eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { apiKeys, users } from "../db/schema.js";
import { env } from "./env.js";
// =============================================================================
@@ -82,6 +83,84 @@ export interface RequestUser {
username: string;
}
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function isMutationMethod(method: string): boolean {
return !READ_ONLY_METHODS.has(method.toUpperCase());
}
function getApiKeyPepper(): string {
return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper";
}
export function hashApiKeyToken(token: string): string {
return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex");
}
function getBearerToken(request: FastifyRequest): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [scheme, value] = authHeader.split(" ");
if (!scheme || !value) return null;
if (scheme.toLowerCase() !== "bearer") return null;
const token = value.trim();
return token.length > 0 ? token : null;
}
async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const bearerToken = getBearerToken(request);
if (!bearerToken) return false;
if (!bearerToken.startsWith("ma_")) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" });
throw new Error("API_KEY_EXPIRED");
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (!user || !user.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
const scope = keyRow.scope === "read" ? "read" : "write";
if (scope === "read" && isMutationMethod(request.method)) {
reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" });
throw new Error("API_KEY_SCOPE_FORBIDDEN");
}
request.user = { id: user.id, username: user.username };
request.authContext = {
method: "api_key",
scope,
apiKeyId: keyRow.id,
};
await db
.update(apiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id)));
return true;
}
// =============================================================================
// Auth Middleware Functions
// =============================================================================
@@ -94,6 +173,28 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
return;
}
const bearerToken = getBearerToken(request);
if (bearerToken?.startsWith("ma_")) {
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) return;
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return;
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (userByKey?.isActive) {
request.user = { id: userByKey.id, username: userByKey.username };
request.authContext = {
method: "api_key",
scope: keyRow.scope === "read" ? "read" : "write",
apiKeyId: keyRow.id,
};
}
return;
}
const token = request.cookies.access_token;
if (!token) {
return;
@@ -107,6 +208,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
}
} catch {
// Invalid token, continue as anonymous
@@ -121,6 +226,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
return;
}
if (await tryApiKeyAuth(request, reply)) {
return;
}
const token = request.cookies.access_token;
if (!token) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
@@ -145,11 +254,20 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
} catch (err: unknown) {
// Re-throw our own errors
if (
err instanceof Error &&
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
(err.message === "AUTH_REQUIRED" ||
err.message === "USER_NOT_FOUND" ||
err.message === "ACCOUNT_DISABLED" ||
err.message === "INVALID_API_KEY" ||
err.message === "API_KEY_EXPIRED" ||
err.message === "API_KEY_SCOPE_FORBIDDEN")
) {
throw err;
}
+14 -3
View File
@@ -14,6 +14,10 @@ const EnvSchema = z.object({
.default("3000"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
OPENAPI_DOCS_ENABLED: z
.string()
.transform((v) => v === "true")
.optional(),
// ==========================================================================
// Auth Configuration
@@ -69,10 +73,13 @@ const EnvSchema = z.object({
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
export type Env = z.infer<typeof EnvSchema>;
type ParsedEnv = z.infer<typeof EnvSchema>;
export type Env = ParsedEnv & {
OPENAPI_DOCS_ENABLED: boolean;
};
// Parse and validate
let parsed: z.infer<typeof EnvSchema>;
let parsed: ParsedEnv;
try {
parsed = EnvSchema.parse(process.env);
} catch (err) {
@@ -154,4 +161,8 @@ if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
);
}
export const env = parsed;
export const env: Env = {
...parsed,
// Docs UI/spec are enabled in non-production by default.
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
};
+302
View File
@@ -0,0 +1,302 @@
import { randomBytes } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { apiKeys } from "../db/schema.js";
import { hashApiKeyToken, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const createApiKeySchema = z.object({
name: z.string().trim().min(3).max(100),
scope: z.enum(["read", "write"]).default("write"),
expiresInDays: z.number().int().min(1).max(3650).optional(),
});
const idParamSchema = z.object({
id: z.string().regex(/^\d+$/),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const genericErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
const apiKeyMetadataSchema = {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
tokenPrefix: { type: "string" },
scope: { type: "string", enum: ["read", "write"] },
isActive: { type: "boolean" },
lastUsedAt: { type: ["string", "null"], format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
updatedAt: { type: ["string", "null"], format: "date-time" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function serializeApiKeyMetadata<
T extends {
id: number;
name: string;
tokenPrefix: string;
scope: string;
isActive: boolean;
lastUsedAt: unknown;
expiresAt: unknown;
createdAt: unknown;
updatedAt: unknown;
},
>(key: T) {
return {
id: key.id,
name: key.name,
tokenPrefix: key.tokenPrefix,
scope: key.scope,
isActive: key.isActive,
lastUsedAt: normalizeDateTime(key.lastUsedAt),
expiresAt: normalizeDateTime(key.expiresAt),
createdAt: normalizeDateTime(key.createdAt),
updatedAt: normalizeDateTime(key.updatedAt),
};
}
export async function apiKeyRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
app.get(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "List API keys for the current user",
description: "Returns API key metadata. Raw API key tokens are never returned.",
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
keys: {
type: "array",
items: apiKeyMetadataSchema,
},
},
},
400: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, authUser.id))
.orderBy(desc(apiKeys.createdAt));
return { keys: keys.map(serializeApiKeyMetadata) };
}
);
app.post<{ Body: z.infer<typeof createApiKeySchema> }>(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "Create and rotate API key",
description:
"Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.",
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 3, maxLength: 100 },
scope: { type: "string", enum: ["read", "write"], default: "write" },
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
},
example: {
name: "Home Assistant integration",
scope: "write",
expiresInDays: 365,
},
},
response: {
201: {
type: "object",
properties: {
key: apiKeyMetadataSchema,
token: { type: "string" },
note: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsed = createApiKeySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send(parsed.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const { name, scope, expiresInDays } = parsed.data;
const rawToken = `ma_${randomBytes(32).toString("hex")}`;
const tokenPrefix = `${rawToken.slice(0, 12)}...`;
const keyHash = hashApiKeyToken(rawToken);
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
// Keep a single active key per user: creating a new key invalidates old ones.
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true)));
const [created] = await db
.insert(apiKeys)
.values({
userId: authUser.id,
name,
keyHash,
tokenPrefix,
scope,
expiresAt,
})
.returning({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
});
return reply.status(201).send({
key: serializeApiKeyMetadata(created),
token: rawToken,
note: "Store this token now. It cannot be retrieved again.",
});
}
);
app.delete<{ Params: { id: string } }>(
"/auth/api-keys/:id",
{
schema: {
tags: ["api-keys"],
summary: "Deactivate API key",
description: "Deactivates one API key belonging to the current user.",
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", pattern: "^\\d+$" },
},
},
response: {
204: { type: "null" },
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsedParams = idParamSchema.safeParse(request.params);
if (!parsedParams.success) {
return reply.status(400).send(parsedParams.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keyId = Number(parsedParams.data.id);
const [existing] = await db
.select({ id: apiKeys.id, userId: apiKeys.userId })
.from(apiKeys)
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
if (!existing) {
return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" });
}
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
return reply.status(204).send();
}
);
}
+268 -24
View File
@@ -85,6 +85,38 @@ const updateProfileSchema = z.object({
.optional(),
});
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
const authErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
// =============================================================================
// Auth Routes
// =============================================================================
@@ -99,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
app.get(
"/auth/state",
{
config: { rateLimit: false },
schema: {
tags: ["auth"],
summary: "Get authentication state",
description: "Returns auth and login mode state before user login.",
response: {
200: {
type: "object",
properties: {
authEnabled: { type: "boolean" },
registrationEnabled: { type: "boolean" },
formLoginEnabled: { type: "boolean" },
oidcEnabled: { type: "boolean" },
hasUsers: { type: "boolean" },
oidcProviderName: { type: "string" },
},
},
},
},
},
async () => {
return getAuthState();
}
);
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
@@ -110,6 +166,40 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Register local user",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string", minLength: 3, maxLength: 50 },
password: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
},
},
response: {
201: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
},
},
message: { type: "string" },
},
},
400: authErrorSchema,
409: authErrorSchema,
},
},
},
async (request, reply) => {
// Check auth state
@@ -157,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
})
.returning();
app.log.info(`User registered: ${username}`);
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
return reply.status(201).send({
ok: true,
@@ -177,6 +267,42 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Login with username and password",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" },
rememberMe: { type: "boolean" },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
rememberMe: true,
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
},
},
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const state = await getAuthState();
@@ -250,7 +376,7 @@ export async function authRoutes(app: FastifyInstance) {
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
app.log.info(`[Auth] Login succeeded: username=${user.username}, userId=${user.id}, rememberMe=${rememberMe}`);
// Cookie options: with maxAge for "remember me", without for session cookie
const accessCookieOptions = rememberMe
@@ -281,6 +407,15 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Refresh access token",
description: "Requires refresh token cookie context.",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -350,6 +485,13 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/logout",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Logout and clear auth cookies",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -375,26 +517,56 @@ export async function authRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/me - Get current user profile
// ---------------------------------------------------------------------------
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
app.get(
"/auth/me",
{
preHandler: requireAuth,
schema: {
tags: ["auth"],
summary: "Get current user profile",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
authProvider: { type: "string" },
createdAt: { type: "string", format: "date-time" },
lastLoginAt: { type: ["string", "null"], format: "date-time" },
},
},
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
};
});
const createdAt =
normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString();
const lastLoginAt = normalizeDateTime(user.lastLoginAt);
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider ?? "local",
createdAt,
lastLoginAt,
};
}
);
// ---------------------------------------------------------------------------
// PUT /auth/me - Update current user profile
@@ -404,6 +576,34 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Update current user profile",
security: authEndpointSecurity,
body: {
type: "object",
properties: {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
currentPassword: "current-password",
newPassword: "new-strong-password",
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -462,6 +662,24 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Upload user avatar",
description: "Uploads and optimizes a profile image using multipart/form-data.",
security: authEndpointSecurity,
consumes: ["multipart/form-data"],
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
avatarUrl: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -517,6 +735,16 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete user avatar",
security: authEndpointSecurity,
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -547,6 +775,22 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete current user account",
description: "Deletes the current account and related data (cascade delete).",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -563,7 +807,7 @@ export async function authRoutes(app: FastifyInstance) {
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
// Clear auth cookies
return reply
+393 -102
View File
@@ -2,11 +2,23 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, shareTokens } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js";
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import {
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// =============================================================================
// Validation Schemas
@@ -23,12 +35,31 @@ const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
}
const doseReadResponseSchema = {
type: "object",
properties: {
doses: {
type: "array",
items: {
type: "object",
properties: {
doseId: { type: "string" },
takenAt: { type: "number" },
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
},
},
},
},
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
@@ -125,43 +156,145 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
}
if (!parsedDose.personSuffix) {
return true;
return intake.takenBy === null;
}
return expectedPersons.includes(parsedDose.personSuffix);
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
stockCalculationMode: "automatic" | "manual";
}): Promise<boolean> {
const parsedDose = parseDoseId(options.doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
if (!medication) {
return false;
}
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
const intake = intakes[parsedDose.intakeIndex];
const scheduledOccurrenceMs = intake
? (() => {
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
).getTime();
})()
: parsedDose.timestampMs;
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
return (
computeMedicationCurrentStock({
medication,
doses,
stockCalculationMode: options.stockCalculationMode,
nowMs: stockBeforeDoseMs,
}) <= 0
);
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
});
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// Suppress request logs — polled every 5s by frontend
// ---------------------------------------------------------------------------
app.get("/doses/taken", { preHandler: requireAuth, logLevel: "warn" }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/doses/taken",
{
preHandler: requireAuth,
logLevel: "warn",
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: doseReadResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
});
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -184,6 +317,16 @@ export async function doseRoutes(app: FastifyInstance) {
return { success: true, message: "Already marked" };
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const outOfStock = await isDoseOutOfStock({
userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record
await db.insert(doseTracking).values({
userId,
@@ -201,7 +344,24 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -230,7 +390,33 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseIds: { type: "array", items: { type: "string" } },
},
example: {
doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"],
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
dismissedCount: { type: "integer" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -267,6 +453,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
@@ -280,61 +467,123 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.delete(
"/doses/dismiss",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
clearedCount: { type: "integer" },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
for (const d of dismissed) {
if (d.markedBy !== null || d.takenAt) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
for (const d of dismissed) {
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt);
if (d.markedBy !== null || hasRealTakenTimestamp) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
}
}
}
return { success: true, clearedCount: dismissed.length };
});
return { success: true, clearedCount: dismissed.length };
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// Suppress request logs — polled every 5s by SharedSchedule
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token/doses", { logLevel: "warn" }, async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
response: {
200: doseReadResponseSchema,
404: genericErrorSchema,
},
},
logLevel: "warn",
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
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");
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
});
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
@@ -349,14 +598,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -368,20 +617,38 @@ 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})`);
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
// Insert new record - marked by the takenBy person
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const outOfStock = await isDoseOutOfStock({
userId: share.userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
const parsedShareDose = parseDoseId(doseId);
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
await db.insert(doseTracking).values({
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
markedBy,
takenSource: "manual",
});
request.log.info(
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -391,40 +658,64 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
const { token, doseId } = request.params;
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
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 { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
await db
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
return { success: true };
}
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()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (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 };
});
);
}
+349 -233
View File
@@ -10,14 +10,20 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.3";
const EXPORT_VERSION = "1.4";
// =============================================================================
// Zod Schemas for Import Validation
@@ -27,6 +33,8 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
@@ -39,7 +47,7 @@ const inventorySchema = z.object({
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister"),
packageType: z.enum(PACKAGE_TYPES).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
@@ -130,6 +138,7 @@ const settingsExportSchema = z
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareStockStatus: z.boolean().default(true),
shareMedicationOverview: z.boolean().default(false),
})
.optional();
@@ -144,6 +153,69 @@ const importDataSchema = z.object({
shareLinks: z.array(shareLinkSchema).default([]),
});
const exportQuerystringSchema = {
type: "object",
properties: {
includeSensitive: { type: "string", enum: ["true", "false"] },
includeImages: { type: "string", enum: ["true", "false"] },
},
} as const;
const exportResponseSchema = {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
const importBodyOpenApiSchema = {
type: "object",
required: ["version", "exportedAt"],
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
{
name: "Ibuprofen 400",
packageType: "box",
packCount: 1,
looseTablets: 8,
intakes: [
{
usage: 1,
every: 8,
start: "2026-03-11T08:00:00.000Z",
takenBy: "Daniel",
remind: true,
},
],
},
],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
@@ -167,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
scheduleMode: "interval" | "weekdays";
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
@@ -182,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: intake.usage,
every: intake.every,
start: intake.start,
intakeUnit: null,
scheduleMode: intake.scheduleMode ?? "interval",
weekdays: intake.weekdays ?? [],
intakeUnit: intake.intakeUnit ?? null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -271,243 +347,257 @@ function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: nu
export async function exportRoutes(app: FastifyInstance) {
// All export routes require auth
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "export", protectedByDefault: true });
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
"/export",
{
schema: {
querystring: exportQuerystringSchema,
response: {
200: exportResponseSchema,
401: genericErrorSchema,
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
});
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
}
: undefined;
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
}
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
});
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
});
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
@@ -520,6 +610,29 @@ export async function exportRoutes(app: FastifyInstance) {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024, // 50 MB
schema: {
body: importBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
imported: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
settings: { type: "integer" },
shareLinks: { type: "integer" },
},
},
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -564,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules to both legacy and new formats
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy)
const intakesJson = JSON.stringify(
med.schedules.map((s) => ({
usage: s.usage,
every: s.every,
start: s.start,
intakeUnit: s.intakeUnit ?? null,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
const intakesJson = JSON.stringify(normalizedSchedules);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await db
.insert(medications)
@@ -595,7 +710,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: med.inventory.packageType ?? "blister",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
@@ -688,6 +803,7 @@ export async function exportRoutes(app: FastifyInstance) {
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
+27 -5
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { FastifyInstance } from "fastify";
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
// Read version from package.json at startup
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,10 +11,31 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const backendVersion = packageJson.version || "unknown";
export async function healthRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
app.get("/health", { config: { rateLimit: false }, logLevel: "warn" }, async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
}));
app.get(
"/health",
{
config: { rateLimit: false },
logLevel: "warn",
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string", enum: ["ok"] },
version: { type: "string" },
smtpConfigured: { type: "boolean" },
},
},
},
},
},
async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
})
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { z } from "zod";
import { requireAuth } from "../plugins/auth.js";
import {
enrichMedicationSelection,
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
type MedicationEnrichmentEnrichRequest,
MedicationEnrichmentServiceError,
searchMedicationEnrichment,
} from "../services/medication-enrichment.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const searchQuerySchema = z.object({
q: z.string().trim().min(1).max(120),
limit: z.coerce
.number()
.int()
.min(1)
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
});
const enrichBodySchema = z.object({
query: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(140),
genericName: z.string().trim().max(140).nullable().optional(),
code: z.string().trim().min(1).max(160).nullable().optional(),
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
});
const searchQueryOpenApiSchema = {
type: "object",
required: ["q"],
properties: {
q: { type: "string", minLength: 1, maxLength: 120 },
limit: {
anyOf: [
{ type: "string", pattern: "^[0-9]+$" },
{
type: "integer",
minimum: 1,
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
},
],
},
},
} as const;
const enrichBodyOpenApiSchema = {
type: "object",
required: ["query", "name"],
properties: {
query: { type: "string", minLength: 1, maxLength: 120 },
name: { type: "string", minLength: 1, maxLength: 140 },
genericName: { type: "string", nullable: true, maxLength: 140 },
code: { type: "string", nullable: true, maxLength: 160 },
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
},
} as const;
const strengthOptionSchema = {
type: "object",
properties: {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
},
},
} as const;
const searchResponseSchema = {
type: "object",
properties: {
query: { type: "string" },
normalizedQuery: { type: "string" },
hasMore: { type: "boolean" },
results: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
genericName: { type: "string", nullable: true },
authorisationHolder: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
matchType: { type: "string", enum: ["brand", "ingredient"] },
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
authorisationDate: { type: "string", nullable: true },
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
},
},
},
},
} as const;
const enrichResponseSchema = {
type: "object",
properties: {
selection: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
indication: { type: "string", nullable: true },
atcCode: { type: "string", nullable: true },
source: {
type: "string",
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
},
},
},
suggestions: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
medicationForm: {
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
},
strengthOptions: { type: "array", items: strengthOptionSchema },
},
},
meta: {
type: "object",
properties: {
rxNormMatched: { type: "boolean" },
openFdaMatched: { type: "boolean" },
partial: { type: "boolean" },
note: { type: "string", nullable: true },
},
},
},
} as const;
function sendServiceError(error: unknown, reply: FastifyReply) {
if (error instanceof MedicationEnrichmentServiceError) {
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
}
return reply.status(503).send({
error: "Medication enrichment request failed.",
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
});
}
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
app.get(
"/medication-enrichment/search",
{
schema: {
querystring: searchQueryOpenApiSchema,
response: {
200: searchResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = searchQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Search request failed"
);
return sendServiceError(error, reply);
}
}
);
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
"/medication-enrichment/enrich",
{
schema: {
body: enrichBodyOpenApiSchema,
response: {
200: enrichResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = enrichBodySchema.safeParse(request.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await enrichMedicationSelection(parsed.data, request.log);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Enrich request failed"
);
return sendServiceError(error, reply);
}
}
);
}
File diff suppressed because it is too large Load Diff
+72 -42
View File
@@ -5,6 +5,7 @@ import * as client from "openid-client";
import { db } from "../db/client.js";
import { refreshTokens, users } from "../db/schema.js";
import { env } from "../plugins/env.js";
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
// =============================================================================
// OIDC Configuration Cache
@@ -49,12 +50,14 @@ function getFrontendUrl(): string {
// OIDC Routes
// =============================================================================
export async function oidcRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, { tag: "auth", protectedByDefault: false });
if (!env.OIDC_ENABLED) {
// Register a disabled route that returns an error
app.get("/auth/oidc/login", async (_request, reply) => {
app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
app.get("/auth/oidc/callback", async (_request, reply) => {
app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
return;
@@ -63,58 +66,85 @@ export async function oidcRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/oidc/login - Initiates OIDC flow
// ---------------------------------------------------------------------------
app.get("/auth/oidc/login", async (request, reply) => {
try {
const config = await getOIDCConfig();
app.get(
"/auth/oidc/login",
{
schema: {
response: {
302: { type: "null", description: "Redirect to OIDC provider" },
500: genericErrorSchema,
},
},
},
async (request, reply) => {
try {
const config = await getOIDCConfig();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
}
}
});
);
// ---------------------------------------------------------------------------
// GET /auth/oidc/callback - Handles callback from OIDC provider
// ---------------------------------------------------------------------------
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
"/auth/oidc/callback",
{
schema: {
querystring: {
type: "object",
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
response: {
302: { type: "null", description: "Redirect back to frontend" },
},
},
},
async (request, reply) => {
const { code, state, error, error_description } = request.query;
@@ -208,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Set cookies (use app's centralized cookie options)
request.log.debug(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
File diff suppressed because it is too large Load Diff
+258 -112
View File
@@ -6,6 +6,13 @@ import { medications, refillHistory } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
const refillSchema = z
.object({
@@ -17,9 +24,72 @@ const refillSchema = z
message: "Must add at least one pack or some loose pills",
});
const refillBodyOpenApiSchema = {
type: "object",
properties: {
packsAdded: { type: "integer", minimum: 0, default: 0 },
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
usePrescription: { type: "boolean", default: false },
},
description: "Provide at least one pack or some loose pills.",
example: {
packsAdded: 1,
loosePillsAdded: 4,
usePrescription: true,
},
} as const;
const refillResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
refill: {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" },
},
},
newStock: {
type: "object",
properties: {
packCount: { type: "integer" },
looseTablets: { type: "integer" },
totalPills: { type: "number" },
},
},
prescription: {
type: "object",
properties: {
used: { type: "boolean" },
remainingRefills: { type: "integer" },
authorizedRefills: { type: "integer" },
lowRefillThreshold: { type: "integer" },
enabled: { type: "boolean" },
},
},
},
} as const;
const refillHistoryItemSchema = {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
} as const;
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true });
// Helper to get user ID from request
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -35,142 +105,218 @@ export async function refillRoutes(app: FastifyInstance) {
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
app.post<{ Params: { id: string } }>(
"/medications/:id/refill",
{
schema: {
params: idParamsSchema,
body: refillBodyOpenApiSchema,
response: {
200: refillResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
409: genericErrorSchema,
},
},
},
async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const isBottle = (med.packageType ?? "blister") === "bottle";
const effectivePacksAdded = isBottle ? 0 : packsAdded;
const effectiveLoosePillsAdded = loosePillsAdded;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
1,
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
);
const amountPerPackage =
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
? configuredAmountPerPackage
: fallbackAmountPerPackage;
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
const requestedPackAdds = Math.max(0, packsAdded);
const requestedAmountAdds = Math.max(0, loosePillsAdded);
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
}
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
const previousAmountBase = med.totalPills ?? med.looseTablets;
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
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({
const updatePayload: {
packCount: number;
looseTablets: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
updatedAt: Date;
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
};
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
if (isCountBasedAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isBottle
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
const newTotalPills = isBottle
? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
await db
.update(medications)
.set(updatePayload)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
});
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
if (isCountBasedAmountPackage) {
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
} else if (isBottle) {
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
}
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
}
);
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
app.get<{ Params: { id: string } }>(
"/medications/:id/refills",
{
schema: {
params: idParamsSchema,
response: {
200: { type: "array", items: refillHistoryItemSchema },
400: genericErrorSchema,
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
.orderBy(desc(refillHistory.refillDate));
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
});
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
}
);
}
+137 -72
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -6,13 +6,61 @@ import { doseTracking, medications, refillHistory } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
});
const reportDataBodyOpenApiSchema = {
type: "object",
required: ["medicationIds"],
properties: {
medicationIds: {
type: "array",
minItems: 1,
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
},
example: {
medicationIds: [1, 3, 5],
},
} as const;
const reportDataResponseSchema = {
type: "object",
additionalProperties: {
type: "object",
properties: {
dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" },
dosesDismissed: { type: "integer" },
firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" },
refills: {
type: "array",
items: {
type: "object",
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
},
},
},
},
} as const;
export async function reportRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
@@ -27,87 +75,104 @@ export async function reportRoutes(app: FastifyInstance) {
}
// POST /medications/report-data - Get aggregated dose/refill data for report generation
app.post("/medications/report-data", async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
app.post(
"/medications/report-data",
{
schema: {
body: reportDataBodyOpenApiSchema,
response: {
200: reportDataResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
403: genericErrorSchema,
},
},
},
async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds } = parsed.data;
const userId = await getUserId(req, reply);
const { medicationIds } = 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));
// Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
for (const id of medicationIds) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
}
}
}
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
> = {};
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
}
> = {};
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
// Get refills for this medication
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
};
// Get refills for this medication scoped to the authenticated user.
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
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;
}
return result;
});
);
}
+443 -223
View File
@@ -32,7 +32,7 @@ export type UserSettings = {
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -71,7 +71,7 @@ type SettingsBody = {
maxNaggingReminders: number;
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
@@ -85,12 +85,17 @@ type TestShoutrrrBody = {
url: string;
};
function maskEmail(email: string): string {
const [localPart, domain] = email.split("@");
if (!domain) return "invalid-email";
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
return `${localPart.slice(0, 2)}***@${domain}`;
}
const settingsEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const settingsErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
type MailDeliveryInfo = {
accepted?: unknown;
@@ -122,6 +127,38 @@ function getDeliveryError(info: MailDeliveryInfo): string | null {
return "SMTP did not confirm accepted recipients.";
}
function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
const normalizedMessage = errorMessage.toLowerCase();
if (
normalizedMessage.includes("smtp rejected all recipients") ||
normalizedMessage.includes("all recipients were rejected") ||
normalizedMessage.includes("recipient address rejected") ||
normalizedMessage.includes("nullmx")
) {
return {
status: 400,
code: "EMAIL_RECIPIENT_REJECTED",
message: `Failed to send email: ${errorMessage}`,
};
}
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
return {
status: 502,
code: "SMTP_DELIVERY_UNCONFIRMED",
message: `Failed to send email: ${errorMessage}`,
};
}
return {
status: 500,
code: "TEST_EMAIL_FAILED",
message: `Failed to send email: ${errorMessage}`,
};
}
function getNotificationProvider(url: string): string {
if (url.startsWith("discord://")) return "discord";
if (url.startsWith("telegram://")) return "telegram";
@@ -176,7 +213,7 @@ function getDefaultSettings() {
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
swapDashboardMainSections: false,
@@ -238,7 +275,7 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
@@ -282,7 +319,7 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
@@ -322,197 +359,346 @@ export async function settingsRoutes(app: FastifyInstance) {
// Get settings for current user
// Suppress request logs — polled every 30s for reminder status refresh
app.get("/settings", { logLevel: "warn" }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/settings",
{
logLevel: "warn",
schema: {
tags: ["settings"],
summary: "Get current user settings",
security: settingsEndpointSecurity,
response: {
200: { type: "object", additionalProperties: true },
401: settingsErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const settings = await getOrCreateUserSettings(userId);
const settings = await getOrCreateUserSettings(userId);
const reminderHour = envInt("REMINDER_HOUR", 6);
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
return reply.send({
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: settings.shoutrrrEnabled,
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,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
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),
smtpUser: process.env.SMTP_USER ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
// Reminder state for this user
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Stock reminder tracking (separate from intake)
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),
});
});
return reply.send({
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: settings.shoutrrrEnabled,
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,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareMedicationOverview: settings.shareMedicationOverview ?? false,
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),
smtpUser: process.env.SMTP_USER ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
// Reminder state for this user
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Stock reminder tracking (separate from intake)
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)
reminderHour,
reminderMinutesBefore,
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
}
);
// Update settings for current user
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
const userId = await getUserId(request, reply);
app.put<{ Body: SettingsBody }>(
"/settings",
{
schema: {
tags: ["settings"],
summary: "Update current user settings",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: {
emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" },
repeatDailyReminders: { type: "boolean" },
lowStockDays: { type: "number" },
normalStockDays: { type: "number" },
highStockDays: { type: "number" },
shoutrrrEnabled: { type: "boolean" },
shoutrrrUrl: { type: "string" },
emailStockReminders: { type: "boolean" },
emailIntakeReminders: { type: "boolean" },
emailPrescriptionReminders: { type: "boolean" },
shoutrrrStockReminders: { type: "boolean" },
shoutrrrIntakeReminders: { type: "boolean" },
shoutrrrPrescriptionReminders: { type: "boolean" },
skipRemindersForTakenDoses: { type: "boolean" },
repeatRemindersEnabled: { type: "boolean" },
reminderRepeatIntervalMinutes: { type: "number" },
maxNaggingReminders: { type: "number" },
language: { type: "string", enum: ["en", "de"] },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareMedicationOverview: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
swapDashboardMainSections: { type: "boolean" },
},
example: {
emailEnabled: true,
notificationEmail: "daniel@example.com",
reminderDaysBefore: 7,
repeatDailyReminders: true,
lowStockDays: 14,
normalStockDays: 30,
highStockDays: 90,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: false,
shoutrrrIntakeReminders: false,
shoutrrrPrescriptionReminders: false,
skipRemindersForTakenDoses: true,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: settingsErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const body = request.body;
const body = request.body;
// Check if any stock reminders are configured
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
// Check if any stock reminders are configured
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
// Disable repeatDailyReminders if no stock reminders are configured
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
// Disable repeatDailyReminders if no stock reminders are configured
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
// Update or insert user settings
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
// Update or insert user settings
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = {
emailEnabled: body.emailEnabled,
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,
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: body.maxNaggingReminders ?? 5,
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
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(),
};
const settingsData = {
emailEnabled: body.emailEnabled,
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,
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: body.maxNaggingReminders ?? 5,
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareMedicationOverview: body.shareMedicationOverview ?? false,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
updatedAt: new Date(),
};
if (existingSettings.length > 0) {
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId: userId,
...settingsData,
});
if (existingSettings.length > 0) {
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId: userId,
...settingsData,
});
}
return reply.send({ success: true });
}
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;
app.put<{ Body: { language: string } }>(
"/settings/language",
{
schema: {
tags: ["settings"],
summary: "Update UI language",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["language"],
properties: {
language: { type: "string", enum: ["en", "de"] },
},
example: {
language: "de",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: settingsErrorSchema,
401: settingsErrorSchema,
},
},
},
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" });
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 });
}
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;
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;
request.log.info(
{
to: maskEmail(email),
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
request.log.warn(
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
app.post<{ Body: TestEmailBody }>(
"/settings/test-email",
{
schema: {
tags: ["settings"],
summary: "Send test email",
description: "Sends a test message using configured SMTP settings.",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", format: "email" },
},
example: {
email: "daniel@example.com",
},
},
});
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: settingsErrorSchema,
401: settingsErrorSchema,
500: settingsErrorSchema,
502: settingsErrorSchema,
},
},
},
async (request, reply) => {
const { email } = request.body;
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
html: `
request.log.info(
{
to: email,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
request.log.warn(
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ to: email }, "[Settings] Sending test email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
html: `
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">MedAssist-ng - Test Email</h2>
<p>This is a test email from MedAssist-ng.</p>
@@ -521,55 +707,89 @@ export async function settingsRoutes(app: FastifyInstance) {
<p style="color: #6b7280; font-size: 14px;">Sent from MedAssist-ng Medication Planner</p>
</div>
`,
});
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
}
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
return reply.send({ success: true, message: "Test email sent successfully" });
} catch (error) {
request.log.error({ to: email, error }, "[Settings] Test email failed");
const failure = classifyTestEmailFailure(error);
return reply.status(failure.status).send({ error: failure.message, code: failure.code });
}
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
return reply.send({ success: true, message: "Test email sent successfully" });
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
}
});
);
// Test Shoutrrr/ntfy notification
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
const { url } = request.body;
app.post<{ Body: TestShoutrrrBody }>(
"/settings/test-shoutrrr",
{
schema: {
tags: ["settings"],
summary: "Send test push notification",
description: "Sends a test notification via a Shoutrrr-compatible URL.",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["url"],
properties: {
url: { type: "string" },
},
example: {
url: "ntfy://user:token@push.example.com/medassist",
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: settingsErrorSchema,
401: settingsErrorSchema,
500: settingsErrorSchema,
},
},
},
async (request, reply) => {
const { url } = request.body;
if (!url) {
return reply.status(400).send({ error: "Notification URL is required" });
}
try {
const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
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 });
if (!url) {
return reply.status(400).send({ error: "Notification URL is required" });
}
try {
const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
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}` });
}
} 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}` });
}
});
);
}
// Validate and sanitize URL to prevent SSRF attacks
+370 -142
View File
@@ -3,10 +3,18 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { buildSharedMedicationOverview } from "../services/coverage.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -22,10 +30,71 @@ 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)}`;
}
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const shareTokenPattern = /^[a-f0-9]{16}$/;
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
},
} as const;
const shareReadResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
scheduleDays: { type: "integer" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
shareMedicationOverview: { type: "boolean" },
medicationOverview: {
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
} as const;
const shareExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
ownerUsername: { type: "string" },
takenBy: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
generatedAt: { type: "string", format: "date-time" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
@@ -47,132 +116,269 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
// Share Routes
// =============================================================================
export async function shareRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "share",
protectedByDefault: false,
protectedPaths: [/^\/share$/, /^\/share\/people$/],
});
// ---------------------------------------------------------------------------
// GET /share/:token - PUBLIC: Get shared schedule by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareReadResponseSchema,
404: genericErrorSchema,
410: shareExpiredResponseSchema,
},
},
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
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
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills =
(med.packageType ?? "blister") === "bottle" ||
(med.packageType ?? "blister") === "tube" ||
(med.packageType ?? "blister") === "liquid_container"
const totalPills = isAmountBasedPackageType(med.packageType)
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: med.packageType ?? "blister",
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: normalizePackageType(med.packageType),
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
const medicationOverview = shareMedicationOverview
? buildSharedMedicationOverview({
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
})
: null;
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/overview",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareOverviewResponseSchema,
404: genericErrorSchema,
410: shareOverviewExpiredResponseSchema,
},
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
});
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
expiredAt: share.expiresAt.toISOString(),
});
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const overview = buildSharedMedicationOverview({
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
generatedAt: new Date().toISOString(),
medications: overview,
};
}
);
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
body: createShareBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
expiresAt: { type: ["string", "null"] },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -187,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
@@ -216,7 +425,7 @@ export async function shareRoutes(app: FastifyInstance) {
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})`
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
@@ -238,7 +447,7 @@ export async function shareRoutes(app: FastifyInstance) {
});
request.log.info(
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
@@ -253,37 +462,56 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/share/people",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
people: { type: "array", items: { type: "string" } },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(eq(medications.userId, userId));
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
}
}
}
return { people: [...allPeople].sort() };
});
return { people: [...allPeople].sort() };
}
);
}
+212
View File
@@ -0,0 +1,212 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getAverageOccurrencesPerDay,
getNextScheduledOccurrenceTime,
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
} from "../utils/scheduler-utils.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
export type SharedMedicationOverviewItem = {
name: string;
genericName: string | null;
imageUrl: string | null;
packageType: string;
packCount: number;
packageAmountValue: number | null;
packageAmountUnit: "ml" | "g" | null;
blistersPerPack: number;
pillsPerBlister: number;
totalPills: number | null;
looseTablets: number;
currentStock: number | null;
capacity: number | null;
daysLeft: number | null;
nextIntakeDate: string | null;
depletionDate: string | null;
priority: "normal" | "high" | "out-of-stock" | null;
expiryDate: string | null;
medicationStartDate: string | null;
prescriptionEnabled: boolean;
prescriptionRemainingRefills: number | null;
};
function toDateOnlyString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseDateOnly(dateOnly: string): Date {
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
return new Date(year, month - 1, day, 0, 0, 0, 0);
}
function computeCapacity(medication: MedicationRow): number {
if (isAmountBasedPackageType(medication.packageType)) {
return medication.totalPills ?? medication.looseTablets;
}
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
}
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
return intakes.reduce((sum, intake) => {
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
}, 0);
}
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly);
let nextOccurrenceMs: number | null = null;
for (const intake of intakes) {
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
if (occurrenceMs === null) {
continue;
}
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
nextOccurrenceMs = occurrenceMs;
}
}
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
}
function computeTakenAmount(
medication: MedicationRow,
intakes: Intake[],
dosesByMedication: Map<number, DoseRow[]>
): number {
const doseRows = dosesByMedication.get(medication.id) ?? [];
if (doseRows.length === 0) return 0;
const correctionDateOnlyMs = medication.lastStockCorrectionAt
? new Date(
medication.lastStockCorrectionAt.getFullYear(),
medication.lastStockCorrectionAt.getMonth(),
medication.lastStockCorrectionAt.getDate(),
0,
0,
0,
0
).getTime()
: 0;
let takenAmount = 0;
for (const dose of doseRows) {
if (dose.dismissed) continue;
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const intakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
const intake = intakes[intakeIndex];
if (!intake) continue;
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
}
return takenAmount;
}
function toNullableDate(value: string | null): string | null {
if (!value) return null;
return value.trim() ? value : null;
}
function computeOverviewPriority(
currentStock: number,
daysLeft: number | null,
thresholdDays: number
): "normal" | "high" | "out-of-stock" {
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
return "normal";
}
export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const medicationId = Number.parseInt(match[1], 10);
if (Number.isNaN(medicationId)) continue;
const existing = dosesByMedication.get(medicationId) ?? [];
existing.push(dose);
dosesByMedication.set(medicationId, existing);
}
const todayDateOnly = getTodayInTimezone();
const todayDate = parseDateOnly(todayDateOnly);
return medicationRows.map((medication) => {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled ?? false
);
const capacity = computeCapacity(medication);
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
return {
name: medication.name,
genericName: medication.genericName,
imageUrl: medication.imageUrl,
packageType: medication.packageType,
packCount: medication.packCount,
packageAmountValue: medication.packageAmountValue,
packageAmountUnit:
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
? medication.packageAmountUnit
: null,
blistersPerPack: medication.blistersPerPack,
pillsPerBlister: medication.pillsPerBlister,
totalPills: medication.totalPills,
looseTablets: medication.looseTablets,
currentStock,
capacity,
daysLeft,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate,
priority,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
};
});
}
+144
View File
@@ -0,0 +1,144 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
countScheduledOccurrencesInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getDoseTakenAtMs(dose: DoseRow): number {
const rawTakenAt = Number(dose.takenAt);
if (Number.isFinite(rawTakenAt)) {
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
}
return new Date(dose.takenAt).getTime();
}
export function computeMedicationCurrentStock(options: {
medication: MedicationRow;
doses: DoseRow[];
stockCalculationMode: "automatic" | "manual";
nowMs?: number;
}): number {
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled ?? false
);
const baseStock = isAmountBasedPackageType(medication.packageType)
? medication.looseTablets + (medication.stockAdjustment ?? 0)
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
medication.looseTablets +
(medication.stockAdjustment ?? 0);
const relevantDoses = doses.filter((dose) => !dose.dismissed);
const stockCorrectionCutoff = medication.lastStockCorrectionAt
? new Date(medication.lastStockCorrectionAt).getTime()
: 0;
let consumed = 0;
if (stockCalculationMode === "automatic") {
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
intakes.forEach((intake, intakeIndex) => {
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
const intakeStart = parseLocalDateTime(intake.start).getTime();
if (Number.isNaN(intakeStart)) return;
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
: intakeStart;
if (effectiveStart === null) return;
let peopleForThisIntake: Array<string | null>;
if (intake.takenBy) {
peopleForThisIntake = [intake.takenBy];
} else if (medicationTakenBy.length > 0) {
peopleForThisIntake = medicationTakenBy;
} else {
peopleForThisIntake = [null];
}
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= nowMs) {
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
intake,
effectiveStart,
nowMs
);
consumed += occurrences * usage * peopleForThisIntake.length;
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
for (const dose of relevantDoses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
continue;
}
if (doseDateOnlyMs > earlyCutoff) {
consumed += usage;
}
}
});
} else {
intakes.forEach((intake, intakeIndex) => {
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
const intakeStart = parseLocalDateTime(intake.start);
const intakeStartDateOnly = new Date(
intakeStart.getFullYear(),
intakeStart.getMonth(),
intakeStart.getDate()
).getTime();
if (Number.isNaN(intakeStartDateOnly)) return;
for (const dose of relevantDoses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
continue;
}
const takenAtMs = getDoseTakenAtMs(dose);
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
consumed += usage;
}
}
});
}
return Math.max(0, Math.floor(baseStock - consumed));
}
+233 -113
View File
@@ -4,7 +4,7 @@ import { and, eq, gte, lte } from "drizzle-orm";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js";
import { doseTracking, medications, users } from "../db/schema.js";
import {
getDateLocale,
getFooterHtml,
@@ -23,11 +23,13 @@ import {
getTodaysIntakes,
getUpcomingIntakes,
type IntakeReminderState,
normalizeIntakeUsageForStock,
parseIntakeReminderState,
parseIntakesJson,
parseTakenByJson,
type UpcomingIntake,
} from "../utils/scheduler-utils.js";
import { computeMedicationCurrentStock } from "./current-stock.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
@@ -50,6 +52,36 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
response?: unknown;
};
function normalizeRecipients(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
.map((entry) => entry.trim())
.filter(Boolean);
}
function getDeliveryError(info: MailDeliveryInfo): string | null {
const accepted = normalizeRecipients(info.accepted);
const rejected = normalizeRecipients(info.rejected);
if (accepted.length > 0) return null;
if (rejected.length > 0) {
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
}
if (typeof info.response === "string" && info.response.trim()) {
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
}
return "SMTP did not confirm accepted recipients.";
}
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
@@ -59,6 +91,27 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function getUsernameForLog(userId: number): Promise<string> {
const user = await db.select({ username: users.username }).from(users).where(eq(users.id, userId));
const username = user[0]?.username?.trim();
return username && username.length > 0 ? username : `unknown-user-${userId}`;
}
function formatIntakeLog(intake: {
medName: string;
medicationId: number;
blisterIndex: number;
intakeTime: Date;
intakeTimeStr: string;
usage: number;
doseUnit?: string;
takenBy?: string | null;
}): string {
const takenBy = intake.takenBy ? intake.takenBy : "none";
const doseUnit = intake.doseUnit ?? "mg";
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
@@ -67,6 +120,9 @@ async function autoMarkDueIntakesAsTaken(
logger: ServiceLogger
): Promise<number> {
if (settings.stockCalculationMode !== "automatic") {
logger.debug(
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
);
return 0;
}
@@ -88,6 +144,10 @@ async function autoMarkDueIntakesAsTaken(
)
);
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
const trackedDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
let inserted = 0;
@@ -107,6 +167,15 @@ async function autoMarkDueIntakesAsTaken(
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
let remainingStock = computeMedicationCurrentStock({
medication: med,
doses: trackedDoses,
stockCalculationMode: settings.stockCalculationMode,
nowMs: now.getTime(),
});
if (remainingStock <= 0) {
continue;
}
const todaysIntakes = getTodaysIntakes(
medDisplayName,
intakes,
@@ -137,6 +206,14 @@ async function autoMarkDueIntakesAsTaken(
continue;
}
const intakeDefinition = intakes[intake.blisterIndex];
const usage = intakeDefinition
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
: 0;
if (remainingStock <= 0) {
break;
}
await db.insert(doseTracking).values({
userId: settings.userId,
doseId,
@@ -146,13 +223,38 @@ async function autoMarkDueIntakesAsTaken(
dismissed: false,
});
logger.info(
`[IntakeReminder] Auto-marked intake for userId=${settings.userId}: ${formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})}`
);
existingDoseIds.add(doseId);
trackedDoses.push({
id: 0,
userId: settings.userId,
doseId,
takenAt: intake.intakeTime,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
remainingStock = Math.max(0, remainingStock - usage);
inserted++;
}
}
if (inserted > 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
if (inserted === 0) {
logger.debug(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: no due intakes`);
} else {
logger.info(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: inserted=${inserted}`);
}
return inserted;
@@ -166,7 +268,7 @@ async function sendIntakeReminderEmail(
repeatIntervalMinutes?: number,
currentCount?: number,
maxCount?: number
): Promise<{ success: boolean; error?: string }> {
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
@@ -310,7 +412,7 @@ ${getFooterPlain(language)}`;
},
});
await transporter.sendMail({
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `💊 ${subject}`,
@@ -318,7 +420,16 @@ ${getFooterPlain(language)}`;
html,
});
return { success: true };
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
return { success: false, error: deliveryError };
}
return {
success: true,
messageId: mailResult.messageId,
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
@@ -330,40 +441,55 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
logger.debug(`[IntakeReminder] Scheduler cycle loaded user settings count=${allUserSettings.length}`);
if (allUserSettings.length === 0) {
logger.debug(`[IntakeReminder] No users with settings found`);
return; // No users with settings
}
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
}
logger.debug(`[IntakeReminder] Scheduler cycle finished`);
}
async function checkAndSendIntakeRemindersForUser(
export async function checkAndSendIntakeRemindersForUser(
settings: UserSettings & { userId: number },
logger: ServiceLogger
): Promise<void> {
const username = await getUsernameForLog(settings.userId);
logger.info(
`[IntakeReminder] Evaluating intake reminders for user=${username} (userId=${settings.userId}, emailEnabled=${settings.emailEnabled}, pushEnabled=${settings.shoutrrrEnabled}, skipTaken=${settings.skipRemindersForTakenDoses}, repeat=${settings.repeatRemindersEnabled}, mode=${settings.stockCalculationMode})`
);
const language = settings.language;
const tr = getTranslations(language);
logger.debug(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
.where(and(eq(medications.userId, settings.userId), eq(medications.isObsolete, false)));
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language);
const tz = getTimezone();
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) {
logger.info(
`[IntakeReminder] Auto-mark summary for user=${username} (userId=${settings.userId}): autoMarkedCount=${autoMarkedCount}`
);
}
if (settings.stockCalculationMode === "automatic" && settings.skipRemindersForTakenDoses) {
logger.info(
`[IntakeReminder] Reminder sending skipped for user=${username} (userId=${settings.userId}) because stockCalculationMode=automatic and skipRemindersForTakenDoses=true`
);
return;
}
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
@@ -371,29 +497,35 @@ async function checkAndSendIntakeRemindersForUser(
if (!emailEnabled && !shoutrrrEnabled) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
`[IntakeReminder] Notification sending disabled for user=${username} (userId=${settings.userId}): both email and push intake reminders are off`
);
return; // No intake reminder notifications enabled for this user
}
logger.debug(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
// Build medication entries that have at least one reminder-enabled intake.
// Intake-level reminders are the single source of truth.
const reminderEntries = activeRows
.map((med) => {
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
false
);
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
return { med, intakes, intakesWithReminders };
})
.filter((entry) => entry.intakesWithReminders.length > 0);
// Get all medications with intake reminders enabled for this user
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
if (reminderEntries.length === 0) {
logger.debug(
`[IntakeReminder] No reminder-enabled intake definitions for user=${username} (userId=${settings.userId})`
);
return; // No medications have reminders enabled for this user
}
logger.debug(
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
);
const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
let scheduledIntakesTodayCount = 0;
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
@@ -402,41 +534,27 @@ async function checkAndSendIntakeRemindersForUser(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) {
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
);
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
const todaysIntakesForThisDefinition = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
@@ -451,9 +569,6 @@ async function checkAndSendIntakeRemindersForUser(
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
);
// Add upcoming intakes for first reminders
allUpcoming.push(
@@ -466,25 +581,9 @@ async function checkAndSendIntakeRemindersForUser(
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
);
const missedIntakes = allTodaysIntakes.filter(
const missedIntakes = todaysIntakesForThisDefinition.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
@@ -501,13 +600,17 @@ async function checkAndSendIntakeRemindersForUser(
});
}
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
if (allUpcoming.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
logger.debug(
`[IntakeReminder] No upcoming intakes in reminder window for user=${username} (userId=${settings.userId}, scheduledToday=${scheduledIntakesTodayCount})`
);
return; // No upcoming intakes for today
}
logger.info(
`[IntakeReminder] Candidate intakes for user=${username} (userId=${settings.userId}): scheduledToday=${scheduledIntakesTodayCount}, candidates=${allUpcoming.length}`
);
// Determine which doses need reminders (new or repeated)
const nowMs = Date.now();
const maxReminders = settings.maxNaggingReminders ?? 5;
@@ -535,9 +638,6 @@ async function checkAndSendIntakeRemindersForUser(
// Recently missed — scheduler likely recovered from sleep/restart.
// Send a catch-up reminder (counts as first nagging reminder).
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
);
} else {
// Long ago — seed state without notification (user likely already noticed)
state.reminders[key] = {
@@ -546,16 +646,10 @@ async function checkAndSendIntakeRemindersForUser(
sendCount: 0,
advanceSent: false,
};
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
);
}
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
);
}
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Intake time passed - check if we need to send nagging reminder
@@ -567,27 +661,41 @@ async function checkAndSendIntakeRemindersForUser(
const currentNaggingCount = existingEntry.sendCount;
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.debug(
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
);
} else if (timeSinceLastReminder >= intervalMs) {
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
}
if (remindersToSend.length === 0) {
logger.debug(
`[IntakeReminder] No reminders to send for user=${username} (userId=${settings.userId}) after state/repeat evaluation`
);
return; // All reminders already sent and no repeats needed
}
logger.info(
`[IntakeReminder] Reminders selected for user=${username} (userId=${settings.userId}): count=${remindersToSend.length} :: ${remindersToSend
.map((intake) =>
formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})
)
.join(" | ")}`
);
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
if (settings.skipRemindersForTakenDoses) {
const beforeFilterCount = remindersToSend.length;
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
const takenToday = await db
.select()
@@ -613,33 +721,30 @@ async function checkAndSendIntakeRemindersForUser(
// For person-specific intake, check if that person has taken it
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
} else {
// For non-person-specific intakes
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
}
});
const filteredOutCount = beforeFilterCount - remindersToSend.length;
if (filteredOutCount > 0) {
logger.info(
`[IntakeReminder] Removed reminders for already taken doses for user=${username} (userId=${settings.userId}): removed=${filteredOutCount}, remaining=${remindersToSend.length}`
);
}
if (remindersToSend.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
logger.info(
`[IntakeReminder] All candidate reminders already taken for user=${username} (userId=${settings.userId}); nothing to send`
);
return;
}
}
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
// Determine if this is a repeat reminder:
// - Any intake already has a state entry AND is past (repeat after first reminder)
// - OR intake is past even without state entry (missed the 15-min window)
@@ -669,10 +774,14 @@ async function checkAndSendIntakeRemindersForUser(
hasNaggingReminder ? maxReminderCount : undefined
);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
if (!result.success) {
logger.error(
`[IntakeReminder] Email delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
logger.info(
`[IntakeReminder] Email delivered for user=${username} (userId=${settings.userId}, recipient=${settings.notificationEmail}, reminders=${remindersToSend.length}, messageId=${result.messageId ?? "n/a"})`
);
}
}
@@ -735,10 +844,14 @@ async function checkAndSendIntakeRemindersForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
if (!result.success) {
logger.error(
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
logger.info(
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
);
}
}
@@ -805,6 +918,13 @@ async function checkAndSendIntakeRemindersForUser(
const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy || undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
logger.info(
`[IntakeReminder] Reminder state persisted for user=${username} (userId=${settings.userId}, channel=${channel}, reminders=${remindersToSend.length}, firstMed=${medName ?? "n/a"}, firstTakenBy=${takenBy ?? "none"})`
);
} else {
logger.info(
`[IntakeReminder] No reminder channel succeeded for user=${username} (userId=${settings.userId}, remindersAttempted=${remindersToSend.length})`
);
}
}
File diff suppressed because it is too large Load Diff
+40 -50
View File
@@ -8,14 +8,23 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
import {
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
// Import shared utilities
import {
type Blister,
calculateDepletionInfo,
countScheduledOccurrencesInRange,
createDefaultReminderState,
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
@@ -265,12 +274,12 @@ async function getMedicationsNeedingReminder(
const lowStock: LowStockItem[] = [];
const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) {
const packageType = normalizePackageType(row.packageType);
// Tube stock reminders are intentionally disabled:
// topical usage in grams cannot be mapped reliably to schedule events.
if ((row.packageType ?? "blister") === "tube") continue;
if (isTubePackageType(packageType)) continue;
const intakes = parseIntakesJson(
row.intakesJson,
@@ -281,12 +290,13 @@ async function getMedicationsNeedingReminder(
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
every: i.every,
start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
}));
const originalTotalPills =
(row.packageType ?? "blister") === "bottle"
? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const originalTotalPills = isAmountBasedPackageType(packageType)
? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
@@ -298,16 +308,11 @@ async function getMedicationsNeedingReminder(
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
: blisterStart;
if (effectiveStart === null) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
@@ -325,25 +330,20 @@ async function getMedicationsNeedingReminder(
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
blister,
effectiveStart,
now
);
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
@@ -393,7 +393,7 @@ async function getMedicationsNeedingReminder(
if (daysLeft === null) continue;
const isLiquid = (row.packageType ?? "blister") === "liquid_container";
const isLiquid = isLiquidContainerPackageType(packageType);
const { lowDays, criticalDays } = isLiquid
? getLiquidReminderThresholds(reminderDaysBefore)
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
@@ -681,12 +681,10 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
if (!stockSendLock) {
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send");
} else {
try {
logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
);
logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
@@ -700,7 +698,7 @@ async function checkAndSendReminderForUser(
);
emailSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
logger.error(`[Reminder] Failed to send stock email: ${result.error}`);
}
}
@@ -742,7 +740,7 @@ async function checkAndSendReminderForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
}
}
@@ -774,9 +772,7 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
if (!prescriptionSendLock) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
);
logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send");
} else {
try {
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
@@ -785,9 +781,7 @@ async function checkAndSendReminderForUser(
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
if (!shouldSend) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder already marked as sent today, skipping`
);
logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping");
}
const preMarkedNotified =
@@ -807,9 +801,7 @@ async function checkAndSendReminderForUser(
}
if (shouldSend) {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`);
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
@@ -941,9 +933,7 @@ async function checkAndSendReminderForUser(
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(
`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`
);
logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`);
}
}
}
@@ -980,7 +970,7 @@ async function checkAndSendReminderForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
}
}
+4 -3
View File
@@ -8,6 +8,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -97,7 +98,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
beforeAll(async () => {
await createSchema(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret-12345" });
@@ -228,7 +229,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should reject short username", async () => {
@@ -242,7 +243,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should register with trimmed username when input has whitespace", async () => {
@@ -0,0 +1,486 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { medicationRoutes } = await import("../routes/medications.js");
const { doseRoutes } = await import("../routes/doses.js");
const { refillRoutes } = await import("../routes/refills.js");
const { shareRoutes } = await import("../routes/share.js");
const { reportRoutes } = await import("../routes/report.js");
const { exportRoutes } = await import("../routes/export.js");
const { hashApiKeyToken } = await import("../plugins/auth.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertApiKey(options: {
userId: number;
token: string;
scope?: "read" | "write";
isActive?: boolean;
expiresAt?: Date | null;
}) {
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
await testClient.execute({
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
"Seeded Key",
hashApiKeyToken(options.token),
`${options.token.slice(0, 12)}...`,
options.scope ?? "write",
options.isActive === false ? 0 : 1,
expiresAtValue,
],
});
}
async function seedMedication(options: {
userId: number;
name: string;
takenBy?: string[];
packCount?: number;
looseTablets?: number;
start?: string;
}) {
const start = options.start ?? "2026-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
options.packCount ?? 1,
1,
10,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) {
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)",
args: [options.userId, options.doseId, options.dismissed ? 1 : 0],
});
}
async function seedRefill(options: {
userId: number;
medicationId: number;
packsAdded?: number;
loosePillsAdded?: number;
}) {
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription)
VALUES (?, ?, ?, ?, 0)`,
args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0],
});
}
function buildMedicationPayload(name: string) {
return {
name,
genericName: `${name} Generic`,
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
};
}
function buildImportPayload() {
return {
version: "1.3",
exportedAt: new Date().toISOString(),
includeSensitiveData: false,
medications: [],
doseHistory: [],
refillHistory: [],
settings: {
emailEnabled: false,
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
},
shareLinks: [],
};
}
describe("Real business route authz contracts", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(medicationRoutes);
await app.register(doseRoutes);
await app.register(refillRoutes);
await app.register(shareRoutes);
await app.register(reportRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("rejects protected business endpoints without authentication", async () => {
const endpoints: Array<{
method: "GET" | "POST";
url: string;
payload?: Record<string, unknown>;
}> = [
{ method: "GET", url: "/medications" },
{ method: "GET", url: "/doses/taken" },
{ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } },
{ method: "GET", url: "/export" },
{ method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } },
{ method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } },
];
for (const endpoint of endpoints) {
const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload });
expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401);
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
}
});
it("scopes medication listing and export output to the authenticated user", async () => {
const ownerId = await createUser("owner-medications");
const otherId = await createUser("other-medications");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications");
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
await seedMedication({ userId: otherId, name: "Other User Med" });
const listResponse = await app.inject({
method: "GET",
url: "/medications",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("Owner Only Med");
expect(listResponse.body).not.toContain("Other User Med");
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: ownerCookie },
});
expect(exportResponse.statusCode).toBe(200);
expect(exportResponse.body).toContain("Owner Only Med");
expect(exportResponse.body).not.toContain("Other User Med");
});
it("returns 404 when a user updates or deletes another user's medication", async () => {
const ownerId = await createUser("owner-update");
const otherId = await createUser("other-update");
const otherCookie = buildSessionCookie(app, otherId, "other-update");
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
const updateResponse = await app.inject({
method: "PUT",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
payload: buildMedicationPayload("Updated By Stranger"),
});
expect(updateResponse.statusCode).toBe(404);
const deleteResponse = await app.inject({
method: "DELETE",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(404);
const dbState = await testClient.execute({
sql: "SELECT name FROM medications WHERE id = ?",
args: [medicationId],
});
expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]);
});
it("scopes dose reads and writes to the authenticated user", async () => {
const ownerId = await createUser("owner-dose");
const otherId = await createUser("other-dose");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
const listResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("101-0-1760000000000");
expect(listResponse.body).not.toContain("202-0-1760000000000");
const deleteResponse = await app.inject({
method: "DELETE",
url: "/doses/taken/101-0-1760000000000",
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(200);
const ownerDose = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [ownerId, "101-0-1760000000000"],
});
expect(Number(ownerDose.rows[0].count)).toBe(1);
});
it("enforces medication ownership on refill history and report generation", async () => {
const ownerId = await createUser("owner-refill");
const otherId = await createUser("other-refill");
const otherCookie = buildSessionCookie(app, otherId, "other-refill");
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
await seedRefill({ userId: ownerId, medicationId });
const refillListResponse = await app.inject({
method: "GET",
url: `/medications/${medicationId}/refills`,
headers: { cookie: otherCookie },
});
expect(refillListResponse.statusCode).toBe(404);
const refillMutationResponse = await app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { cookie: otherCookie },
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillMutationResponse.statusCode).toBe(404);
const reportResponse = await app.inject({
method: "POST",
url: "/medications/report-data",
headers: { cookie: otherCookie },
payload: { medicationIds: [medicationId] },
});
expect(reportResponse.statusCode).toBe(403);
expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" });
});
it("scopes share people to the authenticated user's medications", async () => {
const ownerId = await createUser("owner-share");
const otherId = await createUser("other-share");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-share");
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
const response = await app.inject({
method: "GET",
url: "/share/people",
headers: { cookie: ownerCookie },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ people: ["Daniel"] });
});
it("rejects mutation routes for read-only API keys across business endpoints", async () => {
const userId = await createUser("readonly-business-key");
const medicationId = await seedMedication({ userId, name: "Readonly Med" });
const apiToken = "ma_readonly_business_routes_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const responses = await Promise.all([
app.inject({
method: "POST",
url: "/medications",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildMedicationPayload("Blocked Create"),
}),
app.inject({
method: "POST",
url: "/doses/taken",
headers: { authorization: `Bearer ${apiToken}` },
payload: { doseId: "1-0-1760000000000" },
}),
app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { authorization: `Bearer ${apiToken}` },
payload: { packsAdded: 1, loosePillsAdded: 0 },
}),
app.inject({
method: "POST",
url: "/share",
headers: { authorization: `Bearer ${apiToken}` },
payload: { takenBy: "Daniel", scheduleDays: 7 },
}),
app.inject({
method: "POST",
url: "/import",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildImportPayload(),
}),
]);
for (const response of responses) {
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
}
});
it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => {
const userId = await createUser("readonly-export-user");
const otherId = await createUser("readonly-export-other");
await seedMedication({ userId, name: "Readable Owner Med" });
await seedMedication({ userId: otherId, name: "Unreadable Other Med" });
const apiToken = "ma_readonly_export_access_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "GET",
url: "/export",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain("Readable Owner Med");
expect(response.body).not.toContain("Unreadable Other Med");
});
});
+328 -386
View File
@@ -1,487 +1,412 @@
/**
* Tests for /doses/taken API endpoints.
* Tests marking doses as taken, listing taken doses, and unmarking.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// =============================================================================
// Route Registration
// Since we can't easily import routes that depend on the global db,
// we'll create simplified route handlers for testing the core logic.
// =============================================================================
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
async function registerDoseRoutes(ctx: TestContext) {
const { app, client } = ctx;
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
// GET /doses/taken - List all taken doses
app.get("/doses/taken", async (_request, _reply) => {
// In test mode, use user ID 1 (will be created in tests)
const userId = 1;
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
const result = await client.execute({
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
return {
doses: result.rows.map((d) => ({
doseId: d.dose_id,
takenAt: (d.taken_at as number) * 1000, // Convert to ms
markedBy: d.marked_by,
})),
};
const { doseRoutes } = await import("../routes/doses.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
// POST /doses/taken - Mark a dose as taken
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
const userId = 1;
const { doseId } = request.body || {};
return Number(result.rows[0].id);
}
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
return reply.status(400).send({ error: "doseId is required" });
}
// Check if already marked
const existing = await client.execute({
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
return { success: true, message: "Already marked" };
}
// Insert new record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
args: [userId, doseId],
});
return { success: true };
});
// DELETE /doses/taken/:doseId - Unmark a dose
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => {
const userId = 1;
const { doseId } = request.params;
// Check if this dose was also dismissed
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
// Already dismissed - keep the record as-is (don't delete)
// The dose stays dismissed, we just ignore the undo request
} else {
// Not dismissed - delete the record entirely
await client.execute({
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
}
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
async function insertMedication(options: {
id: number;
userId: number;
takenBy?: string[];
packCount?: number;
looseTablets?: number;
start?: string;
}) {
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
args: [
options.id,
options.userId,
JSON.stringify(options.takenBy ?? []),
options.packCount ?? 1,
options.looseTablets ?? 0,
intakeStart,
"[]",
],
});
}
// =============================================================================
// Tests
// =============================================================================
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
args: [userId, stockCalculationMode],
});
}
async function _insertShareToken(userId: number, token: string, takenBy: string) {
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
args: [userId, token, takenBy],
});
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertDose(options: {
userId: number;
doseId: string;
markedBy?: string | null;
dismissed?: boolean;
takenAt?: number | null;
takenSource?: "manual" | "automatic";
}) {
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
options.doseId,
options.markedBy ?? null,
options.dismissed ? 1 : 0,
options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0),
options.takenSource ?? "manual",
],
});
}
describe("Dose Tracking API", () => {
let ctx: TestContext;
let app: FastifyInstance;
let userId: number;
let cookieHeader: string;
beforeAll(async () => {
ctx = await buildTestApp();
await registerDoseRoutes(ctx);
await ctx.app.ready();
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(doseRoutes);
await app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user - will get ID 1 since table is cleared
userId = await createTestUser(ctx.client, { username: "testuser" });
// Reset SQLite autoincrement so user gets ID 1
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await clearTestData(ctx.client);
userId = await createTestUser(ctx.client, { username: "testuser" });
await clearTables();
userId = await createUser("dose-test-user");
cookieHeader = buildSessionCookie(app, userId, "dose-test-user");
});
// ---------------------------------------------------------------------------
// POST /doses/taken
// ---------------------------------------------------------------------------
describe("POST /doses/taken", () => {
it("should mark a dose as taken", async () => {
it("marks a dose as taken", async () => {
const doseId = "1-0-1735344000000";
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dose_id).toBe(doseId);
expect(result.rows[0].marked_by).toBeNull();
expect(result.rows).toEqual([
expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }),
]);
});
it("should return idempotent response when dose already marked", async () => {
it("returns an idempotent response when the dose is already marked", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark once
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Mark again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Already marked" });
// Should still only have one record
const result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(1);
expect(Number(countResult.rows[0].count)).toBe(1);
});
it("should reject request without doseId", async () => {
const response = await ctx.app.inject({
it("rejects requests without a doseId", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.json()).toEqual({ error: "Required" });
});
it("should reject request with empty doseId", async () => {
const response = await ctx.app.inject({
it("accepts dose IDs with a person suffix and special characters", async () => {
const doseId = "5-0-1735344000000-Max Müller";
const response = await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: "" },
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.statusCode).toBe(200);
const getResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(getResponse.statusCode).toBe(200);
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
it("rejects taking a dose when the medication is out of stock", async () => {
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId: "5-0-1735344000000" },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
});
it("allows taking a historical dose when stock existed at that occurrence", async () => {
await insertMedication({
id: 6,
userId,
packCount: 1,
looseTablets: 0,
start: "2025-01-01T08:00:00.000Z",
});
await insertUserSettings(userId, "automatic");
const historicalDoseId = "6-0-1736064000000";
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId: historicalDoseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
});
// ---------------------------------------------------------------------------
// GET /doses/taken
// ---------------------------------------------------------------------------
describe("GET /doses/taken", () => {
it("should return empty array when no doses taken", async () => {
const response = await ctx.app.inject({
it("returns an empty array when no doses were taken", async () => {
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ doses: [] });
});
it("should return list of taken doses", async () => {
const doseId1 = "1-0-1735344000000";
const doseId2 = "1-0-1735430400000";
// Mark two doses
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId1 },
});
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId2 },
it("returns only the authenticated user's taken doses with metadata", async () => {
const otherUserId = await createUser("dose-other-user");
await insertDose({
userId,
doseId: "1-0-1735344000000",
markedBy: "Daniel",
takenSource: "automatic",
});
await insertDose({ userId, doseId: "1-0-1735430400000" });
await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" });
const response = await ctx.app.inject({
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(2);
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
// Each dose should have a takenAt timestamp
for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number");
expect(dose.takenAt).toBeGreaterThan(0);
expect(dose.markedBy).toBeNull();
}
});
it("should include markedBy when present", async () => {
const doseId = "1-0-1735344000000";
// Insert directly with markedBy
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
});
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].markedBy).toBe("Daniel");
expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([
"1-0-1735344000000",
"1-0-1735430400000",
]);
expect(data.doses).toEqual(
expect.arrayContaining([
expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }),
expect.objectContaining({ markedBy: null, takenSource: "manual" }),
])
);
});
});
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId
// ---------------------------------------------------------------------------
describe("DELETE /doses/taken/:doseId", () => {
it("should unmark a dose", async () => {
it("unmarks an existing dose", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark first
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Verify marked
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].count).toBe(1);
// Unmark
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify unmarked
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(0);
expect(Number(countResult.rows[0].count)).toBe(0);
});
it("should succeed even if dose was not marked", async () => {
const doseId = "nonexistent-dose-id";
it("keeps the record when the dose is dismissed", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]);
});
it("should preserve dismissed status when unmarking a dose", async () => {
const doseId = "1-0-1735344000000";
// First dismiss the dose
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Verify it's dismissed
let result = await ctx.client.execute({
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].dismissed).toBe(1);
const originalTakenAt = result.rows[0].taken_at;
// Now try to unmark it (undo) - should keep the dismissed record
const response = await ctx.app.inject({
it("still succeeds when the dose does not exist", async () => {
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
url: "/doses/taken/nonexistent-dose-id",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify the record still exists and is still dismissed
result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
});
});
// ---------------------------------------------------------------------------
// Dose ID Format Tests
// ---------------------------------------------------------------------------
describe("Dose ID Format", () => {
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
const doseId = "5-0-1735344000000";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
const doseId = "5-0-1735344000000-Daniel";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle special characters in dose ID", async () => {
// Dose ID with URL-unsafe characters (edge case)
const doseId = "5-0-1735344000000-Max Müller";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
// Can retrieve it
const getResponse = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
it("dismisses multiple doses", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
headers: { cookie: cookieHeader },
payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1",
args: [userId],
});
expect(result.rows.length).toBe(2);
expect(Number(result.rows[0].count)).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
it("does not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
@@ -489,54 +414,71 @@ describe("Dose Tracking API", () => {
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
it("converts a taken dose into a dismissed one", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: false });
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]);
});
it("rejects missing or empty doseIds", async () => {
const emptyResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [] },
});
expect(emptyResponse.statusCode).toBe(400);
expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" });
const missingResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: {},
});
expect(missingResponse.statusCode).toBe(400);
expect(missingResponse.json()).toEqual({ error: "Required" });
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" });
const response = await app.inject({
method: "DELETE",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, clearedCount: 2 });
const rows = await testClient.execute({
sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
args: [userId],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }),
]);
});
});
});
+268 -5
View File
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -145,6 +146,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
@@ -247,7 +249,7 @@ describe("E2E Tests with Real Routes", () => {
await createSchema(testClient);
// Build app with real routes
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
@@ -345,6 +347,37 @@ describe("E2E Tests with Real Routes", () => {
usedPrescription: true,
});
});
it("should not include refill history entries from another user for the same medication", async () => {
const medId = await createMedication(testClient, userId, "Report Isolation Med", ["Daniel"]);
const otherUserId = await _createUser(testClient, "report-isolation-other-user");
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, 1735603200],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, otherUserId, 9, 99, 1, 1735689600],
});
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].refills).toHaveLength(1);
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 0,
usedPrescription: false,
});
});
});
afterAll(async () => {
@@ -503,6 +536,93 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(404);
});
it("should return shared medication overview for a valid token", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type, pack_count, blisters_per_pack, pills_per_blister,
package_amount_value, package_amount_unit, total_pills, loose_tablets, medication_form,
usage_json, every_json, start_json
) VALUES (?, ?, ?, 'tube', 2, 1, 1, 40, 'g', 80, 80, 'topical', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
args: [userId, "Hydrogel", JSON.stringify(["Daniel"])],
});
const token = "abcdef0123456789";
await createShareToken(testClient, userId, "Daniel", token);
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
const data = response.json();
expect(data.takenBy).toBe("Daniel");
expect(data.sharedBy).toBe("__anonymous__");
expect(Array.isArray(data.medications)).toBe(true);
expect(data.medications).toHaveLength(2);
expect(data.medications[0].name).toBe("Aspirin");
expect(data.medications[0].currentStock).toBeTypeOf("number");
const hydrogel = data.medications.find((med: { name: string }) => med.name === "Hydrogel");
expect(hydrogel).toMatchObject({
packageType: "tube",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
totalPills: 80,
});
});
it("should return 404 for unknown overview token", async () => {
const response = await app.inject({
method: "GET",
url: "/share/abcdef0123456789/overview",
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ error: "not_found" });
});
it("should return 410 for expired overview token", async () => {
const token = "fedcba9876543210";
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)",
args: [userId, token, "Daniel", Math.floor(Date.now() / 1000) - 60],
});
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(410);
const data = response.json();
expect(data.error).toBe("expired");
expect(data.expiredAt).toBeTypeOf("string");
});
it("should always show stock fields in overview regardless of share_stock_status setting", async () => {
await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]);
const token = "0123456789abcdef";
await createShareToken(testClient, userId, "Daniel", token);
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, share_stock_status, low_stock_days) VALUES (?, 0, 30)",
args: [userId],
});
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(200);
const [medication] = response.json().medications;
expect(medication.currentStock).toBeTypeOf("number");
expect(medication.capacity).toBeTypeOf("number");
});
});
// ---------------------------------------------------------------------------
@@ -834,7 +954,7 @@ describe("E2E Tests with Real Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
});
it("should create and update language via lightweight language endpoint", async () => {
@@ -1929,6 +2049,47 @@ describe("E2E Tests with Real Routes", () => {
expect(hasLooseRefill).toBe(true);
});
it("should not return refill history entries from another user for the same medication", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Isolation Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const otherUserId = await _createUser(testClient, "refill-isolation-other-user");
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, 3, 0, 1735603200],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, otherUserId, 8, 88, 1, 1735689600],
});
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const refills = response.json();
expect(refills).toHaveLength(1);
expect(refills[0]).toMatchObject({
packsAdded: 2,
loosePillsAdded: 3,
usedPrescription: false,
});
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "GET",
@@ -2302,6 +2463,28 @@ describe("E2E Tests with Real Routes", () => {
payload: {
emailEnabled: true,
notificationEmail: "test@example.com",
reminderDaysBefore: 7,
repeatDailyReminders: false,
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",
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
@@ -2342,7 +2525,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -2506,10 +2688,10 @@ describe("E2E Tests with Real Routes", () => {
});
// ---------------------------------------------------------------------------
// Package Type (blister, bottle, liquid_container) Tests
// Package Type (blister, bottle, tube, liquid_container) Tests
// ---------------------------------------------------------------------------
describe("Package type handling (blister, bottle, liquid_container)", () => {
describe("Package type handling (blister, bottle, tube, liquid_container)", () => {
const bottleMedication = {
name: "Vitamin D Drops",
packageType: "bottle",
@@ -2542,6 +2724,21 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const tubeMedication = {
name: "Topical Cream",
medicationForm: "topical",
packageType: "tube",
doseUnit: "units",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
it("should create and return bottle type medication", async () => {
const response = await app.inject({
method: "POST",
@@ -2698,6 +2895,72 @@ describe("E2E Tests with Real Routes", () => {
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
});
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 180,
totalPills: 180,
looseTablets: 180,
},
});
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 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
expect(refillData.refill.loosePillsAdded).toBe(180);
expect(refillData.refill.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.totalPills).toBe(360);
expect(med.looseTablets).toBe(360);
});
it("should keep tube refill additive and preserve amount baseline", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: tubeMedication,
});
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 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
expect(refillData.refill.loosePillsAdded).toBe(40);
expect(refillData.refill.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.totalPills).toBe(120);
expect(med.looseTablets).toBe(120);
});
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -0,0 +1,283 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { db } from "../db/client.js";
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
vi.mock("../db/client.js", () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
},
}));
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function mockSelectWhere<T>(result: T) {
return {
from: () => ({
where: async () => result,
}),
} as never;
}
describe("checkAndSendIntakeRemindersForUser", () => {
const mockedDb = vi.mocked(db);
let originalTz: string | undefined;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
originalTz = process.env.TZ;
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
if (originalTz === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTz;
}
});
it("auto-marks due intakes in automatic mode even when all intake reminder channels are disabled", async () => {
const insertedRows: Array<Record<string, unknown>> = [];
const selectMock = vi.mocked(mockedDb.select);
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
({
values: async (row: Record<string, unknown>) => {
insertedRows.push(row);
},
}) as never
);
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "automatic",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: false,
shoutrrrUrl: null,
shoutrrrIntakeReminders: false,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(insertedRows).toHaveLength(1);
expect(insertedRows[0]).toMatchObject({
userId: 11,
doseId: `7-0-${new Date(2026, 0, 5).getTime()}`,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-mark completed for userId=11: inserted=1");
});
it("does not auto-mark due intakes when current stock is empty", async () => {
const insertedRows: Array<Record<string, unknown>> = [];
const selectMock = vi.mocked(mockedDb.select);
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: false,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: false,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
({
values: async (row: Record<string, unknown>) => {
insertedRows.push(row);
},
}) as never
);
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "automatic",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: false,
shoutrrrUrl: null,
shoutrrrIntakeReminders: false,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(insertedRows).toHaveLength(0);
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
});
it("suppresses intake notifications entirely when automatic mode and skip-taken reminders are both enabled", async () => {
const insertedRows: Array<Record<string, unknown>> = [];
const selectMock = vi.mocked(mockedDb.select);
const insertMock = vi.mocked(mockedDb.insert);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Vitamin D",
genericName: null,
takenByJson: null,
packageType: "blister",
medicationForm: "tablet",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: 0,
pillWeightMg: null,
doseUnit: "mg",
isObsolete: false,
intakeRemindersEnabled: true,
intakesJson: JSON.stringify([
{
usage: 1,
every: 1,
start: "2026-01-05T08:00:00.000Z",
takenBy: null,
intakeRemindersEnabled: true,
},
]),
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]))
.mockImplementationOnce(() => mockSelectWhere([]));
insertMock.mockImplementation(
() =>
({
values: async (row: Record<string, unknown>) => {
insertedRows.push(row);
},
}) as never
);
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "automatic",
skipRemindersForTakenDoses: true,
emailEnabled: true,
notificationEmail: "user@example.com",
emailIntakeReminders: true,
shoutrrrEnabled: false,
shoutrrrUrl: null,
shoutrrrIntakeReminders: false,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(insertedRows).toHaveLength(1);
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Sending reminder for 1 intakes...");
expect(logger.error).not.toHaveBeenCalled();
});
});
+47 -58
View File
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -139,6 +140,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
@@ -203,7 +205,7 @@ describe("Integration Tests", () => {
beforeAll(async () => {
await createSchema(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
@@ -253,6 +255,9 @@ describe("Integration Tests", () => {
url: "/medications",
payload: {
name: "Test Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
@@ -306,6 +311,9 @@ describe("Integration Tests", () => {
url: "/medications",
payload: {
name: "Test Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
},
});
@@ -344,6 +352,9 @@ describe("Integration Tests", () => {
url: "/medications",
payload: {
name: "Test Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
@@ -405,6 +416,9 @@ describe("Integration Tests", () => {
url: "/medications",
payload: {
name: "Weekly Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
},
});
@@ -542,6 +556,9 @@ describe("Integration Tests", () => {
url: "/medications",
payload: {
name: "Interval Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
},
});
@@ -596,6 +613,9 @@ describe("Integration Tests", () => {
payload: {
name: "Aspirin",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
@@ -922,17 +942,17 @@ describe("Integration Tests", () => {
// ---------------------------------------------------------------------------
describe("Planner usage calculation", () => {
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
const futureDailyStart = "2030-01-15T08:00:00.000Z";
const futureEveningStart = "2030-01-15T20:00:00.000Z";
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
it("should calculate correct usage for daily medication", async () => {
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
// Schedule: 1 pill daily starting tomorrow (future date)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
// This avoids daylight-saving-time edge cases in local test environments.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -952,8 +972,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr, // 10 days
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -968,15 +988,8 @@ describe("Integration Tests", () => {
it("should detect insufficient stock", async () => {
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
// Schedule: 1 pill daily starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -996,8 +1009,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1009,15 +1022,8 @@ describe("Integration Tests", () => {
it("should calculate weekly medication usage correctly", async () => {
// Create medication: 10 pills total
// Schedule: 1 pill every 7 days starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1036,8 +1042,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: thirtyFiveDayPlanEnd,
},
});
@@ -1050,18 +1056,8 @@ describe("Integration Tests", () => {
it("should handle multiple intake schedules per medication", async () => {
// Create medication with morning and evening doses
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const morningStart = tomorrow.toISOString();
const eveningStart = new Date(tomorrow);
eveningStart.setHours(20, 0, 0, 0);
const eveningStartStr = eveningStart.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const morningStart = futureDailyStart;
const eveningStartStr = futureEveningStart;
await app.inject({
method: "POST",
@@ -1083,8 +1079,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: morningStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1096,14 +1092,7 @@ describe("Integration Tests", () => {
it("should calculate correct blisters needed", async () => {
// 10 pills per blister, need 25 pills → need 3 blisters
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1122,8 +1111,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -0,0 +1,541 @@
import sensible from "@fastify/sensible";
import Fastify, { type FastifyInstance } from "fastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { fetchMock, requireAuthMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
requireAuthMock: vi.fn(async () => {}),
}));
vi.mock("../plugins/auth.js", () => ({
requireAuth: requireAuthMock,
}));
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
function createEmaRow(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
return {
category: "Human",
medicine_status: "Authorised",
name_of_medicine: "Aspirin 500 mg tablets",
international_non_proprietary_name_common_name: "Acetylsalicylic acid",
active_substance: "Acetylsalicylic acid",
marketing_authorisation_developer_applicant_holder: "Bayer",
therapeutic_area_mesh: "Pain",
therapeutic_indication: "Pain relief",
atc_code_human: "N02BA01",
generic_or_hybrid: "No",
biosimilar: "No",
marketing_authorisation_date: "01/02/2024",
ema_product_number: "EMA-ASPIRIN",
...overrides,
};
}
async function buildApp(): Promise<FastifyInstance> {
const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js");
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(medicationEnrichmentRoutes);
await app.ready();
return app;
}
describe("medication enrichment", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
fetchMock.mockReset();
requireAuthMock.mockReset();
requireAuthMock.mockImplementation(async () => {});
vi.stubGlobal("fetch", fetchMock);
});
it("normalizes German ingredient queries for EMA-backed search results", async () => {
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("medicines-output-medicines_json-report_en.json")) {
return Promise.resolve(
jsonResponse([
createEmaRow({
name_of_medicine: "Tylenol 500 mg tablets",
international_non_proprietary_name_common_name: "Acetaminophen",
active_substance: "Acetaminophen",
ema_product_number: "EMA-TYLENOL",
}),
createEmaRow({
name_of_medicine: "Ibuprofen 400 mg tablets",
international_non_proprietary_name_common_name: "Ibuprofen",
active_substance: "Ibuprofen",
ema_product_number: "EMA-IBUPROFEN",
}),
])
);
}
if (url.includes("/drugs.json?name=")) {
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(jsonResponse({ results: [] }));
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5);
expect(response.normalizedQuery).toBe("paracetamol 500 mg");
expect(response.results).toHaveLength(1);
expect(response.results[0]).toMatchObject({
code: "EMA-TYLENOL",
name: "Tylenol 500 mg tablets",
matchType: "ingredient",
source: "ema",
});
});
it("requires auth and returns EMA search results from the route", async () => {
const app = await buildApp();
fetchMock.mockImplementation((url: string) => {
if (url.includes("/drugs.json?name=")) {
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(jsonResponse({ results: [] }));
}
if (url.includes("medicines-output-medicines_json-report_en.json")) {
return Promise.resolve(jsonResponse([createEmaRow()]));
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await app.inject({
method: "GET",
url: "/medication-enrichment/search?q=aspirin&limit=1",
});
expect(response.statusCode).toBe(200);
expect(requireAuthMock).toHaveBeenCalledTimes(1);
expect(response.json()).toMatchObject({
query: "aspirin",
normalizedQuery: "aspirin",
hasMore: false,
results: [
{
code: "EMA-ASPIRIN",
name: "Aspirin 500 mg tablets",
source: "ema",
},
],
});
await app.close();
});
it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => {
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("medicines-output-medicines_json-report_en.json")) {
return Promise.resolve(jsonResponse([createEmaRow()]));
}
if (url.includes("/drugs.json?name=semaglutide")) {
return Promise.resolve(
jsonResponse({
drugGroup: {
conceptGroup: [
{
tty: "SBD",
conceptProperties: [
{
rxcui: "12345",
name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]",
synonym: "Wegovy 0.25 mg oral tablet",
},
],
},
],
},
})
);
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(
jsonResponse({
results: [
{
product_ndc: "00011-1111",
brand_name: "Ozempic",
generic_name: "Semaglutide",
dosage_form: "Tablet",
marketing_start_date: "20240101",
},
],
})
);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await searchMedicationEnrichment("Semaglutide", 3);
expect(response.hasMore).toBe(false);
expect(response.results).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "12345",
name: "Wegovy",
genericName: "Semaglutide",
source: "rxnorm",
}),
expect.objectContaining({
code: "00011-1111",
name: "Ozempic",
genericName: "Semaglutide",
source: "openfda",
}),
])
);
});
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("medicines-output-medicines_json-report_en.json")) {
return Promise.resolve(jsonResponse([createEmaRow()]));
}
if (url.includes("/drugs.json?name=")) {
return Promise.resolve(
jsonResponse({
drugGroup: {
conceptGroup: [
{
tty: "SBD",
conceptProperties: [
{
rxcui: "1191",
name: "Aspirin 500 MG Oral Tablet [Aspirin]",
synonym: "Aspirin 500 mg oral tablet",
},
],
},
],
},
})
);
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(
jsonResponse({
results: [
{
product_ndc: "00011-1111",
brand_name: "Bayer Aspirin",
generic_name: "Acetylsalicylic acid",
dosage_form: "Tablet",
marketing_start_date: "20240101",
},
],
})
);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await searchMedicationEnrichment("Aspirin", 3);
expect(response.hasMore).toBe(false);
expect(response.results).toHaveLength(3);
expect(response.results[0]).toMatchObject({
code: "1191",
source: "rxnorm",
});
expect(response.results[1]).toMatchObject({
code: "00011-1111",
source: "openfda",
});
expect(response.results[2]).toMatchObject({
code: "EMA-ASPIRIN",
source: "ema",
});
});
it("validates malformed search requests", async () => {
const app = await buildApp();
const response = await app.inject({
method: "GET",
url: "/medication-enrichment/search?q=",
});
expect(response.statusCode).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
await app.close();
});
it("returns enrichment suggestions with optional RxNorm strength data", async () => {
const app = await buildApp();
fetchMock
.mockResolvedValueOnce(
jsonResponse([
createEmaRow({
name_of_medicine: "Tylenol 500 mg tablets",
international_non_proprietary_name_common_name: "Acetaminophen",
active_substance: "Acetaminophen",
ema_product_number: "EMA-TYLENOL",
}),
])
)
.mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } }))
.mockResolvedValueOnce(
jsonResponse({
relatedGroup: {
conceptGroup: [
{
conceptProperties: [
{ name: "Acetaminophen 500 MG Oral Tablet" },
{ name: "Acetaminophen 650 MG Oral Tablet" },
],
},
],
},
})
);
const response = await app.inject({
method: "POST",
url: "/medication-enrichment/enrich",
payload: {
query: "Paracetamol",
name: "Tylenol 500 mg tablets",
genericName: "Acetaminophen",
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
selection: {
name: "Tylenol 500 mg tablets",
genericName: "Acetaminophen",
source: "ema+rxnorm",
},
suggestions: {
medicationForm: "tablet",
strengthOptions: [
{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" },
{ label: "650 mg", pillWeightMg: 650, doseUnit: "mg" },
],
},
meta: {
rxNormMatched: true,
openFdaMatched: false,
partial: false,
note: null,
},
});
await app.close();
});
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("medicines-output-medicines_json-report_en.json")) {
return Promise.resolve(
jsonResponse([
createEmaRow({
name_of_medicine: "Tylenol 500 mg tablets",
international_non_proprietary_name_common_name: "Acetaminophen",
active_substance: "Acetaminophen",
ema_product_number: "EMA-TYLENOL",
}),
])
);
}
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
return Promise.reject(new Error("rxnorm timeout"));
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(jsonResponse({ results: [] }));
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await enrichMedicationSelection({
query: "Paracetamol",
name: "Tylenol 500 mg tablets",
genericName: "Acetaminophen",
});
expect(response.selection.source).toBe("ema");
expect(response.suggestions.strengthOptions).toEqual([]);
expect(response.meta).toEqual({
rxNormMatched: false,
openFdaMatched: false,
partial: true,
note: "Returned EMA enrichment without RxNorm suggestions.",
});
});
it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => {
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("/rxcui/12345/related.json")) {
return Promise.resolve(
jsonResponse({
relatedGroup: {
conceptGroup: [],
},
})
);
}
if (url.includes("api.fda.gov/drug/ndc.json")) {
return Promise.resolve(
jsonResponse({
results: [
{
product_ndc: "00011-1111",
brand_name: "Ozempic",
generic_name: "Semaglutide",
dosage_form: "Tablet",
active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }],
},
],
})
);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await enrichMedicationSelection({
query: "Ozempic",
name: "Ozempic",
genericName: "Semaglutide",
code: "12345",
source: "rxnorm",
});
expect(response).toMatchObject({
selection: {
name: "Ozempic",
genericName: "Semaglutide",
source: "rxnorm+openfda",
},
suggestions: {
medicationForm: "tablet",
strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }],
},
meta: {
rxNormMatched: false,
openFdaMatched: true,
partial: false,
note: null,
},
});
});
it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => {
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
fetchMock.mockImplementation((url: string) => {
if (url.includes("search=product_ndc%3A%2200011-1111%22")) {
return Promise.resolve(
jsonResponse({
results: [
{
product_ndc: "00011-1111",
brand_name: "US Ibuprofen",
generic_name: "Ibuprofen",
dosage_form: "Tablet",
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
},
],
})
);
}
if (url.includes("/rxcui.json?name=ibuprofen&search=2")) {
return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } }));
}
if (url.includes("/rxcui/161/related.json")) {
return Promise.resolve(
jsonResponse({
relatedGroup: {
conceptGroup: [
{
conceptProperties: [
{ name: "Ibuprofen 200 MG Oral Tablet" },
{ name: "Ibuprofen 400 MG Oral Tablet" },
],
},
],
},
})
);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
const response = await enrichMedicationSelection({
query: "US Ibuprofen",
name: "US Ibuprofen",
genericName: "Ibuprofen",
code: "00011-1111",
source: "openfda",
});
expect(response).toMatchObject({
selection: {
name: "US Ibuprofen",
genericName: "Ibuprofen",
source: "rxnorm+openfda",
},
suggestions: {
medicationForm: "tablet",
strengthOptions: [
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
],
},
meta: {
rxNormMatched: true,
openFdaMatched: true,
partial: false,
note: null,
},
});
});
it("returns not found when an explicit selection cannot be resolved", async () => {
const app = await buildApp();
fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()]));
const response = await app.inject({
method: "POST",
url: "/medication-enrichment/enrich",
payload: {
query: "Unknown",
name: "Completely Different Medication",
genericName: "No match",
},
});
expect(response.statusCode).toBe(404);
expect(response.json()).toMatchObject({
code: "MEDICATION_ENRICHMENT_NOT_FOUND",
error: "Selected medication could not be resolved.",
});
await app.close();
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import cookie from "@fastify/cookie";
import Fastify from "fastify";
import { afterEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
type OidcMocks = {
discovery: ReturnType<typeof vi.fn>;
@@ -54,7 +55,7 @@ async function buildOidcApp(envOverrides: Record<string, unknown>) {
const { oidcRoutes } = await import("../routes/oidc.js");
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
app.decorate("config", {
accessSecret: "test-jwt-secret-12345",
+3 -1
View File
@@ -1,6 +1,7 @@
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Create test database and mocks before anything else (hoisted)
const {
@@ -156,6 +157,7 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
share_medication_overview integer NOT NULL DEFAULT 0,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
@@ -214,7 +216,7 @@ describe("Planner Routes", () => {
args: [],
});
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(plannerRoutes);
await app.ready();
+295 -5
View File
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -45,7 +46,9 @@ vi.mock("nodemailer", () => ({
},
}));
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
const { settingsRoutes, sendShoutrrrNotification, loadUserSettings, getAllUserSettings } = await import(
"../routes/settings.js"
);
const { exportRoutes } = await import("../routes/export.js");
const { reportRoutes } = await import("../routes/report.js");
@@ -106,7 +109,7 @@ describe("Real route coverage: settings/export/report", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(settingsRoutes);
await app.register(exportRoutes);
await app.register(reportRoutes);
@@ -137,11 +140,76 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.language).toBe("en");
expect(body.shareStockStatus).toBe(true);
expect(body.upcomingTodayOnly).toBe(false);
expect(body.shareScheduleTodayOnly).toBe(false);
});
it("GET /settings returns a non-empty serialized payload with SMTP fields", async () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_PORT = "2525";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_FROM = "MedAssist <mailer@example.com>";
process.env.SMTP_PASS = "secret";
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
notificationEmail: "person@example.com",
reminderDaysBefore: 5,
repeatDailyReminders: true,
lowStockDays: 14,
normalStockDays: 45,
highStockDays: 90,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 20,
maxNaggingReminders: 4,
language: "en",
stockCalculationMode: "manual",
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
},
});
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(200);
expect(response.body).not.toBe("{}");
const body = response.json();
expect(body).toEqual(
expect.objectContaining({
emailEnabled: true,
notificationEmail: "person@example.com",
reminderDaysBefore: 5,
repeatDailyReminders: true,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 20,
maxNaggingReminders: 4,
stockCalculationMode: "manual",
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
smtpHost: "smtp.example.com",
smtpPort: 2525,
smtpUser: "mailer@example.com",
smtpFrom: "MedAssist <mailer@example.com>",
hasSmtpPassword: true,
})
);
});
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
const response = await app.inject({
method: "PUT",
@@ -168,7 +236,6 @@ describe("Real route coverage: settings/export/report", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -190,7 +257,30 @@ describe("Real route coverage: settings/export/report", () => {
payload: { language: "fr" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
});
it("PUT /settings/language creates and updates the stored language", async () => {
let response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "de" },
});
expect(response.statusCode).toBe(200);
response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "en" },
});
expect(response.statusCode).toBe(200);
const stored = await testClient.execute({
sql: "SELECT language FROM user_settings WHERE user_id = 1",
});
expect(stored.rows[0].language).toBe("en");
});
it("POST /settings/test-email fails when SMTP is not configured", async () => {
@@ -224,6 +314,22 @@ describe("Real route coverage: settings/export/report", () => {
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
});
it("POST /settings/test-email maps generic transport failures to HTTP 500", async () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockRejectedValue(new Error("socket hang up"));
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(500);
expect(response.json()).toMatchObject({ code: "TEST_EMAIL_FAILED" });
});
it("POST /settings/test-shoutrrr validates URL presence", async () => {
const response = await app.inject({
method: "POST",
@@ -233,6 +339,30 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(400);
});
it("POST /settings/test-shoutrrr returns 500 when notification delivery fails", async () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-shoutrrr",
payload: { url: "ftp://invalid.example.com/topic" },
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toMatch(/Only HTTP\/HTTPS protocols are allowed|Unsupported URL format/);
});
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
fetchMock.mockResolvedValue({ ok: true });
const response = await app.inject({
method: "POST",
url: "/settings/test-shoutrrr",
payload: { url: "ntfy://ntfy.sh/medassist" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
});
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
expect(result.success).toBe(false);
@@ -266,6 +396,166 @@ describe("Real route coverage: settings/export/report", () => {
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
});
it("sendShoutrrrNotification returns HTTP response errors for ntfy-style endpoints", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("rate limited") });
const result = await sendShoutrrrNotification("https://ntfy.sh/medassist", "Title", "Body");
expect(result).toEqual({ success: false, error: "HTTP 429: rate limited" });
});
it("sendShoutrrrNotification rejects invalid Discord webhook identifiers", async () => {
const result = await sendShoutrrrNotification("discord://bad-token@not-a-number", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Discord webhook ID" });
});
it("sendShoutrrrNotification validates Pushover URL credentials", async () => {
const result = await sendShoutrrrNotification("pushover://missing-token", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Pushover URL format" });
});
it("sendShoutrrrNotification requires Telegram chats and validates tokens", async () => {
let result = await sendShoutrrrNotification("telegram://123:abc@telegram", "Title", "Body");
expect(result).toEqual({ success: false, error: "Telegram URL requires chats parameter" });
result = await sendShoutrrrNotification("telegram://invalid@telegram?chats=123", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Telegram token format" });
});
it("sendShoutrrrNotification converts Gotify URLs and supports disabletls", async () => {
fetchMock.mockResolvedValue({ ok: true });
const result = await sendShoutrrrNotification(
"gotify://push.example.com/basepath/token123?disabletls=yes&priority=8",
"Title",
"Body"
);
expect(result).toEqual({ success: true });
const [targetUrl, requestInit] = fetchMock.mock.calls[0];
expect(targetUrl).toBe("http://push.example.com/basepath/message?token=token123");
expect(requestInit.body).toBe("Body\n\n(priority=8)");
expect(requestInit.headers).toMatchObject({ Tags: "pill" });
});
it("loadUserSettings creates defaults for users without settings", async () => {
const settings = await loadUserSettings(1);
expect(settings).toEqual(
expect.objectContaining({
userId: 1,
emailEnabled: false,
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
})
);
});
it("loadUserSettings maps persisted settings", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, skip_reminders_for_taken_doses,
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
upcoming_today_only, share_schedule_today_only, swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
1,
1,
"person@example.com",
1,
1,
1,
0,
null,
1,
1,
1,
4,
0,
12,
30,
90,
"de",
"manual",
1,
0,
0,
30,
5,
0,
0,
0,
],
});
const settings = await loadUserSettings(1);
expect(settings).toEqual(
expect.objectContaining({
notificationEmail: "person@example.com",
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
stockCalculationMode: "manual",
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
})
);
});
it("getAllUserSettings returns mapped entries for each persisted user", async () => {
await testClient.execute({
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
args: [2, "second-user", "local"],
});
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [1, 0, null, 1, 1, 1, 1, "ntfy://ntfy.sh/topic", 1, 1, 1, 7, 1, 30, 60, 120, "en", "manual", 1, 1, 0, 1],
});
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [2, 1, "second@example.com", 0, 1, 1, 0, null, 1, 1, 1, 10, 0, 20, 50, 100, "de", "automatic", 1, 0, 0, 0],
});
const allSettings = await getAllUserSettings();
expect(allSettings).toHaveLength(2);
expect(allSettings).toEqual(
expect.arrayContaining([
expect.objectContaining({ userId: 1, stockCalculationMode: "manual", upcomingTodayOnly: true }),
expect.objectContaining({
userId: 2,
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
}),
])
);
});
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
await seedMedication("Owned Med");
const response = await app.inject({
+9 -7
View File
@@ -6,6 +6,7 @@ import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, describe, expect, it } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Import from utils to avoid index.ts import side effects (server start)
import {
@@ -197,6 +198,7 @@ describe("Server Bootstrap", () => {
logger: {
level: "silent", // Disable logging for tests
},
ajv: documentationSchemaAjv,
});
expect(app).toBeDefined();
@@ -206,7 +208,7 @@ describe("Server Bootstrap", () => {
});
it("should register sensible plugin", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
// Sensible adds error helpers
@@ -219,7 +221,7 @@ describe("Server Bootstrap", () => {
it("should register cors plugin with multiple origins", async () => {
const origins = ["http://localhost:5173", "http://localhost:4173"];
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cors, { origin: origins, credentials: true });
// Add a test route
@@ -243,7 +245,7 @@ describe("Server Bootstrap", () => {
});
it("should register cookie plugin", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
// Add a test route that sets a cookie
@@ -267,7 +269,7 @@ describe("Server Bootstrap", () => {
describe("Config Decorator", () => {
it("should create config with auth settings", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
const accessTtlMinutes = 15;
const refreshTtlDays = 7;
@@ -369,7 +371,7 @@ describe("Server Bootstrap", () => {
describe("Route Registration", () => {
it("should register multiple route plugins", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
// Mock route plugins
const healthRoutes = async (app: FastifyInstance) => {
@@ -402,7 +404,7 @@ describe("Server Bootstrap", () => {
describe("Server Startup", () => {
it("should listen on specified port", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
app.get("/test", async () => ({ ok: true }));
@@ -415,7 +417,7 @@ describe("Server Bootstrap", () => {
});
it("should handle listen errors gracefully", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
// Try to listen on an invalid port
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
+162 -2
View File
@@ -6,21 +6,30 @@ import {
calculateDailyUsage,
calculateDepletionInfo,
cleanOldIntakeReminders,
countScheduledOccurrencesInRange,
createDefaultIntakeReminderState,
createDefaultReminderState,
forEachScheduledOccurrenceInRange,
formatInTimezone,
getAverageOccurrencesPerDay,
getCurrentHourInTimezone,
getMaxScheduledGapDays,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
normalizeIntake,
parseBlisters,
parseIntakeReminderState,
parseIntakesJson,
parseReminderState,
parseTakenByJson,
personTakesMedication,
type Weekday,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
@@ -151,6 +160,16 @@ describe("Scheduler Utils - Timezone Functions", () => {
});
});
describe("Scheduler Utils - Sharing", () => {
it("treats the all-share sentinel as matching intake-specific assignees", () => {
const intakes = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, "Max")];
expect(personTakesMedication("all", [], intakes)).toBe(true);
expect(personTakesMedication("Max", [], intakes)).toBe(true);
expect(personTakesMedication("Anna", [], intakes)).toBe(false);
});
});
describe("Scheduler Utils - Blister Parsing", () => {
describe("parseBlisters", () => {
it("should parse valid blister JSON arrays", () => {
@@ -256,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => {
});
});
describe("Scheduler Utils - Intake Schedule Normalization", () => {
describe("normalizeIntake", () => {
it("keeps interval schedules backward-compatible by default", () => {
const intake = normalizeIntake({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
});
expect(intake).toMatchObject({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
});
});
it("normalizes malformed weekday schedules to the start date weekday", () => {
const intake = normalizeIntake({
usage: 1,
every: 99,
start: "2025-01-06T08:00:00",
scheduleMode: "weekdays",
weekdays: ["bogus", null],
});
expect(intake.scheduleMode).toBe("weekdays");
expect(intake.every).toBe(1);
expect(intake.weekdays).toEqual(["mon"]);
});
});
describe("parseIntakesJson", () => {
it("falls back to legacy interval data when unified intakes are absent", () => {
const intakes = parseIntakesJson(
null,
{
usageJson: "[1,2]",
everyJson: "[1,3]",
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
},
true
);
expect(intakes).toEqual([
{
usage: 1,
every: 1,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
{
usage: 2,
every: 3,
start: "2025-01-02T20:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
]);
});
});
});
describe("Scheduler Utils - Daily Usage Calculation", () => {
describe("calculateDailyUsage", () => {
it("should calculate daily usage for single daily dose", () => {
@@ -295,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
});
});
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
it("calculates average usage and gap length for weekday schedules", () => {
const weekdaysSchedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
});
it("finds the next weekday occurrence after a given timestamp", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
});
it("iterates weekday occurrences in canonical order within a range", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
};
const occurrences: number[] = [];
forEachScheduledOccurrenceInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime(),
(occurrenceMs) => {
occurrences.push(occurrenceMs);
}
);
expect(occurrences.sort((a, b) => a - b)).toEqual([
new Date(2025, 0, 6, 9, 0, 0).getTime(),
new Date(2025, 0, 8, 9, 0, 0).getTime(),
new Date(2025, 0, 10, 9, 0, 0).getTime(),
]);
expect(
countScheduledOccurrencesInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime()
)
).toEqual({
count: 3,
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
});
});
});
describe("Scheduler Utils - Depletion Calculation", () => {
describe("calculateDepletionInfo", () => {
it("should calculate days left correctly", () => {
@@ -367,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(result[0].pillWeightMg).toBe(500);
});
it("should skip blisters with zero interval", () => {
it("should treat zero interval as a daily fallback", () => {
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
medName: "TestMed",
usage: 1,
takenBy: null,
});
});
it("should handle multiple blisters", () => {
@@ -0,0 +1,395 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
nodemailerSendMail: vi.fn(),
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("nodemailer", () => ({
default: {
createTransport: () => ({
sendMail: nodemailerSendMail,
}),
},
}));
const { settingsRoutes } = await import("../routes/settings.js");
const { apiKeyRoutes } = await import("../routes/api-keys.js");
const { hashApiKeyToken } = await import("../plugins/auth.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertApiKey(options: {
userId: number;
token: string;
scope?: "read" | "write";
isActive?: boolean;
expiresAt?: Date | null;
}) {
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
const result = await testClient.execute({
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
"Seeded Key",
hashApiKeyToken(options.token),
`${options.token.slice(0, 12)}...`,
options.scope ?? "write",
options.isActive === false ? 0 : 1,
expiresAtValue,
],
});
return Number(result.rows[0].id);
}
describe("Settings and API key security contracts", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(settingsRoutes);
await app.register(apiKeyRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
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("rejects GET /settings without authentication when auth is enabled", async () => {
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
});
it("returns settings defaults for an authenticated session cookie", async () => {
const userId = await createUser("settings-session-user");
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(
expect.objectContaining({
emailEnabled: false,
language: "en",
stockCalculationMode: "automatic",
})
);
});
it("allows GET /settings with a read-only API key", async () => {
const userId = await createUser("settings-read-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_PORT = "2525";
const apiToken = "ma_read_only_valid_token_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(
expect.objectContaining({
smtpHost: "smtp.example.com",
smtpPort: 2525,
})
);
});
it("rejects PUT /settings with a read-only API key", async () => {
const userId = await createUser("settings-read-mutation-user");
const apiToken = "ma_read_only_mutation_token_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "PUT",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
payload: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
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",
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
});
it("rejects invalid API key bearer tokens for GET /settings", async () => {
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: "Bearer definitely-not-a-medassist-key" },
});
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "INVALID_API_KEY" });
});
it("rejects expired API keys for GET /settings", async () => {
const userId = await createUser("settings-expired-key-user");
const apiToken = "ma_expired_token_for_settings_123456789";
await insertApiKey({
userId,
token: apiToken,
scope: "read",
expiresAt: new Date(Date.now() - 60_000),
});
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "API_KEY_EXPIRED" });
});
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
const userId = await createUser("api-key-session-user");
const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user");
const firstCreate = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
payload: { name: "Primary key", scope: "write", expiresInDays: 30 },
});
expect(firstCreate.statusCode).toBe(201);
const firstBody = firstCreate.json();
expect(firstBody.token).toMatch(/^ma_/);
const secondCreate = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
payload: { name: "Rotated key", scope: "write", expiresInDays: 30 },
});
expect(secondCreate.statusCode).toBe(201);
const secondBody = secondCreate.json();
const listResponse = await app.inject({
method: "GET",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).not.toContain(firstBody.token);
expect(listResponse.body).not.toContain(secondBody.token);
expect(listResponse.body).not.toContain("keyHash");
expect(listResponse.json().keys).toHaveLength(2);
const dbState = await testClient.execute({
sql: "SELECT name, is_active FROM api_keys WHERE user_id = ? ORDER BY id ASC",
args: [userId],
});
expect(dbState.rows).toEqual([
expect.objectContaining({ name: "Primary key", is_active: 0 }),
expect.objectContaining({ name: "Rotated key", is_active: 1 }),
]);
});
it("rejects API key rotation when authenticated with a read-only API key", async () => {
const userId = await createUser("api-key-readonly-rotate-user");
const readOnlyToken = "ma_readonly_rotation_denied_123456789";
await insertApiKey({ userId, token: readOnlyToken, scope: "read" });
const response = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { authorization: `Bearer ${readOnlyToken}` },
payload: { name: "Blocked rotation", scope: "write", expiresInDays: 30 },
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
});
it("returns 404 when deleting an API key owned by a different user", async () => {
const ownerUserId = await createUser("api-key-owner");
const otherUserId = await createUser("api-key-other-user");
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
const keyId = await insertApiKey({
userId: ownerUserId,
token: "ma_write_owner_token_123456789",
scope: "write",
});
const response = await app.inject({
method: "DELETE",
url: `/auth/api-keys/${keyId}`,
headers: { cookie: otherCookieHeader },
});
expect(response.statusCode).toBe(404);
expect(response.json()).toMatchObject({ code: "API_KEY_NOT_FOUND" });
});
it("maps SMTP recipient rejection to HTTP 400 instead of a generic 500", async () => {
const userId = await createUser("settings-email-recipient-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockResolvedValue({
accepted: [],
rejected: ["missing@example.com"],
response: "550 5.1.1 recipient address rejected",
});
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") },
payload: { email: "missing@example.com" },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "EMAIL_RECIPIENT_REJECTED" });
});
it("maps missing SMTP acceptance to HTTP 502 for test email", async () => {
const userId = await createUser("settings-email-unconfirmed-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockResolvedValue({
accepted: [],
rejected: [],
response: "250 queued without explicit acceptance",
});
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(502);
expect(response.json()).toMatchObject({ code: "SMTP_DELIVERY_UNCONFIRMED" });
});
});
+2 -61
View File
@@ -51,7 +51,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
};
}
@@ -77,7 +76,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
shareStockStatus: Boolean(s.share_stock_status ?? 1),
};
});
@@ -104,7 +102,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
}>("/settings", async (request, reply) => {
const userId = 1;
@@ -177,7 +174,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
],
});
} else {
@@ -228,7 +225,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
userId,
],
});
@@ -550,62 +547,6 @@ describe("Settings API", () => {
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
describe("Share Stock Status", () => {
it("should default to true (show stock on shared links)", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(true);
});
it("should disable share stock status", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(false);
});
it("should re-enable share stock status", async () => {
// Disable first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
// Re-enable
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: true },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
+46 -12
View File
@@ -13,6 +13,7 @@ import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
@@ -44,7 +45,7 @@ export async function buildTestApp(): Promise<TestContext> {
await runTestMigrations(client);
// Create Fastify app with minimal plugins
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
@@ -217,13 +218,20 @@ export interface UpdateUserSettingsOptions {
stockCalculationMode?: "automatic" | "manual";
lowStockDays?: number;
shareStockStatus?: boolean;
shareMedicationOverview?: boolean;
}
/**
* Create or update user settings
*/
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
const {
userId,
stockCalculationMode = "automatic",
lowStockDays = 30,
shareStockStatus,
shareMedicationOverview,
} = options;
// Check if settings exist
const existing = await client.execute({
@@ -232,20 +240,46 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting
});
if (existing.rows.length > 0) {
const updateArgs = [stockCalculationMode, lowStockDays] as Array<string | number>;
let updateSql = "UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?";
if (shareStockStatus !== undefined) {
updateSql += ", share_stock_status = ?";
updateArgs.push(shareStockStatus ? 1 : 0);
}
if (shareMedicationOverview !== undefined) {
updateSql += ", share_medication_overview = ?";
updateArgs.push(shareMedicationOverview ? 1 : 0);
}
updateSql += " WHERE user_id = ?";
updateArgs.push(userId);
await client.execute({
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
args:
shareStockStatus !== undefined
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
: [stockCalculationMode, lowStockDays, userId],
sql: updateSql,
args: updateArgs,
});
} else {
const insertColumns = ["user_id", "stock_calculation_mode", "low_stock_days"];
const insertPlaceholders = ["?", "?", "?"];
const insertArgs = [userId, stockCalculationMode, lowStockDays] as Array<string | number>;
if (shareStockStatus !== undefined) {
insertColumns.push("share_stock_status");
insertPlaceholders.push("?");
insertArgs.push(shareStockStatus ? 1 : 0);
}
if (shareMedicationOverview !== undefined) {
insertColumns.push("share_medication_overview");
insertPlaceholders.push("?");
insertArgs.push(shareMedicationOverview ? 1 : 0);
}
await client.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
args:
shareStockStatus !== undefined
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
: [userId, stockCalculationMode, lowStockDays],
sql: `INSERT INTO user_settings (${insertColumns.join(", ")}) VALUES (${insertPlaceholders.join(", ")})`,
args: insertArgs,
});
}
}
-45
View File
@@ -10,7 +10,6 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
@@ -142,14 +141,6 @@ async function registerShareRoutes(ctx: TestContext) {
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
// Get shareStockStatus setting
const shareStockResult = await client.execute({
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
args: [share.user_id],
});
const shareStockStatus =
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
return {
takenBy: share.taken_by,
sharedBy: share.owner_username,
@@ -158,7 +149,6 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -431,41 +421,6 @@ describe("Share Link API", () => {
expect(med.blisters).toHaveLength(1);
expect(med.blisters[0].usage).toBe(1);
expect(med.blisters[0].every).toBe(1);
// shareStockStatus should default to true
expect(data.shareStockStatus).toBe(true);
});
it("should respect shareStockStatus setting when disabled", async () => {
// Create medication
await createTestMedication(ctx.client, {
userId,
name: "TestMed",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
});
// Set shareStockStatus to false
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
// Create share token
const token = await createTestShareToken(ctx.client, {
userId,
takenBy: "Daniel",
scheduleDays: 30,
});
const response = await ctx.app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(false);
});
it("should return 404 for invalid token", async () => {
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -173,7 +174,7 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(medicationRoutes);
await app.ready();
});
+7 -1
View File
@@ -5,7 +5,12 @@ import "@fastify/jwt";
export interface AuthUser {
id: number;
username: string;
role: string;
}
export interface AuthContext {
method: "session" | "api_key";
scope: "read" | "write";
apiKeyId?: number;
}
declare module "fastify" {
@@ -22,6 +27,7 @@ declare module "fastify" {
interface FastifyRequest {
user?: AuthUser | null;
authContext?: AuthContext;
correlationId?: string;
}
}
@@ -0,0 +1,10 @@
import type { Plugin } from "ajv";
export const registerDocumentationSchemaKeywords: Plugin<unknown> = (ajv) => {
ajv.addKeyword({ keyword: "example", valid: true });
return ajv;
};
export const documentationSchemaAjv = {
plugins: [registerDocumentationSchemaKeywords],
};
@@ -0,0 +1,114 @@
import type { FastifyInstance, RouteOptions } from "fastify";
type SecurityEntry = Readonly<Record<string, readonly string[]>>;
const defaultProtectedSecurity: readonly SecurityEntry[] = [{ bearerAuth: [] }, { cookieAuth: [] }];
export const genericErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
} as const;
export const validationErrorSchema = {
type: "object",
additionalProperties: true,
} as const;
export const idParamsSchema = {
type: "object",
required: ["id"],
properties: {
id: { type: "string", pattern: "^\\d+$" },
},
} as const;
export const tokenParamsSchema = {
type: "object",
required: ["token"],
properties: {
token: { type: "string", minLength: 1 },
},
} as const;
export const successResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
},
} as const;
export const messageResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
} as const;
export type OpenApiRouteStandardsOptions = {
tag: string;
protectedByDefault: boolean;
protectedPaths?: RegExp[];
publicPaths?: RegExp[];
};
function asMethods(method: RouteOptions["method"]): string[] {
if (Array.isArray(method)) return method.map((m) => String(m).toUpperCase());
return [String(method).toUpperCase()];
}
function pathMatches(path: string, patterns: RegExp[] | undefined): boolean {
if (!patterns || patterns.length === 0) return false;
return patterns.some((pattern) => pattern.test(path));
}
function buildDefaultSummary(methods: string[], path: string): string {
const methodText = methods.join("/");
return `${methodText} ${path}`;
}
function buildDefaultDescription(requiresAuth: boolean): string {
return requiresAuth
? "Protected endpoint. Requires Bearer token (API key or JWT) or session cookie."
: "Public endpoint.";
}
export function applyOpenApiRouteStandards(app: FastifyInstance, options: OpenApiRouteStandardsOptions): void {
app.addHook("onRoute", (routeOptions) => {
const methods = asMethods(routeOptions.method);
const path = routeOptions.url;
const isExplicitlyPublic = pathMatches(path, options.publicPaths);
const isExplicitlyProtected = pathMatches(path, options.protectedPaths);
let requiresAuth = options.protectedByDefault;
if (isExplicitlyPublic) {
requiresAuth = false;
} else if (isExplicitlyProtected) {
requiresAuth = true;
}
routeOptions.schema = routeOptions.schema ?? {};
routeOptions.schema.tags = routeOptions.schema.tags ?? [options.tag];
routeOptions.schema.summary = routeOptions.schema.summary ?? buildDefaultSummary(methods, path);
routeOptions.schema.description = routeOptions.schema.description ?? buildDefaultDescription(requiresAuth);
if (requiresAuth) {
routeOptions.schema.security = routeOptions.schema.security ?? defaultProtectedSecurity;
routeOptions.schema.response = {
...(routeOptions.schema.response ?? {}),
401: (routeOptions.schema.response as Record<number | string, unknown> | undefined)?.[401] ?? {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
},
};
} else if (routeOptions.schema.security === undefined) {
routeOptions.schema.security = [];
}
});
}
+32
View File
@@ -0,0 +1,32 @@
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
export type PackageType = (typeof PACKAGE_TYPES)[number];
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
export function normalizePackageType(packageType?: string | null): PackageType {
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
return packageType as PackageType;
}
return "blister";
}
export function isTubePackageType(packageType?: string | null): boolean {
return normalizePackageType(packageType) === "tube";
}
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
return normalizePackageType(packageType) === "liquid_container";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
const normalized = normalizePackageType(packageType);
if (normalized === "tube") return "units";
if (normalized === "liquid_container") return "ml";
return "pills";
}
+332 -90
View File
@@ -4,15 +4,36 @@
*/
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
export type IntakeScheduleMode = "interval" | "weekdays";
type ScheduleLike = {
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
export type Blister = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
@@ -21,6 +42,278 @@ export type Intake = {
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "tbsp";
const weekdayToJavascriptDay: Record<Weekday, number> = {
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
sun: 0,
};
function isWeekday(value: unknown): value is Weekday {
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
}
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
return value === "weekdays" ? "weekdays" : "interval";
}
function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime();
}
export function getWeekdayFromDate(date: Date): Weekday {
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
return weekday ?? "mon";
}
export function getWeekdayFromStart(start: string): Weekday {
const startDate = parseLocalDateTime(start);
if (Number.isNaN(startDate.getTime())) {
return "mon";
}
return getWeekdayFromDate(startDate);
}
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
if (!Array.isArray(value)) {
return [getWeekdayFromStart(start)];
}
const uniqueWeekdays = new Set<Weekday>();
for (const weekday of value) {
if (isWeekday(weekday)) {
uniqueWeekdays.add(weekday);
}
}
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
}
function createOccurrenceAtDate(date: Date, startDate: Date): number {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds(),
startDate.getMilliseconds()
).getTime();
}
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
if (schedule.scheduleMode !== "weekdays") {
return [];
}
if (schedule.weekdays && schedule.weekdays.length > 0) {
return schedule.weekdays;
}
return [getWeekdayFromStart(schedule.start)];
}
export function getAverageOccurrencesPerDay(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode === "weekdays") {
return getNormalizedWeekdays(schedule).length / 7;
}
return 1 / Math.max(1, schedule.every);
}
export function getMaxScheduledGapDays(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode !== "weekdays") {
return Math.max(1, schedule.every);
}
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
if (weekdays.length === 0) {
return 7;
}
let maxGap = 0;
for (let index = 0; index < weekdays.length; index++) {
const current = weekdays[index];
const next = weekdays[(index + 1) % weekdays.length];
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
if (gap > maxGap) {
maxGap = gap;
}
}
return maxGap || 7;
}
export function getScheduleMatchWindowMs(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}
export function getNextScheduledOccurrenceTime(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
fromMs: number,
inclusive: boolean = true
): number | null {
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime)) {
return null;
}
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
}
const candidateStart = Math.max(lowerBound, startTime);
const candidateDateOnly = toDateOnly(new Date(candidateStart));
let nextOccurrence: number | null = null;
for (const weekday of getNormalizedWeekdays(schedule)) {
const candidateDate = new Date(candidateDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
candidateDate.setDate(candidateDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
if (occurrenceMs < candidateStart) {
candidateDate.setDate(candidateDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
}
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
nextOccurrence = occurrenceMs;
}
}
return nextOccurrence;
}
export function forEachScheduledOccurrenceInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number,
callback: (occurrenceMs: number) => void
): void {
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
return;
}
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
return;
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
}
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs);
}
}
return;
}
const lowerBound = Math.max(rangeStartMs, startTime);
const firstDateOnly = toDateOnly(new Date(lowerBound));
for (const weekday of getNormalizedWeekdays(schedule)) {
const occurrenceDate = new Date(firstDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
if (occurrenceMs < lowerBound) {
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
while (occurrenceMs <= rangeEndMs) {
callback(occurrenceMs);
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
}
}
export function countScheduledOccurrencesInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number
): { count: number; lastOccurrenceMs: number | null } {
let count = 0;
let lastOccurrenceMs: number | null = null;
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
count += 1;
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
lastOccurrenceMs = occurrenceMs;
}
});
return { count, lastOccurrenceMs };
}
export function normalizeIntake(
value: {
usage?: unknown;
every?: unknown;
start?: unknown;
scheduleMode?: unknown;
weekdays?: unknown;
intakeUnit?: unknown;
takenBy?: unknown;
intakeRemindersEnabled?: unknown;
},
defaultIntakeRemindersEnabled: boolean = false
): Intake {
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
let every = 1;
if (scheduleMode !== "weekdays") {
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
every = value.every;
}
}
return {
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
every,
start,
scheduleMode,
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
intakeRemindersEnabled:
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
};
}
/**
* Normalize intake usage for stock math.
*
@@ -36,9 +329,9 @@ export function normalizeIntakeUsageForStock(
): number {
const usage = Number(intake.usage);
if (!Number.isFinite(usage) || usage <= 0) return 0;
if (packageType === "tube") return 0;
if (isTubePackageType(packageType)) return 0;
const isLiquidStock = packageType === "liquid_container" || medicationForm === "liquid";
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;
@@ -224,15 +517,7 @@ export function parseIntakesJson(
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
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(),
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
}
} catch {
// Fall through to legacy parsing
@@ -242,14 +527,18 @@ export function parseIntakesJson(
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
return blisters.map((b) =>
normalizeIntake(
{
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null,
},
medicationIntakeRemindersEnabled ?? false
)
);
}
return [];
@@ -291,6 +580,7 @@ export function getAllTakenByForMedication(medicationTakenBy: string[], intakes:
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
*/
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
if (person === "all") return medicationTakenBy.length > 0 || intakes.some((intake) => intake.takenBy !== null);
if (medicationTakenBy.includes(person)) return true;
return intakes.some((intake) => intake.takenBy === person);
}
@@ -301,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
/** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number {
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
}
/** Calculate depletion information for a medication */
@@ -368,50 +658,31 @@ export function getTodaysIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today
let currentTime = startTime;
// If start is in the past, calculate the first occurrence on or after todayStart
if (currentTime < todayStart.getTime()) {
const elapsed = todayStart.getTime() - startTime;
const intervals = Math.floor(elapsed / intervalMs);
currentTime = startTime + intervals * intervalMs;
}
// Collect all intakes for today
while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
currentTime += intervalMs;
}
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
const intakeDate = new Date(occurrenceMs);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
});
}
return result;
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
}
/**
@@ -442,40 +713,11 @@ export function getUpcomingIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime;
// If start is in the past, calculate occurrences
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
// Check the current occurrence (today's scheduled time, even if past)
const currentOccurrence = startTime + intervals * intervalMs;
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence notification time falls in current minute and intake hasn't happened
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
// but the intake time is still in the future — include it so the advance
// reminder can still be sent rather than falling into a dead zone.
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
}
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
if (nextTime === null) continue;
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
+4
View File
@@ -1,3 +1,5 @@
name: medassist-dev
services:
backend-dev:
image: node:22-slim
@@ -10,6 +12,7 @@ services:
env_file:
- .env
environment:
- NODE_ENV=development
- DATA_DIR=/app/data
- RATE_LIMIT_MAX=1000
ports:
@@ -33,6 +36,7 @@ services:
env_file:
- .env
environment:
- NODE_ENV=development
- BACKEND_URL=http://backend-dev:3000
ports:
- "5173:5173"
+2
View File
@@ -1,3 +1,5 @@
name: medassist-ng
services:
backend:
image: ghcr.io/danielvolz/medassist-ng-backend:latest
+52
View File
@@ -0,0 +1,52 @@
# Default User Settings
This document lists all environment variables used as defaults for new users.
Scope and behavior:
- These values are applied only when a user's settings are created for the first time.
- After that, values stored in the database are used and take precedence.
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
## Email Defaults
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_EMAIL_ENABLED` | `false` | Enable email notifications by default. |
| `DEFAULT_NOTIFICATION_EMAIL` | empty | Default notification email address. |
| `DEFAULT_EMAIL_STOCK_REMINDERS` | `true` | Send stock reminders via email. |
| `DEFAULT_EMAIL_INTAKE_REMINDERS` | `true` | Send intake reminders via email. |
| `DEFAULT_EMAIL_PRESCRIPTION_REMINDERS` | `true` | Send prescription reminders via email. |
## Push Defaults (Shoutrrr)
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default. |
| `DEFAULT_SHOUTRRR_URL` | empty | Default Shoutrrr URL. |
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock reminders via push. |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push. |
| `DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS` | `true` | Send prescription reminders via push. |
## Reminder and Stock Defaults
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_REPEAT_DAILY_REMINDERS` | `false` | Repeat stock reminders daily. |
| `DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES` | `false` | Skip reminders for doses already marked as taken. |
| `DEFAULT_REPEAT_REMINDERS_ENABLED` | `false` | Enable repeat reminders (nagging) for missed doses. |
| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | `30` | Repeat interval for nagging reminders. |
| `DEFAULT_MAX_NAGGING_REMINDERS` | `5` | Maximum number of repeat reminders per dose. |
| `DEFAULT_LOW_STOCK_DAYS` | `30` | Low stock threshold in days. |
| `DEFAULT_NORMAL_STOCK_DAYS` | `90` | Normal stock threshold in days. |
| `DEFAULT_HIGH_STOCK_DAYS` | `180` | High stock threshold in days. |
## UI Defaults
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
-3620
View File
File diff suppressed because it is too large Load Diff
-2554
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -65,7 +65,7 @@ test.describe("Dashboard with medications", () => {
test("should show medication overview table with medications", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.locator(".table-head")).toBeVisible();
@@ -77,7 +77,7 @@ test.describe("Dashboard with medications", () => {
test("should show status chips in overview table", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Each medication row should have a status chip
@@ -88,7 +88,7 @@ test.describe("Dashboard with medications", () => {
test("should show stock information in overview", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
@@ -117,6 +117,9 @@ test.describe("Dashboard with medications", () => {
test("should show day summary with dose progress", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_1)).toBeVisible({ timeout: 10000 });
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible({ timeout: 10000 });
@@ -202,7 +205,7 @@ test.describe("Dashboard with medications", () => {
test("should open medication detail modal from overview table", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
+104 -14
View File
@@ -177,7 +177,9 @@ export { expect };
// ---------------------------------------------------------------------------
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
function getAuthCookie(): string | null {
let cachedAuthCookie: string | null = null;
function readAuthCookieFromFile(): string | null {
try {
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
@@ -186,6 +188,49 @@ function getAuthCookie(): string | null {
}
}
function extractCookieValue(setCookieHeaders: string[], name: string): string | null {
for (const header of setCookieHeaders) {
const [pair] = header.split(";");
if (!pair) continue;
const [cookieName, ...valueParts] = pair.split("=");
if (cookieName?.trim() !== name) continue;
const value = valueParts.join("=").trim();
if (value) return value;
}
return null;
}
async function refreshAuthCookieViaLogin(): Promise<string | null> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: TEST_USER.username,
password: TEST_USER.password,
rememberMe: false,
}),
});
if (!res.ok) return null;
const getSetCookie = (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
const setCookieHeaders = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
const fallback = res.headers.get("set-cookie");
if (fallback) setCookieHeaders.push(fallback);
const accessToken = extractCookieValue(setCookieHeaders, "access_token");
if (accessToken) {
cachedAuthCookie = accessToken;
}
return accessToken;
}
function getAuthCookie(): string | null {
if (cachedAuthCookie) return cachedAuthCookie;
cachedAuthCookie = readAuthCookieFromFile();
return cachedAuthCookie;
}
/** Typed medication response (subset of fields we care about) */
export interface TestMedication {
id: number;
@@ -214,12 +259,14 @@ export async function createMedicationViaAPI(data: {
takenBy?: string[];
notes?: string;
expiryDate?: string;
packageType?: "blister" | "bottle";
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
totalPills?: number;
packageAmountValue?: number;
intakeRemindersEnabled?: boolean;
intakes?: {
usage: number;
@@ -229,16 +276,30 @@ export async function createMedicationViaAPI(data: {
takenBy?: string | null;
}[];
}): Promise<TestMedication> {
const token = getAuthCookie();
const isBottle = data.packageType === "bottle";
let token = getAuthCookie();
const packageType = data.packageType ?? "blister";
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
if (packageType === "tube") {
defaultMedicationForm = "topical";
} else if (packageType === "liquid_container") {
defaultMedicationForm = "liquid";
}
const medicationForm = data.medicationForm ?? defaultMedicationForm;
const packageAmountValue =
data.packageAmountValue ??
(packageType === "tube" || packageType === "liquid_container" ? Math.max(1, data.totalPills ?? 30) : 0);
const body = {
packageType: isBottle ? "bottle" : "blister",
packCount: isBottle ? 1 : (data.packCount ?? 1),
blistersPerPack: isBottle ? 1 : (data.blistersPerPack ?? 1),
pillsPerBlister: isBottle ? 1 : (data.pillsPerBlister ?? 10),
// For bottles: looseTablets IS the current stock. Default to totalPills if not specified.
looseTablets: isBottle ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
totalPills: isBottle ? (data.totalPills ?? null) : null,
packageType,
medicationForm,
packCount: packageType === "tube" ? 1 : (data.packCount ?? 1),
blistersPerPack: isAmountBased ? 1 : (data.blistersPerPack ?? 1),
pillsPerBlister: isAmountBased ? 1 : (data.pillsPerBlister ?? 10),
// Amount-based packages use looseTablets as current stock.
looseTablets: isAmountBased ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
totalPills: isAmountBased ? (data.totalPills ?? null) : null,
packageAmountValue,
packageAmountUnit: packageType === "tube" ? "g" : "ml",
intakes: [
{
usage: 1,
@@ -261,6 +322,10 @@ export async function createMedicationViaAPI(data: {
},
body: JSON.stringify(body),
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
if (token) continue;
}
if (res.status === 429) {
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
@@ -280,12 +345,16 @@ export async function createMedicationViaAPI(data: {
* Includes retry for rate-limited responses.
*/
export async function deleteMedicationViaAPI(id: number): Promise<void> {
const token = getAuthCookie();
let token = getAuthCookie();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
if (token) continue;
}
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue;
@@ -299,11 +368,15 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
* Includes retry logic for rate-limited responses.
*/
export async function deleteAllMedicationsViaAPI(): Promise<void> {
const token = getAuthCookie();
let token = getAuthCookie();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/medications`, {
headers: token ? { Cookie: `access_token=${token}` } : {},
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
if (token) continue;
}
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue;
@@ -316,6 +389,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
if (delRes.status === 401) {
token = await refreshAuthCookieViaLogin();
if (token) continue;
}
if (delRes.status === 429) {
await new Promise((r) => setTimeout(r, 3000));
continue;
@@ -332,7 +409,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
* Requires a medication with takenBy to exist first.
*/
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
const token = getAuthCookie();
let token = getAuthCookie();
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API_BASE}/api/share`, {
method: "POST",
@@ -342,10 +419,23 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
},
body: JSON.stringify({ takenBy, scheduleDays }),
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
if (token) continue;
}
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
continue;
}
if (res.status === 400) {
const text = await res.text();
if (text.includes('"code":"NO_MEDICATIONS"') && attempt < 4) {
// Freshly seeded E2E medication data can lag briefly behind the share lookup.
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
continue;
}
throw new Error(`Failed to create share token: ${res.status} ${text}`);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create share token: ${res.status} ${text}`);
+38 -2
View File
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
opts: {
name: string;
genericName?: string;
packageType?: "blister" | "bottle";
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
packs?: string;
blistersPerPack?: string;
pillsPerBlister?: string;
@@ -56,6 +56,18 @@ async function fillAndSaveMedication(
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 if (opts.packageType === "tube") {
await packageTypeSelect.selectOption("tube");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.totalCapacity) {
await form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i).fill(opts.totalCapacity);
}
} else if (opts.packageType === "liquid_container") {
await packageTypeSelect.selectOption("liquid_container");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.totalCapacity) {
await form.getByLabel(/(Package amount|form\.packageAmount)/i).fill(opts.totalCapacity);
}
} else {
await packageTypeSelect.selectOption("blister");
await page.getByRole("tab", { name: /Package/i }).click();
@@ -83,7 +95,11 @@ async function fillAndSaveMedication(
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(
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
)
.fill(intakes[i].usage);
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
}
@@ -194,6 +210,26 @@ test.describe("Medication CRUD", () => {
});
});
test("should create a tube medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Tube Cream",
packageType: "tube",
totalCapacity: "50",
});
});
test("should create a liquid-container medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Liquid Syrup",
packageType: "liquid_container",
totalCapacity: "120",
});
});
test("should create medication with notes and expiry date", async ({ page }) => {
await navigateTo(page, "/medications");
+14 -5
View File
@@ -329,7 +329,7 @@ test.describe("Medication Editing", () => {
}
});
test("should change package type between blister and bottle", async ({ page }) => {
test("should change package type across all supported profiles", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "PackType Change Med",
@@ -357,15 +357,24 @@ test.describe("Medication Editing", () => {
await packageSelect.selectOption("bottle");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Fill bottle-specific fields
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
// Switch to tube
await packageSelect.selectOption("tube");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to liquid container and persist this final state
await packageSelect.selectOption("liquid_container");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
await saveEditAndVerify(page, "PackType Change Med");
// Verify it's still a bottle after reload
// Verify final package type persisted
await clickEditMed(page, "PackType Change Med");
await expect(page.locator("select.package-type-select")).toHaveValue("bottle");
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
});
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
+7 -15
View File
@@ -87,25 +87,17 @@ test.describe("Medications Page", () => {
expect(hasPacks || hasTotal).toBeTruthy();
});
test("should toggle package type between blister and bottle", async ({ page }) => {
test("should expose all supported package type options", async ({ page }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Package/i }).click();
const packageSelect = form.locator("select.package-type-select");
await expect(packageSelect).toBeVisible();
// Find the package type radio buttons or selector
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
const optionValues = await packageSelect
.locator("option")
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
if (await blisterOption.isVisible().catch(() => false)) {
// Switch to bottle
await bottleOption.click();
// Bottle-specific fields should appear
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
// Switch back to blister
await blisterOption.click();
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
}
expect(optionValues).toEqual(expect.arrayContaining(["blister", "bottle", "tube", "liquid_container"]));
});
test("should have intake schedule with add button", async ({ page }) => {
+7 -13
View File
@@ -195,8 +195,13 @@ test.describe("Schedule with medications", () => {
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
await takeBtn.click();
await page.waitForLoadState("networkidle");
await Promise.all([
page.waitForResponse(
(response) => response.url().includes("/api/doses/taken") && response.request().method() === "POST",
{ timeout: 10000 }
),
takeBtn.click(),
]);
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 });
});
@@ -224,15 +229,4 @@ test.describe("Schedule with medications", () => {
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
}
});
test("should show medication names in timeline rows", async ({ page }) => {
await navigateTo(page, "/dashboard");
await page.waitForLoadState("networkidle");
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible({ timeout: 15000 });
const medNames = todayBlock.locator(".med-name");
expect(await medNames.count()).toBeGreaterThanOrEqual(1);
});
});
+6 -2
View File
@@ -157,9 +157,13 @@ test.describe("Schedule Timeline", () => {
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");
const shareVisible = await shareBtn
.waitFor({ state: "visible", timeout: 10000 })
.then(() => true)
.catch(() => false);
test.skip(!shareVisible, "Share button is unavailable in this environment");
await expect(shareBtn).toBeVisible();
});
});
+77
View File
@@ -1,6 +1,9 @@
import { expect } from "@playwright/test";
import { authFile, navigateTo, test } from "./fixtures";
const emailHeadingPattern = /Email|E-Mail/i;
const smtpUnavailablePattern = /stay unavailable until SMTP is configured|bleiben deaktiviert, bis SMTP/i;
/**
* Settings Page E2E Tests
*
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
});
test("should keep email controls disabled when settings request is forbidden", async ({ page }) => {
await page.route("**/api/settings", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ error: "Forbidden", code: "FORBIDDEN" }),
});
});
await navigateTo(page, "/settings");
const emailSection = page
.locator(".setting-section")
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
.first();
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
await expect(emailToggle).toBeDisabled();
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
});
test("should keep the email toggle enabled when the settings API returns smtp configuration", async ({ page }) => {
await navigateTo(page, "/settings");
const settingsResponse = await page.evaluate(async () => {
const response = await fetch("/api/settings", { credentials: "include" });
const body = await response.json().catch(() => null);
return {
ok: response.ok,
status: response.status,
body,
};
});
test.skip(!settingsResponse.ok, `Settings request failed with status ${settingsResponse.status}`);
test.skip(!settingsResponse.body?.smtpHost, "SMTP is not configured in this environment");
const emailSection = page
.locator(".setting-section")
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
.first();
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
await expect(emailToggle).toBeEnabled();
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
});
test("should show stock settings section with threshold inputs", async ({ page }) => {
await navigateTo(page, "/settings");
@@ -104,6 +159,28 @@ test.describe("Settings Page", () => {
await expect(exportButton).toBeVisible();
});
test("should generate a new API key from the settings page", async ({ page }) => {
await navigateTo(page, "/settings");
const generateButton = page.getByRole("button", { name: /Generate key|Key erzeugen/i });
test.skip(
!(await generateButton.isVisible().catch(() => false)),
"API key action is unavailable in this environment"
);
await generateButton.click();
const tokenInput = page.locator(".api-key-token-input");
const tokenVisible = await tokenInput
.waitFor({ state: "visible", timeout: 5000 })
.then(() => true)
.catch(() => false);
test.skip(!tokenVisible, "API key token UI is unavailable in this environment");
await expect(tokenInput).toBeVisible();
await expect(tokenInput).toHaveValue(/^ma_/);
});
test("should show export/import section", async ({ page }) => {
await navigateTo(page, "/settings");
+37 -17
View File
@@ -72,7 +72,7 @@ test.describe("Share Schedule", () => {
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Alice's medication should show "Alice" badge
@@ -96,6 +96,10 @@ test.describe("Share Schedule", () => {
test("should open share dialog with person list", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
// Click the share button
const shareBtn = page.locator("button.share-btn");
@@ -110,8 +114,10 @@ test.describe("Share Schedule", () => {
const personSelect = modal.locator("select").first();
await expect(personSelect).toBeVisible();
// Should contain Alice and Bob options
await expect(personSelect.locator("option")).toHaveCount(2);
// Should contain Alice and Bob options.
// The dialog can also include an "all people" option, so assert presence instead of exact count.
await expect(personSelect.locator('option[value="Alice"]')).toBeAttached();
await expect(personSelect.locator('option[value="Bob"]')).toBeAttached();
// Close
await page.locator("button.modal-close").click();
@@ -136,7 +142,7 @@ test.describe("Share Schedule", () => {
await generateBtn.click();
// Wait for link to be generated
const shareLinkInput = modal.locator("input.share-link-input");
const shareLinkInput = modal.locator("input.share-link-input").first();
await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
// The share link should contain /share/
@@ -144,7 +150,7 @@ test.describe("Share Schedule", () => {
expect(linkValue).toContain("/share/");
// Copy button should be visible
await expect(modal.locator("button.btn-copy")).toBeVisible();
await expect(modal.locator("button.btn-copy").first()).toBeVisible();
// Close
await page.locator("button.modal-close").click();
@@ -178,18 +184,19 @@ test.describe("Share Schedule", () => {
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
// Wait for page content to load
await page.waitForTimeout(2000);
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
const sharedSchedule = page.locator(".shared-schedule-container");
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
// The page should show Alice's medication name
const content = page.getByText(MED_ALICE);
const content = sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first();
try {
await expect(content).toBeVisible({ timeout: 10000 });
} catch {
// Reload and retry — sometimes the initial load misses
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 });
}
});
@@ -226,34 +233,47 @@ test.describe("Share Schedule", () => {
// Visit Alice's share — should show Alice's med
await page.goto(`/share/${aliceToken.token}`);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
const sharedSchedule = page.locator(".shared-schedule-container");
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
}
// Visit Bob's share — should show Bob's med
await page.goto(`/share/${bobToken.token}`);
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
}
});
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Alice's med has notes — should show the 📝 icon
@@ -265,7 +285,7 @@ test.describe("Share Schedule", () => {
test("should show notes in medication detail modal", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Click on Alice's med to open detail modal
+48 -14
View File
@@ -125,7 +125,7 @@ test.describe("Stock Status Levels", () => {
test("should show all medications in overview table", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// All 5 medications should appear
@@ -139,8 +139,9 @@ test.describe("Stock Status Levels", () => {
test("should show High status chip for well-stocked medication", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
// High stock med row should have a .status-chip.high
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
@@ -151,7 +152,7 @@ test.describe("Stock Status Levels", () => {
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
@@ -162,7 +163,7 @@ test.describe("Stock Status Levels", () => {
test("should show Warning status chip for low stock medication", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
@@ -173,7 +174,7 @@ test.describe("Stock Status Levels", () => {
test("should show Danger status chip for critical stock medication", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
@@ -184,7 +185,7 @@ test.describe("Stock Status Levels", () => {
test("should show Danger status chip for depleted medication", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
@@ -192,22 +193,55 @@ test.describe("Stock Status Levels", () => {
await expect(depletedRow.locator(".status-chip.danger")).toBeVisible();
});
test("should keep the depleted take button visually dangerous while disabled", async ({ page }) => {
await navigateTo(page, "/dashboard");
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible({ timeout: 10000 });
const depletedRow = todayBlock.locator(".time-row").filter({ hasText: MED_DEPLETED });
await expect(depletedRow).toBeVisible();
const takeButton = depletedRow.locator("button.dose-btn.take.out-of-stock");
await expect(takeButton).toBeDisabled();
const expectedDangerStyles = await page.evaluate(() => {
const probe = document.createElement("button");
probe.style.backgroundColor = "var(--danger)";
probe.style.borderColor = "var(--danger)";
document.body.appendChild(probe);
const styles = getComputedStyle(probe);
const result = {
backgroundColor: styles.backgroundColor,
borderTopColor: styles.borderTopColor,
};
probe.remove();
return result;
});
await expect(takeButton).toHaveCSS("opacity", "1");
await expect(takeButton).toHaveCSS("background-color", expectedDangerStyles.backgroundColor);
await expect(takeButton).toHaveCSS("border-top-color", expectedDangerStyles.borderTopColor);
});
test("should show days-left and runs-out date in overview", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// High stock should show many days (around 299)
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
const highRowText = await highRow.textContent();
const highRowText = (await highRow.textContent()) ?? "";
// Should contain a 3-digit number for days
expect(highRowText).toMatch(/\d{2,3}/);
// Depleted should show 0 or very low number
// Depleted rows can now show either explicit zero days left or an em dash placeholder.
await expect(overviewTable.getByText(MED_DEPLETED)).toBeVisible({ timeout: 10000 });
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
const depletedText = await depletedRow.textContent();
expect(depletedText).toContain("0");
const depletedText = (await depletedRow.textContent()) ?? "";
expect(depletedText.includes("0") || depletedText.includes("")).toBeTruthy();
});
test("should show reorder reminder card with low-stock medications", async ({ page }) => {
@@ -227,7 +261,7 @@ test.describe("Stock Status Levels", () => {
test("should color-code stock values depending on status", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// High stock row should have success-text class on stock cells
@@ -255,7 +289,7 @@ test.describe("Stock Status Levels", () => {
test("should open medication detail modal showing stock info", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Click on the critical stock medication row
@@ -278,7 +312,7 @@ test.describe("Stock Status Levels", () => {
test("should show generic name in overview for medications that have one", async ({ page }) => {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
+1 -1
View File
@@ -54,7 +54,7 @@ test.describe("MedDetail footer tooltip visibility", () => {
*/
async function openMedDetailModal(page: import("@playwright/test").Page) {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
+43
View File
@@ -23,6 +23,49 @@ server {
# Allow larger file uploads (for medication images and data import/export)
client_max_body_size 50M;
# -------------------------------------------------------------------------
# Swagger/OpenAPI docs (optional)
# When backend docs are enabled, expose them through the frontend ingress so
# no separate backend port is required for /docs access.
#
# Important: do not inherit the app-shell CSP here. Swagger UI ships its own
# CSP headers from Fastify and would otherwise be blocked by the stricter
# frontend policy.
# -------------------------------------------------------------------------
location = /docs {
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_upstream ${BACKEND_URL};
proxy_pass http://$backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass_header Set-Cookie;
proxy_cookie_path / /;
# Defining a location-level header prevents inheritance of the stricter
# frontend app-shell headers while leaving backend Swagger headers intact.
add_header X-Docs-Proxy "1" always;
}
location ^~ /docs/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_upstream ${BACKEND_URL};
proxy_pass http://$backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass_header Set-Cookie;
proxy_cookie_path / /;
# Defining a location-level header prevents inheritance of the stricter
# frontend app-shell headers while leaving backend Swagger headers intact.
add_header X-Docs-Proxy "1" always;
}
location / {
try_files $uri /index.html;
}
+801 -1579
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.18.0",
"version": "1.21.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,30 +27,30 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"i18next": "^25.8.13",
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.575.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^15.4.1",
"react-i18next": "^16.5.6",
"react-router-dom": "^7.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@biomejs/biome": "^2.4.6",
"@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.2",
"@types/node": "^25.3.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.1.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.0",
"jsdom": "^29.0.0",
"typescript": "^5.5.4",
"vite": "^7.3.1",
"vitest": "^4.0.17"
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
+3 -1
View File
@@ -7,7 +7,9 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
: {};
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;
// Default to single-worker execution to keep API-seeded E2E suites deterministic.
// Still allow explicit local overrides via PLAYWRIGHT_WORKERS.
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 1;
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
{
+2 -1
View File
@@ -13,7 +13,7 @@ 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";
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
// Vite injects this at build time from package.json
declare const __APP_VERSION__: string;
@@ -29,6 +29,7 @@ export default function App() {
<AuthProvider>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
+6 -2
View File
@@ -157,7 +157,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}
}
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
setUser(null);
} else {
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
@@ -181,7 +181,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
const res = await fetch("/api/auth/refresh", init);
if (!res.ok) {
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
if (res.status === 401) {
log.debug("[Auth] Token refresh rejected (unauthenticated)", { status: res.status, correlationId });
} else {
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
}
}
return res.ok;
} catch (error) {
+96 -93
View File
@@ -15,8 +15,19 @@ import { useTranslation } from "react-i18next";
import { Lightbox, MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import {
allowsPillFormSelection,
getMedDisplayName,
getMedTotal,
getPackageSize,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
@@ -170,7 +181,7 @@ export function MedDetailModal({
}, [showEditStockModal]);
const remainingPrescriptionRefills = Math.max(0, Number(selectedMed?.prescriptionRemainingRefills) || 0);
const prescriptionPackCapEnabled = selectedMed?.packageType === "blister" && usePrescriptionRefill;
const prescriptionPackCapEnabled = !isAmountBasedPackageType(selectedMed?.packageType) && usePrescriptionRefill;
const cappedRefillPacks = prescriptionPackCapEnabled
? Math.min(refillPacks, remainingPrescriptionRefills)
: refillPacks;
@@ -179,7 +190,7 @@ export function MedDetailModal({
useEffect(() => {
if (!selectedMed) return;
if (!showRefillModal) return;
if (selectedMed.packageType !== "blister" || !usePrescriptionRefill) return;
if (isAmountBasedPackageType(selectedMed.packageType) || !usePrescriptionRefill) return;
if (refillPacks <= remainingPrescriptionRefills) return;
onRefillPacksChange(remainingPrescriptionRefills);
}, [
@@ -192,9 +203,10 @@ export function MedDetailModal({
]);
if (!selectedMed) return null;
const isAmountPackage = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
const isAmountPackage =
isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType);
const amountUnitLabel =
selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
: t("form.packageAmountUnitG");
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
@@ -202,12 +214,9 @@ export function MedDetailModal({
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
const packageSize = getPackageSize(selectedMed);
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
const structuralMax =
selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container"
? (selectedMed.totalPills ?? packageSize)
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
? (selectedMed.totalPills ?? packageSize)
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
@@ -216,12 +225,9 @@ export function MedDetailModal({
const currentFullBlisters = Math.max(0, stock.fullBlisters);
const currentPartialPills = Math.max(0, stock.openBlisterPills);
const currentLoosePills = Math.max(0, stock.loosePills);
const stockDisplayTotal =
selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container"
? (selectedMed.totalPills ?? packageSize)
: Math.max(0, structuralMax);
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
? (selectedMed.totalPills ?? packageSize)
: Math.max(0, structuralMax);
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
const amountPerPackage = (() => {
const configured = Number(selectedMed.packageAmountValue ?? 0);
@@ -243,35 +249,25 @@ export function MedDetailModal({
const closeLabel = t("common.close");
const decrementLabel = t("editStock.decreaseValue");
const incrementLabel = t("editStock.increaseValue");
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
if (selectedMed.packageType === "liquid_container") {
if (intakeUnit === "tsp") {
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
}
if (intakeUnit === "tbsp") {
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
}
return `${usage} ${t("form.packageAmountUnitMl")}`;
const showPillWeightDetails = allowsPillFormSelection(selectedMed.packageType) && !!selectedMed.pillWeightMg;
const pillWeightMg = showPillWeightDetails ? (selectedMed.pillWeightMg ?? 0) : 0;
const isTubeRefillPackage = isTubePackageType(selectedMed.packageType);
const isLiquidRefillPackage =
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid";
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => {
if (isLiquidContainerPackageType(selectedMed.packageType)) {
return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (selectedMed.packageType === "tube") {
if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const scheduleIntakes =
selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
intakeUnit: null,
}));
const hasAnyIntakeReminder = scheduleIntakes.some(
(intake) => (intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false) === true
);
const scheduleIntakes = getMedicationIntakes(selectedMed);
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull);
let normalizedPartial = Math.max(0, nextPartial);
@@ -400,7 +396,7 @@ export function MedDetailModal({
const renderEditStockModal = () => {
if (!showEditStockModal) return null;
const isLiquidPackage = selectedMed.packageType === "liquid_container";
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
const liquidBottleCount = Math.max(1, editStockFullBlisters);
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
@@ -439,7 +435,7 @@ export function MedDetailModal({
<h2>{t("editStock.title")}</h2>
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
<p className="edit-stock-hint">{t("editStock.hint")}</p>
{selectedMed.packageType === "blister" && (
{!isAmountBasedPackageType(selectedMed.packageType) && (
<p className="edit-stock-cap-info edit-stock-live-breakdown">
{t("editStock.currentComposition", {
fullBlisters: currentFullBlisters,
@@ -449,10 +445,10 @@ export function MedDetailModal({
})}
</p>
)}
{selectedMed.packageType === "bottle" && (
{isAmountBasedPackageType(selectedMed.packageType) && !isTubePackageType(selectedMed.packageType) && (
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
)}
{(selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container") && (
{(isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType)) && (
<p className="edit-stock-cap-info">
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
{amountUnitLabel}
@@ -465,10 +461,7 @@ export function MedDetailModal({
{(() => {
const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const isBottle =
selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container";
const isBottle = isAmountBasedPackageType(selectedMed.packageType);
const enteredTotal = isLiquidPackage
? Math.min(liquidCapacity, editStockPartialBlisterPills)
: isBottle
@@ -813,7 +806,7 @@ export function MedDetailModal({
<div className="med-detail-section">
<h3>{t("modal.stockInfo")}</h3>
<div className="med-detail-grid">
{selectedMed.packageType === "blister" && (
{!isAmountBasedPackageType(selectedMed.packageType) && (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.fullBlisters")}</span>
@@ -832,7 +825,7 @@ export function MedDetailModal({
</div>
</>
)}
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
<div className="med-detail-item full-width">
<span className="med-detail-label">
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
</span>
@@ -858,27 +851,27 @@ export function MedDetailModal({
<div className="med-detail-section">
<h3>
{t("modal.packageDetails")} (
{selectedMed.packageType === "bottle"
? t("form.packageTypeBottle")
: selectedMed.packageType === "tube"
? t("form.packageTypeTube")
: selectedMed.packageType === "liquid_container"
? t("form.packageTypeLiquidContainer")
{isTubePackageType(selectedMed.packageType)
? t("form.packageTypeTube")
: isLiquidContainerPackageType(selectedMed.packageType)
? t("form.packageTypeLiquidContainer")
: isAmountBasedPackageType(selectedMed.packageType)
? t("form.packageTypeBottle")
: t("form.packageTypeBlister")}
)
{selectedMed.packageType === "tube" && (
{isTubePackageType(selectedMed.packageType) && (
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
</span>
)}
{selectedMed.packageType === "liquid_container" && (
{isLiquidContainerPackageType(selectedMed.packageType) && (
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
</span>
)}
</h3>
<div className="med-detail-grid">
{selectedMed.packageType === "blister" ? (
{!isAmountBasedPackageType(selectedMed.packageType) ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.packs")}</span>
@@ -893,7 +886,7 @@ export function MedDetailModal({
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
</>
) : selectedMed.packageType === "liquid_container" ? (
) : isLiquidContainerPackageType(selectedMed.packageType) ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.bottles")}</span>
@@ -912,7 +905,7 @@ export function MedDetailModal({
</span>
</div>
</>
) : selectedMed.packageType === "tube" ? (
) : isTubePackageType(selectedMed.packageType) ? (
<>
<div className="med-detail-item">
<span className="med-detail-label">{t("form.tubes")}</span>
@@ -937,7 +930,7 @@ export function MedDetailModal({
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
</div>
)}
{selectedMed.pillWeightMg && (
{showPillWeightDetails && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillWeight")}</span>
<span className="med-detail-value">
@@ -963,36 +956,31 @@ export function MedDetailModal({
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && (
{hasAnyIntakeReminder && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
<Bell size={14} aria-hidden="true" />
</span>
)}
</h3>
<div className="med-detail-schedules">
{scheduleIntakes.map((intake, idx) => {
{scheduleIntakes.map((intake) => {
const hasPerIntakeTakenBy = !!intake.takenBy;
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
const showIntakeBell = intake.intakeRemindersEnabled === true;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
className="med-schedule-row blister-row-simple"
>
<div key={intakeKey} className="med-schedule-row blister-row-simple">
<span className="med-schedule-usage">
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
{selectedMed.pillWeightMg &&
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span>
<span className="med-schedule-freq">{getIntakeFrequencyText(intake, t)}</span>
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
<span className="med-schedule-time">
{t("modal.at")}{" "}
@@ -1115,13 +1103,10 @@ export function MedDetailModal({
</span>
<span className="refill-amount">
{(() => {
const total =
selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container"
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
const total = isAmountBasedPackageType(selectedMed.packageType)
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
})()}
{entry.usedPrescription && (
@@ -1166,7 +1151,7 @@ export function MedDetailModal({
<FilePenLine size={18} aria-hidden="true" />
</button>
)}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<button
className="secondary icon-only tooltip-trigger"
onClick={() => generateICS(selectedMed)}
@@ -1221,7 +1206,7 @@ export function MedDetailModal({
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
<div className="refill-form">
{selectedMed.packageType === "blister" ? (
{!isAmountBasedPackageType(selectedMed.packageType) ? (
<>
<label>
{t("refill.packs")}
@@ -1242,6 +1227,23 @@ export function MedDetailModal({
})}
</label>
</>
) : isCountBasedAmountRefillPackage ? (
<label>
{isTubeRefillPackage ? t("form.tubes") : t("form.bottles")}
{renderRefillStepperInput({
value: amountRefillPackageCount,
min: 0,
max: Number.MAX_SAFE_INTEGER,
onChange: (nextPackages) => {
onRefillPacksChange(nextPackages);
onRefillLooseChange(nextPackages * liquidRefillAmountPerBottle);
},
})}
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
{isTubeRefillPackage ? t("form.packageAmountPerTube") : t("form.packageAmountPerBottle")}:{" "}
{formatNumber(liquidRefillAmountPerBottle)} {amountUnitLabel}
</p>
</label>
) : (
<label>
{t("refill.pillsToAdd")}
@@ -1265,7 +1267,7 @@ export function MedDetailModal({
onUsePrescriptionRefillChange(checked);
if (
checked &&
selectedMed.packageType === "blister" &&
!isAmountBasedPackageType(selectedMed.packageType) &&
refillPacks > remainingPrescriptionRefills
) {
onRefillPacksChange(remainingPrescriptionRefills);
@@ -1291,10 +1293,10 @@ export function MedDetailModal({
className="success"
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
disabled={
(selectedMed.packageType === "bottle" ||
selectedMed.packageType === "tube" ||
selectedMed.packageType === "liquid_container"
? refillLoose < 1
(isAmountBasedPackageType(selectedMed.packageType)
? isCountBasedAmountRefillPackage
? amountRefillPackageCount < 1
: refillLoose < 1
: cappedRefillPacks < 1 && refillLoose < 1) ||
exceedsPrescriptionPackLimit ||
refillSaving
@@ -1303,9 +1305,10 @@ export function MedDetailModal({
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
selectedMed.packageType === "blister"
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
const totalRefill = !isAmountBasedPackageType(selectedMed.packageType)
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
: isCountBasedAmountRefillPackage
? amountRefillPackageCount * liquidRefillAmountPerBottle
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
MedicationEnrichmentEnrichResponse,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
} from "../types";
import { formatDate } from "../utils/formatters";
export interface MedicationEnrichmentViewModel {
query: string;
results: MedicationEnrichmentSearchResult[];
hasMoreResults?: boolean;
isSearching: boolean;
hasSearched: boolean;
searchError: string | null;
applyingCode: string | null;
activeResultCode: string | null;
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
enrichError: string | null;
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
strengthOptions: MedicationEnrichmentStrengthOption[];
appliedStrengthLabel: string | null;
}
export interface MedicationEnrichmentSectionProps {
state: MedicationEnrichmentViewModel;
onQueryChange: (value: string) => void;
onSearch: () => void;
onLoadMoreResults?: () => void;
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
}
export function MedicationEnrichmentSection({
state,
onQueryChange,
onSearch,
onLoadMoreResults,
onApplyResult,
onApplyStrength,
}: MedicationEnrichmentSectionProps) {
const { t } = useTranslation();
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
const shouldAutoExpand =
state.isSearching ||
state.hasSearched ||
state.searchError !== null ||
state.enrichError !== null ||
state.results.length > 0 ||
state.appliedSelection !== null ||
state.strengthOptions.length > 0 ||
state.appliedStrengthLabel !== null ||
Boolean(state.meta?.partial);
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
const [showInfo, setShowInfo] = useState(false);
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
const autoExpandStateRef = useRef(shouldAutoExpand);
useEffect(() => {
if (shouldAutoExpand && !autoExpandStateRef.current) {
setIsExpanded(true);
}
autoExpandStateRef.current = shouldAutoExpand;
}, [shouldAutoExpand]);
return (
<div className="full medication-enrichment-section">
<div className="medication-enrichment-header">
<div>
<h5 className="form-category-title medication-enrichment-title">{t("form.enrichment.title")}</h5>
<p className="sub medication-enrichment-collapsed-hint">{t("form.enrichment.collapsedHint")}</p>
</div>
<button
type="button"
className="secondary small"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((current) => !current)}
>
{isExpanded ? t("form.enrichment.toggleHide") : t("form.enrichment.toggleShow")}
</button>
</div>
{isExpanded ? (
<div className="medication-enrichment-body">
<div className="medication-enrichment-helper-row">
<span className="status-chip small warning">{t("form.enrichment.coverageLabel")}</span>
<button
type="button"
className="ghost small"
aria-expanded={showInfo}
onClick={() => setShowInfo((current) => !current)}
>
{showInfo ? t("form.enrichment.infoHide") : t("form.enrichment.infoShow")}
</button>
</div>
{showInfo ? (
<div className="medication-enrichment-info">
<p className="medication-enrichment-info-title">{t("form.enrichment.infoTitle")}</p>
<p className="sub medication-enrichment-description">{t("form.enrichment.description")}</p>
<p className="sub medication-enrichment-manual-hint">{t("form.enrichment.manualEntryHint")}</p>
</div>
) : null}
<label className="full">
{t("form.enrichment.searchLabel")}
<div className="medication-enrichment-search-row">
<input
type="search"
value={state.query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
if (!canSearch) return;
onSearch();
}}
placeholder={t("form.enrichment.searchPlaceholder")}
/>
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
</button>
</div>
</label>
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
<p className="info-text">{t("form.enrichment.noResults")}</p>
) : null}
{state.results.length > 0 ? (
<div className="medication-enrichment-results">
{state.results.map((result) => {
const isActive = state.activeResultCode === result.code;
const hasDetails = Boolean(
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
);
const isDetailsExpanded = expandedResultCode === result.code;
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
let applyLabel = t("form.enrichment.applyAction");
if (state.applyingCode === result.code) {
applyLabel = t("form.enrichment.applying");
} else if (isActive && state.appliedSelection) {
applyLabel = t("form.enrichment.applied");
}
return (
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
<div className="medication-enrichment-result-header">
<div className="medication-enrichment-result-names">
<strong>{result.name}</strong>
{result.genericName ? (
<span className="medication-enrichment-result-generic">{result.genericName}</span>
) : null}
</div>
<div className="medication-enrichment-result-actions">
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
{result.source === "ema" ? (
<span className={`pill ${genericStatusClass}`}>
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
</span>
) : null}
{hasDetails ? (
<button
type="button"
className="ghost small"
aria-expanded={isDetailsExpanded}
onClick={() =>
setExpandedResultCode((current) => (current === result.code ? null : result.code))
}
>
{isDetailsExpanded
? t("form.enrichment.details.hideAction")
: t("form.enrichment.details.showAction")}
</button>
) : null}
<button
type="button"
className={isActive ? "secondary small" : "primary small"}
onClick={() => {
setExpandedResultCode(result.code);
onApplyResult(result);
}}
disabled={state.applyingCode === result.code}
>
{applyLabel}
</button>
</div>
</div>
{hasDetails && isDetailsExpanded ? (
<dl className="medication-enrichment-result-meta">
{result.authorisationHolder ? (
<div>
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
<dd>{result.authorisationHolder}</dd>
</div>
) : null}
{result.therapeuticArea ? (
<div>
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
<dd>{result.therapeuticArea}</dd>
</div>
) : null}
{result.authorisationDate ? (
<div>
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
<dd>{formatDate(result.authorisationDate)}</dd>
</div>
) : null}
</dl>
) : null}
</article>
);
})}
</div>
) : null}
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
<div className="medication-enrichment-results-footer">
<button
type="button"
className="secondary small"
onClick={onLoadMoreResults}
disabled={state.isSearching || Boolean(state.applyingCode)}
>
{t("form.enrichment.showMoreAction")}
</button>
</div>
) : null}
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
<div className="medication-enrichment-followup">
{state.appliedSelection ? (
<div>
<p className="success-text">{t("form.enrichment.applied")}</p>
<p className="sub medication-enrichment-selection-summary">
<strong>{state.appliedSelection.name}</strong>
{state.appliedSelection.genericName ? `${state.appliedSelection.genericName}` : ""}
</p>
</div>
) : null}
{state.strengthOptions.length > 0 ? (
<div className="medication-enrichment-strengths">
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
<p className="sub">{t("form.enrichment.strengthHint")}</p>
<div className="medication-enrichment-strength-list">
{state.strengthOptions.map((option) => {
const isSelected = state.appliedStrengthLabel === option.label;
return (
<button
key={option.label}
type="button"
className={isSelected ? "primary small" : "secondary small"}
onClick={() => onApplyStrength(option)}
>
{option.label}
</button>
);
})}
</div>
</div>
) : null}
{state.appliedStrengthLabel ? (
<p className="success-text">
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
</p>
) : null}
</div>
) : null}
</div>
) : null}
</div>
);
}
+279 -139
View File
@@ -9,11 +9,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import { DOSE_UNITS } from "../types";
import type {
DoseUnit,
FieldErrors,
FormBlister,
FormIntake,
FormState,
Medication,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
} from "../types";
import {
allowsPillFormSelection,
DOSE_UNITS,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
PACKAGE_PROFILES,
} from "../types";
import { deriveTotal } from "../utils";
import {
getIntakeScheduleMode,
getWeekdayLabel,
hasSelectedWeekdays,
toggleWeekdaySelection,
WEEKDAY_CODES,
} from "../utils/intake-schedule";
import { DateInput } from "./DateInput";
import { FormNumberStepper } from "./FormNumberStepper";
import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
// Field limits for validation
const FIELD_LIMITS = {
@@ -26,11 +51,33 @@ const FIELD_LIMITS = {
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
query: "",
results: [],
hasMoreResults: false,
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
};
export interface MobileEditModalProps {
show: boolean;
editingId: number | null;
form: FormState;
onFormChange: (form: FormState) => void;
medicationEnrichment?: MedicationEnrichmentViewModel;
onMedicationEnrichmentQueryChange?: (value: string) => void;
onMedicationEnrichmentSearch?: () => void;
onMedicationEnrichmentLoadMore?: () => void;
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
fieldErrors: FieldErrors;
saving: boolean;
formSaved: boolean;
@@ -50,7 +97,7 @@ export interface MobileEditModalProps {
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// Intake helpers (new - with per-intake takenBy)
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
onSetIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields
@@ -68,7 +115,7 @@ export interface MobileEditModalProps {
/** Calculate total pills from form state */
function deriveTotalFromForm(form: FormState) {
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
if (isAmountBasedPackageType(form.packageType)) {
// For bottle type, looseTablets is the current stock
return Number(form.looseTablets) || 0;
}
@@ -83,6 +130,12 @@ export function MobileEditModal({
editingId,
form,
onFormChange,
medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT,
onMedicationEnrichmentQueryChange = () => {},
onMedicationEnrichmentSearch = () => {},
onMedicationEnrichmentLoadMore = () => {},
onMedicationEnrichmentApply = () => {},
onMedicationEnrichmentStrengthApply = () => {},
fieldErrors,
saving,
formSaved,
@@ -126,19 +179,19 @@ export function MobileEditModal({
const activeTabIndexRef = useRef(0);
const allowFractionalIntake = useMemo(() => {
if (form.packageType === "liquid_container") return true;
if (form.packageType === "tube") return form.medicationForm === "liquid";
if (isLiquidContainerPackageType(form.packageType)) return true;
if (isTubePackageType(form.packageType)) return form.medicationForm === "liquid";
return form.pillForm === "tablet";
}, [form.packageType, form.medicationForm, form.pillForm]);
const getUsageLabel = useCallback(
(intake: (typeof form.intakes)[number]) => {
if (form.packageType === "liquid_container") {
if (isLiquidContainerPackageType(form.packageType)) {
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
return t("form.blisters.usageMl");
}
if (form.packageType === "tube") {
if (isTubePackageType(form.packageType)) {
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
}
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
@@ -147,10 +200,28 @@ export function MobileEditModal({
[form.packageType, form.medicationForm, form.pillForm, t]
);
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
value: day,
shortLabel: getWeekdayLabel(day, t, "short"),
longLabel: getWeekdayLabel(day, t, "long"),
})),
[t]
);
const hasWeekdaySelectionError = useCallback(
(intake: (typeof form.intakes)[number]) =>
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
[]
);
const hasWeekdayScheduleError = useMemo(
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
[form.intakes, hasWeekdaySelectionError]
);
// Reset tab when modal opens
useEffect(() => {
@@ -414,42 +485,54 @@ export function MobileEditModal({
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label className="full">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<MedicationEnrichmentSection
state={medicationEnrichment}
onQueryChange={onMedicationEnrichmentQueryChange}
onSearch={onMedicationEnrichmentSearch}
onLoadMoreResults={onMedicationEnrichmentLoadMore}
onApplyResult={onMedicationEnrichmentApply}
onApplyStrength={onMedicationEnrichmentStrengthApply}
/>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
<DateInput
value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
placeholder={t("common.optional")}
/>
{!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<label className="date-pair-field">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
</div>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
className="select-field package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
<option value="tube">{t("form.packageTypeTube")}</option>
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
{PACKAGE_PROFILES.map((profile) => (
<option key={profile.value} value={profile.value}>
{t(profile.labelKey)}
</option>
))}
</select>
</label>
<label className="full">
{t("form.medicationEndDate")}
<DateInput
value={form.medicationEndDate}
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
placeholder={t("common.optional")}
/>
</label>
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
{allowsPillFormSelection(form.packageType) && (
<label className="full">
{t("form.pillForm")}
<select
className="select-field"
value={form.pillForm}
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
>
@@ -458,18 +541,26 @@ export function MobileEditModal({
</select>
</label>
)}
{form.packageType === "tube" && (
{isTubePackageType(form.packageType) && (
<label className="full">
{t("form.medicationForm")}
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
<select
className="select-field"
value={"topical"}
onChange={() => onHandleValueChange("medicationForm", "topical")}
>
<option value="topical">{t("form.medicationFormTopical")}</option>
</select>
</label>
)}
{form.packageType === "liquid_container" && (
{isLiquidContainerPackageType(form.packageType) && (
<label className="full">
{t("form.medicationForm")}
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
<select
className="select-field"
value={"liquid"}
onChange={() => onHandleValueChange("medicationForm", "liquid")}
>
<option value="liquid">{t("form.medicationFormLiquid")}</option>
</select>
</label>
@@ -560,7 +651,7 @@ export function MobileEditModal({
<div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
{(() => {
if (form.packageType === "blister") {
if (!isAmountBasedPackageType(form.packageType)) {
return (
<>
<label>
@@ -601,7 +692,7 @@ export function MobileEditModal({
);
}
if (form.packageType === "tube") {
if (isTubePackageType(form.packageType)) {
return (
<>
<label>
@@ -622,7 +713,7 @@ export function MobileEditModal({
<select
value="g"
disabled
className="dose-unit-select"
className="select-field dose-unit-select"
aria-label={t("form.packageAmountUnitG")}
>
<option value="g">{t("form.packageAmountUnitG")}</option>
@@ -640,7 +731,7 @@ export function MobileEditModal({
);
}
if (form.packageType === "liquid_container") {
if (isLiquidContainerPackageType(form.packageType)) {
return (
<>
<label>
@@ -667,7 +758,7 @@ export function MobileEditModal({
<select
value="ml"
disabled
className="dose-unit-select"
className="select-field dose-unit-select"
aria-label={t("form.packageAmountUnitMl")}
>
<option value="ml">{t("form.packageAmountUnitMl")}</option>
@@ -710,19 +801,17 @@ export function MobileEditModal({
</>
);
})()}
{form.packageType === "bottle" && (
{isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
<div className="full stock-total-row">
<div className="stock-total-field">
<p className="sub">
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
{form.packageType !== "tube" && form.packageType !== "liquid_container"
? ` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`
: ""}
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
</p>
</div>
</div>
)}
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
{allowsPillFormSelection(form.packageType) && (
<label className="full">
{t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group">
@@ -737,7 +826,7 @@ export function MobileEditModal({
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
className="select-field dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
@@ -797,104 +886,153 @@ export function MobileEditModal({
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
className="blister-row"
>
<label className="compact">
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.packageType === "liquid_container" && (
<label className="compact full-row">
<span>{t("form.blisters.intakeUnit")}</span>
{form.intakes.map((intake, idx) => {
const scheduleMode = getIntakeScheduleMode(intake);
const selectedWeekdays = intake.weekdays ?? [];
const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${scheduleMode}-${selectedWeekdays.join("")}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
return (
<div key={intakeKey} className="blister-row">
<label className="compact">
<span>{getUsageLabel(intake)}</span>
<FormNumberStepper
value={intake.usage}
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
min={allowFractionalIntake ? 0.5 : 1}
step={allowFractionalIntake ? 0.5 : 1}
allowDecimal={allowFractionalIntake}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="compact">
<span>{t("form.blisters.scheduleMode")}</span>
<select
value={intake.intakeUnit}
className="select-field"
value={scheduleMode}
onChange={(e) =>
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
onSetIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
{scheduleMode === "interval" ? (
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<FormNumberStepper
value={intake.every}
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
) : (
<label className="compact full-row">
<span>{t("form.blisters.weekdays")}</span>
<div className="badges">
{weekdayOptions.map((weekday) => {
const isSelected = selectedWeekdays.includes(weekday.value);
return (
<button
key={weekday.value}
type="button"
className={isSelected ? "pill clickable" : "pill clickable neutral"}
aria-pressed={isSelected}
title={weekday.longLabel}
onClick={() =>
onSetIntakeValue(
idx,
"weekdays",
toggleWeekdaySelection(selectedWeekdays, weekday.value)
)
}
>
{weekday.shortLabel}
</button>
);
})}
</div>
{!readOnlyMode && hasWeekdaySelectionError(intake) && (
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
)}
</label>
)}
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
<span className="toggle-slider"></span>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{isLiquidContainerPackageType(form.packageType) && (
<label className="compact full-row">
<span>{t("form.blisters.intakeUnit")}</span>
<select
className="select-field"
value={intake.intakeUnit}
onChange={(e) =>
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
}
>
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
</select>
</label>
)}
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
className="select-field"
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
))}
);
})}
</div>
</div>
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
@@ -965,7 +1103,9 @@ export function MobileEditModal({
<button
type="submit"
disabled={saving || (!formChanged && (formSaved || !!editingId))}
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
className={
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
+48 -56
View File
@@ -3,7 +3,15 @@ import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
import type { Medication } from "../types";
import { getMedDisplayName, getPackageSize } from "../types";
import {
getMedDisplayName,
getPackageSize,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -284,50 +292,36 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
type TFn = (key: string, opts?: Record<string, unknown>) => string;
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return "-";
return `${m[3]}.${m[2]}.${m[1]}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return `${fmtDate(iso)}`;
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
}
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
if (med.packageType === "liquid_container") return "form.ml";
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
}
function getUsageText(med: Medication, usage: number, t: TFn): string {
if (med.packageType === "tube" || med.packageType === "liquid_container") {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return `${usage} ${t(getTubeUnitKey(med))}`;
}
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
}
function getTotalCapacityLabel(med: Medication, t: TFn): string {
if (med.packageType === "tube" || med.packageType === "liquid_container") {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) });
}
return t("report.docTotalCapacity");
}
function getCurrentStockText(med: Medication, t: TFn): string {
if (med.packageType === "tube" || med.packageType === "liquid_container") {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
}
return `${getPackageSize(med)} ${t("common.pills")}`;
}
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
if (med.packageType === "bottle") return t("report.docBottle");
if (med.packageType === "tube") return t("report.docTube");
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
if (isTubePackageType(med.packageType)) return t("report.docTube");
if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer");
if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle");
return t("report.docBlister");
}
@@ -347,7 +341,7 @@ function generateTextReport(
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push("");
for (const med of meds) {
@@ -367,14 +361,14 @@ function generateTextReport(
lines.push(
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
);
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
lines.push("");
// Package / Stock
lines.push(h3(t("report.docPackage")));
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
if (med.packageType === "blister") {
if (!isAmountBasedPackageType(med.packageType)) {
lines.push(item(t("report.docPacks"), String(med.packCount)));
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
@@ -383,26 +377,25 @@ function generateTextReport(
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
}
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), formatDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push("");
// Intake Schedule
const allIntakes = med.intakes ?? med.blisters;
const allIntakes = getMedicationIntakes(med);
const intakes = personFilter
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allIntakes;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t);
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
@@ -414,7 +407,7 @@ function generateTextReport(
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
lines.push("");
}
@@ -428,8 +421,8 @@ function generateTextReport(
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
}
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
}
@@ -439,7 +432,7 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills")}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -522,7 +515,7 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const intakes = getMedicationIntakes(med);
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
@@ -554,11 +547,11 @@ function buildPrintHtml(
);
if (med.medicationStartDate)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
);
if (med.isObsolete && med.obsoleteAt)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
);
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
@@ -572,7 +565,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
if (med.packageType === "blister") {
if (!isAmountBasedPackageType(med.packageType)) {
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
@@ -582,10 +575,10 @@ function buildPrintHtml(
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
}
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
@@ -593,18 +586,17 @@ function buildPrintHtml(
// Intake Schedule
const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allPrintIntakes;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
@@ -617,7 +609,7 @@ function buildPrintHtml(
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`;
}
@@ -633,9 +625,9 @@ function buildPrintHtml(
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
@@ -646,7 +638,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
@@ -702,7 +694,7 @@ function buildPrintHtml(
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
${sections.join("\n")}
</body>
</html>`;
+5 -1
View File
@@ -42,6 +42,7 @@ export function ShareDialog({
const { t } = useTranslation();
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
// ESC is handled by the global handler in App.tsx to avoid double history.back()
@@ -91,6 +92,7 @@ export function ShareDialog({
return (
<div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p>
<p className="share-link-label">{t("share.scheduleLink")}</p>
<div className="share-link-box">
<input
type="text"
@@ -131,12 +133,13 @@ export function ShareDialog({
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
<select
id="share-person-select"
className="select-field"
value={shareSelectedPerson}
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
{getPersonLabel(person)}
</option>
))}
</select>
@@ -146,6 +149,7 @@ export function ShareDialog({
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
<select
id="share-period-select"
className="select-field"
value={shareSelectedDays}
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
>
@@ -0,0 +1,226 @@
import { useTranslation } from "react-i18next";
import {
getPackageSize,
isLiquidContainerPackageType,
isTubePackageType,
type SharedMedicationOverviewItem,
} from "../types";
import { formatDate } from "../utils/formatters";
import { MedicationAvatar } from "./MedicationAvatar";
function formatPackageAmountUnit(medication: SharedMedicationOverviewItem, t: (key: string) => string): string | null {
if (isTubePackageType(medication.packageType)) {
return t("form.packageAmountUnitG");
}
if (isLiquidContainerPackageType(medication.packageType)) {
return t("form.packageAmountUnitMl");
}
if (medication.packageAmountUnit === "g") {
return t("form.packageAmountUnitG");
}
if (medication.packageAmountUnit === "ml") {
return t("form.packageAmountUnitMl");
}
return null;
}
function formatPackageInfo(medication: SharedMedicationOverviewItem, t: (key: string) => string): string {
if (medication.packageType === "blister") {
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
}
const unitLabel = formatPackageAmountUnit(medication, t);
if (unitLabel && medication.packageAmountValue && medication.packageAmountValue > 0) {
const sizeLabel = `${medication.packageAmountValue} ${unitLabel}`;
return medication.packCount > 1 ? `${medication.packCount} x ${sizeLabel}` : sizeLabel;
}
const packageSize = getPackageSize(medication);
if (packageSize > 0) {
return medication.packCount > 1 ? `${medication.packCount} x ${packageSize}` : `${packageSize}`;
}
return `${Math.max(medication.packCount, 1)}`;
}
function getOverviewStatus(
priority: SharedMedicationOverviewItem["priority"]
): { className: string; labelKey: string } | null {
if (priority === null) return null;
if (priority === "out-of-stock") {
return { className: "danger", labelKey: "status.outOfStock" };
}
if (priority === "high") {
return { className: "warning", labelKey: "status.lowStock" };
}
return { className: "normal", labelKey: "status.normal" };
}
export interface SharedMedicationOverviewSectionProps {
takenBy: string;
sharedBy: string | null;
medications: SharedMedicationOverviewItem[];
showTitle?: boolean;
onMedicationImageClick?: (imageUrl: string, name: string) => void;
}
export function SharedMedicationOverviewSection({
takenBy,
medications,
showTitle = true,
onMedicationImageClick,
}: SharedMedicationOverviewSectionProps) {
const { t } = useTranslation();
const renderMedicationAvatar = (name: string, imageUrl: string | null) => {
const isClickable = Boolean(imageUrl && onMedicationImageClick);
return (
<div
className={isClickable ? "med-avatar clickable" : undefined}
onClick={() => {
if (imageUrl && onMedicationImageClick) onMedicationImageClick(imageUrl, name);
}}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && imageUrl && onMedicationImageClick) {
onMedicationImageClick(imageUrl, name);
}
}}
>
<MedicationAvatar name={name} imageUrl={imageUrl} size="sm" />
</div>
);
};
return (
<section className="shared-overview-inline-section" aria-label={t("sharedOverview.title", { person: takenBy })}>
{showTitle ? (
<div className="shared-overview-section-header">
<h2>{t("sharedOverview.title", { person: takenBy })}</h2>
</div>
) : null}
{medications.length === 0 ? (
<p className="shared-schedule-empty">{t("sharedOverview.noMedications")}</p>
) : (
<>
<div className="shared-overview-table-wrap">
<table className="shared-overview-table">
<thead>
<tr>
<th>{t("sharedOverview.columns.name")}</th>
<th>{t("sharedOverview.columns.package")}</th>
<th>{t("sharedOverview.columns.stock")}</th>
<th>{t("sharedOverview.columns.daysLeft")}</th>
<th>{t("sharedOverview.columns.depletion")}</th>
<th>{t("sharedOverview.columns.priority")}</th>
</tr>
</thead>
<tbody>
{medications.map((medication) => {
const overviewStatus = getOverviewStatus(medication.priority);
return (
<tr key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}>
<td>
<div className="shared-overview-medication-cell">
{renderMedicationAvatar(medication.name, medication.imageUrl)}
<div className="shared-overview-medication-text">
<div className="shared-overview-med-name">
<strong>{medication.name}</strong>
{medication.genericName ? (
<span className="shared-overview-med-generic">{medication.genericName}</span>
) : null}
</div>
</div>
</div>
</td>
<td>{formatPackageInfo(medication, t)}</td>
<td>
<span className="shared-overview-stock-value">
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</span>
</td>
<td>{medication.daysLeft === null ? "-" : medication.daysLeft}</td>
<td>
<span className="shared-overview-date-value">{formatDate(medication.depletionDate)}</span>
</td>
<td>
{overviewStatus === null ? (
"-"
) : (
<span className={`shared-overview-priority ${overviewStatus.className}`}>
{t(overviewStatus.labelKey)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="shared-overview-cards">
{medications.map((medication) => {
const overviewStatus = getOverviewStatus(medication.priority);
return (
<article
className="shared-overview-card"
key={`${medication.name}-${medication.medicationStartDate ?? "no-start"}`}
>
<div className="shared-overview-card-title">
{renderMedicationAvatar(medication.name, medication.imageUrl)}
<div className="shared-overview-medication-text">
<div className="shared-overview-med-name">
<strong>{medication.name}</strong>
{medication.genericName ? (
<span className="shared-overview-med-generic">{medication.genericName}</span>
) : null}
</div>
</div>
</div>
<div className="shared-overview-card-grid">
<span>{t("sharedOverview.columns.package")}</span>
<strong>{formatPackageInfo(medication, t)}</strong>
<span>{t("sharedOverview.columns.stock")}</span>
<strong>
<span className="shared-overview-stock-value">
{medication.currentStock === null || medication.capacity === null
? "-"
: t("sharedOverview.stock.of", {
current: medication.currentStock,
capacity: medication.capacity,
})}
</span>
</strong>
<span>{t("sharedOverview.columns.daysLeft")}</span>
<strong>{medication.daysLeft === null ? "-" : medication.daysLeft}</strong>
<span>{t("sharedOverview.columns.depletion")}</span>
<strong>
<span className="shared-overview-date-value">{formatDate(medication.depletionDate)}</span>
</strong>
</div>
{overviewStatus ? (
<span className={`shared-overview-priority ${overviewStatus.className}`}>
{t(overviewStatus.labelKey)}
</span>
) : null}
</article>
);
})}
</div>
</>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More