Compare commits

...

124 Commits

Author SHA1 Message Date
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
Daniel Volz f4723c6f99 chore: release v1.18.0 (#365) 2026-03-02 00:41:20 +01:00
github-actions[bot] aad6b143ef chore: update test count badges [skip ci] 2026-03-01 23:27:20 +00:00
Daniel Volz da004b5c3e fix: align frontend tube/liquid container semantics (#364)
* fix: align frontend tube/liquid container semantics

* test(frontend): fix PR #364 CI regressions
2026-03-02 00:23:32 +01:00
github-actions[bot] cd18581bdd chore: update test count badges [skip ci] 2026-03-01 23:07:44 +00:00
Daniel Volz 508bc764d5 fix: align backend amount stock and reminder semantics (#362)
* fix: align backend amount stock and reminder semantics

* test: align settings email route success mock with SMTP delivery checks
2026-03-02 00:02:26 +01:00
Daniel Volz 9e8a6315e7 fix: keep topical stock non-depleting in planner flows (#359)
* fix: keep topical stock non-depleting in planner and reports

* test: stabilize e2e selectors for updated medication semantics

* fix(backend): add missing planner translation keys
2026-02-28 23:36:52 +01:00
github-actions[bot] 8efd99d738 chore: update test count badges [skip ci] 2026-02-28 22:28:41 +00:00
github-actions[bot] dc98dfda44 chore: update test count badges [skip ci] 2026-02-28 22:25:25 +00:00
Daniel Volz 8aaeca6b26 feat: simplify tube stock editing UI (#357)
* feat: add package amount persistence and backend route support

* test: align backend test schemas with medication metadata fields

* fix(backend): restore intake usage normalizer for planner endpoint

* fix(backend): keep export typing compatible before liquid-unit stack step

* feat: simplify tube stock editing in desktop and mobile forms
2026-02-28 23:24:48 +01:00
Daniel Volz 7accb2aad6 feat: persist package amount metadata in backend (#356)
* feat: add package amount persistence and backend route support

* test: align backend test schemas with medication metadata fields

* fix(backend): restore intake usage normalizer for planner endpoint

* fix(backend): keep export typing compatible before liquid-unit stack step
2026-02-28 23:21:13 +01:00
Daniel Volz 2f2edfa479 chore: release v1.17.1 (#351) 2026-02-27 01:53:09 +01:00
Daniel Volz b009d9e158 fix: frontend nginx restart loop from invalid log_format scope (#350)
* fix: place nginx log_format in valid context

* fix: unblock required checks for nginx hotfix

* fix: restore mandatory doku files in nginx hotfix pr
2026-02-27 01:47:59 +01:00
dependabot[bot] 8e4cb5dcd4 build(deps): bump minimatch from 10.2.2 to 10.2.4 in /backend (#338)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.2 to 10.2.4.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v10.2.2...v10.2.4)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 10.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:29:04 +01:00
dependabot[bot] 7f26dca7a7 build(deps-dev): bump @types/node (#343)
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.0 to 25.3.2
- [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.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>
2026-02-27 01:29:00 +01:00
dependabot[bot] 46d768dd4e build(deps): bump the minor-and-patch group in /frontend with 5 updates (#344)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.0` | `19.2.4` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.2` | `19.2.14` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.0` | `19.2.4` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.2` | `19.2.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.0` | `25.3.2` |


Updates `react` from 19.2.0 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

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

Updates `react-dom` from 19.2.0 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

Updates `@types/react-dom` from 19.2.2 to 19.2.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

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

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

Updates `@types/react-dom` from 19.2.2 to 19.2.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.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-02-27 01:28:55 +01:00
dependabot[bot] c62b6d7893 build(deps): bump actions/upload-artifact from 6 to 7 (#345)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  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-02-27 01:28:51 +01:00
dependabot[bot] 1668eb935c build(deps-dev): bump @types/supertest from 6.0.3 to 7.2.0 in /backend (#346)
Bumps [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) from 6.0.3 to 7.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest)

---
updated-dependencies:
- dependency-name: "@types/supertest"
  dependency-version: 7.2.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>
2026-02-27 01:28:47 +01:00
Daniel Volz 1ea4919323 chore: release v1.17.0 (#348) 2026-02-27 01:19:39 +01:00
Daniel Volz ba0ab672b9 docs: update memory and report for multi-pr delivery (#347) 2026-02-27 01:15:40 +01:00
Daniel Volz 57c998ba09 chore: update dependabot automation and agent governance (#341)
* chore: update dependabot automation and agent governance

* chore: trigger required CI checks for governance PR
2026-02-27 01:11:05 +01:00
Daniel Volz cc22f80209 fix: align frontend types and tests for react 19 (#339) 2026-02-27 01:01:48 +01:00
Daniel Volz 6b27d234d9 chore: reduce polling log noise across backend and nginx (#336) 2026-02-27 00:54:21 +01:00
Daniel Volz 19ba4bb7d2 feat: add FORM_LOGIN_ENABLED auth toggle (#334) 2026-02-27 00:48:58 +01:00
dependabot[bot] 8b3901c1e1 build(deps): bump rollup from 4.53.5 to 4.59.0 in /frontend (#333)
Bumps [rollup](https://github.com/rollup/rollup) from 4.53.5 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.5...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 06:29:10 +01:00
dependabot[bot] fd7cc56bb7 build(deps): bump rollup from 4.57.1 to 4.59.0 in /backend (#332)
Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 06:28:51 +01:00
Daniel Volz aabe58d05f ci: add path filters to Docker build workflow
Only build Docker images when backend/, frontend/, docker-compose,
or the workflow itself changes. Prevents unnecessary image builds
for docs-only or config-only changes on main.

Note: paths filter is not evaluated for tag pushes (GitHub Actions
behavior), so release tags always trigger a full build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-26 00:22:08 +01:00
Daniel Volz b35101d339 docs: update AI model credits to Claude Opus 4.6 and GPT-5.3 Codex
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-26 00:16:10 +01:00
dependabot[bot] 8420c74a55 build(deps): bump bn.js from 4.12.2 to 4.12.3 in /backend (#330)
Bumps [bn.js](https://github.com/indutny/bn.js) from 4.12.2 to 4.12.3.
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v4.12.2...v4.12.3)

---
updated-dependencies:
- dependency-name: bn.js
  dependency-version: 4.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 00:14:46 +01:00
Daniel Volz 872b63f665 docs: add explicit scope rule to release-manager agent
Prevent release-manager from chaining unrequested steps.
If user asks for PR+merge only, do not also start a release.
If user asks for release only, do not also create PRs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-26 00:03:07 +01:00
github-actions[bot] f599ac45ab chore: update test count badges [skip ci] 2026-02-25 22:57:37 +00:00
Daniel Volz f36d56c523 test: update modal tests to reflect global ESC handler
Remove ESC-keydown tests from ProfileModal.test.tsx since the
useEscapeKey hook was removed from individual modals. Escape key
handling is now centralized in App.tsx's global handler, making
per-component ESC tests invalid (the component no longer responds
to ESC in isolation).
2026-02-25 23:54:21 +01:00
Daniel Volz f0496e8ca5 fix: remove duplicate ESC handlers causing double history.back()
AboutModal, ProfileModal, and ShareDialog each had their own
useEscapeKey hook AND were handled by the global ESC handler in
App.tsx. When ESC was pressed, both fired synchronously, calling
history.back() twice — navigating past the current page instead
of just closing the modal.

Removed the per-modal useEscapeKey calls since the global handler
in App.tsx already manages ESC priority for all modals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 23:50:07 +01:00
Daniel Volz de300ad919 chore: release v1.16.1 (#326) 2026-02-25 22:26:19 +01:00
Daniel Volz 06bf608913 fix: support core shoutrrr provider schemes (#317)
* fix: support core shoutrrr provider schemes

* fix(ci): resolve backend lint failures and harden shoutrrr URL handling

* fix(codeql): mark validated gotify target as intentional external call

* fix(codeql): route gotify scheme through validated webhook sink
2026-02-25 22:22:50 +01:00
Daniel Volz a47bde0956 chore: sync lockfile package versions to 1.16.0 (#325)
* chore: sync lockfile package versions to 1.16.0

* fix(ci): align medications route formatting with biome
2026-02-25 22:15:43 +01:00
Daniel Volz d02f16af3a fix: stabilize e2e CI and local playwright workers (#321)
* fix: stabilize e2e CI and local playwright workers

* fix(ci): apply biome formatting and import order for frontend build
2026-02-25 22:15:38 +01:00
Daniel Volz dbdf3b61cb fix: harden reminder scheduler dedupe and boundary timing (#319)
* fix: harden reminder scheduler dedupe and boundary timing

* fix(ci): align medications route formatting with biome
2026-02-25 22:15:35 +01:00
Daniel Volz aa29d1c699 docs: align agent instructions and README guidance (#323) 2026-02-25 21:35:35 +01:00
Daniel Volz bfc9aaaa6d fix: tag releases on merged PR commit (#315) 2026-02-25 21:35:32 +01:00
Copilot 2a9ca39c24 Allow medications with only a generic name (no commercial name required) (#311)
* Initial plan

* feat: allow generic name only for medications (frontend changes)

- Add getMedDisplayName() helper for consistent name display
- Update validation to require either commercial or generic name
- Update all display locations to use display name fallback
- Add i18n keys for nameOrGenericRequired in en.json and de.json
- Remove required attribute from commercial name field
- Update FIELD_LIMITS.name.min from 1 to 0

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* feat: allow generic name only for medications (backend changes)

- Update Zod schema to allow empty name with cross-field refinement
- Update reminder scheduler to use name || genericName for display
- Update planner routes to match medications by display name
- Update existing tests to match new validation behavior

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* fix: update placeholder text and fix FIELD_LIMITS test

- Remove "(optional)" from generic name placeholder in en/de
- Update types.test.ts to expect FIELD_LIMITS.name.min = 0

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-02-25 21:29:25 +01:00
dependabot[bot] 691550fb33 build(deps): bump bn.js from 4.12.2 to 4.12.3 in /backend (#305)
Bumps [bn.js](https://github.com/indutny/bn.js) from 4.12.2 to 4.12.3.
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v4.12.2...v4.12.3)

---
updated-dependencies:
- dependency-name: bn.js
  dependency-version: 4.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 21:29:13 +01:00
Daniel Volz 0fded0d42f chore: release v1.16.0 (#308) 2026-02-25 00:19:56 +01:00
Daniel Volz badee6067c chore: add .claude/ to gitignore (#307) 2026-02-25 00:09:20 +01:00
Daniel Volz 6161c14a7b fix: logo optimization, deprecated meta tag, and clipboard copy fallback (#306)
- Replace 2 MB favicon.svg (base64-PNG-in-SVG) with optimized 43 KB app-logo.png (256x256)
- Update AppHeader and AboutModal references to use new logo
- Remove SVG favicon link from index.html (PNG/ICO favicons remain)
- Fix deprecated apple-mobile-web-app-capable → mobile-web-app-capable meta tag
- Add clipboard copy fallback for non-secure contexts (LAN IP over HTTP)

Closes #303
2026-02-25 00:04:35 +01:00
Daniel Volz 96b2a0c96f feat: image upload optimization with sharp, thumbnails, and structured error codes (#304)
- Add sharp for server-side image processing (WebP conversion + thumbnails)
- New shared backend utility for image upload, optimization, and cleanup
- Return structured error codes from upload endpoints (IMAGE_TOO_LARGE, INVALID_TYPE, etc.)
- Frontend error code mapping with i18n support (EN + DE)
- MedicationAvatar tries thumbnail first, falls back to full image
- Error display in MedicationsPage, MobileEditModal, and Auth avatar upload

Closes #302
2026-02-24 23:52:59 +01:00
Daniel Volz 7a32b2045e fix: run one stock reminder catch-up after restart (#300)
* fix: run one stock reminder catch-up after restart

* fix(backend): persist scheduler stock-check timestamp in reminder state
2026-02-24 21:21:34 +01:00
Daniel Volz 26475fd3d0 feat: add correlation ids and tighten frontend security headers (#299)
* feat: add correlation ids and tighten frontend security headers

* docs: remove obsolete project setup guide

* fix: restore health config flags for compatibility

* test(frontend): align auth fetch assertions with correlation headers
2026-02-24 21:21:30 +01:00
Daniel Volz 63cd9ef19b fix: harden share link dose operations and token reuse (#298)
* fix: harden share link dose operations and token reuse

* fix: restore share dose compatibility and add correlation helper
2026-02-24 21:12:43 +01:00
github-actions[bot] f15c2dd79f chore: update test count badges [skip ci] 2026-02-23 18:58:48 +00:00
Daniel Volz b0c5d48095 chore: update bug template guidance and include app test changes (#293) 2026-02-23 19:54:18 +01:00
dependabot[bot] 05226cc500 build(deps): bump the minor-and-patch group in /frontend with 4 updates (#291)
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), [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) and [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `i18next` from 25.8.10 to 25.8.13
- [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.10...v25.8.13)

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

Updates `react-router-dom` from 7.13.0 to 7.13.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.1/packages/react-router-dom)

Updates `@biomejs/biome` from 2.4.1 to 2.4.4
- [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.4/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 0.575.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  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-02-23 19:25:28 +01:00
dependabot[bot] 3e4f1440a9 build(deps-dev): bump the minor-and-patch group (#290)
Bumps the minor-and-patch group in /backend with 3 updates: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer).


Updates `@biomejs/biome` from 2.4.1 to 2.4.4
- [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.4/packages/@biomejs/biome)

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

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

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/nodemailer"
  dependency-version: 7.0.11
  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-02-23 19:25:24 +01:00
dependabot[bot] d64a833bda build(deps-dev): bump @biomejs/biome from 2.4.1 to 2.4.4 (#289)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.1 to 2.4.4.
- [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.4/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 19:25:19 +01:00
Daniel Volz ba36f67371 fix: smooth mobile edit transition and align modal validation behavior (#286)
* fix: reliable Escape key close for all modals via useEscapeKey hook

- Add useEscapeKey hook (document-level keydown listener)
- Retrofit all 12 modal/overlay components to use it
- Remove redundant overlay onKeyDown Escape handlers
- Simplify modal-content onKeyDown to plain stopPropagation
- Replace MedDetailModal's capture-phase useEffect with 3 useEscapeKey calls
- Replace SharedSchedule's inline useEffect with useEscapeKey
- Add mandatory modal rules to UI Consistency skill
- All 777 frontend + 569 backend tests pass

* fix: smooth mobile edit transition and align modal validation behavior

* fix: keep overlay keydown non-closing for Enter key

* fix: show mobile name error when validation already exists

* fix: restore app-level escape priority handling

* fix: prioritize schedule lightbox on Escape
2026-02-23 06:42:06 +01:00
Daniel Volz 2aa6b1f406 fix: prevent background scroll when any modal is open (#284)
Replace CSS-only modal-open class toggle with a shared useScrollLock
hook that uses position:fixed + scroll position save/restore. This
reliably prevents background scrolling on all browsers including
iOS Safari.

The hook supports nesting (lock counter) so stacked modals (e.g.
MedDetail → RefillModal) work correctly.

Also adds missing modal states to the scroll lock: showRefillModal,
showEditStockModal, showImageLightbox, scheduleLightboxImage.

Replaces the inline 40-line scroll lock in MobileEditModal with the
shared hook.
2026-02-22 18:40:39 +01:00
Daniel Volz 3238a22fd6 test: add E2E regression tests for MedDetail tooltip visibility (#282)
Guard against tooltip pseudo-elements being clipped by ancestor
overflow:hidden or hidden behind modal overlays. Covers edit,
stock correction, export, and close button tooltips.
2026-02-22 18:07:58 +01:00
Daniel Volz b139660241 chore: release v1.15.1 (#280) 2026-02-22 18:02:32 +01:00
188 changed files with 26359 additions and 9109 deletions
+26 -2
View File
@@ -11,15 +11,32 @@ PGID=1000
PORT=3000
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=info
LOG_LEVEL=warn
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
#
# Behavior per level:
# debug — all app logs + all HTTP request logs (including polling endpoints)
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
# warn — only warnings and errors
# error — only errors
# silent — no logs
# Rate limit: max requests per minute per IP (default: 100)
# Increase for development/testing environments
# RATE_LIMIT_MAX=100
# 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
@@ -32,6 +49,9 @@ AUTH_ENABLED=false
# Allow new user registrations (auto-enabled when no users exist)
# REGISTRATION_ENABLED=false
# Disable username/password form login (useful for OIDC-only setups)
# FORM_LOGIN_ENABLED=true
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
# Generate with: openssl rand -hex 32
# JWT_SECRET=
@@ -102,12 +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
@@ -126,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
+20 -1
View File
@@ -1,12 +1,19 @@
name: 🐛 Bug Report
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "triage"]
assignees:
- DanielVolz
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below.
Before submitting, please reproduce the issue on the latest released version.
Even better: verify it on the current `main` image/tag.
The issue may already be fixed in newer builds.
- type: textarea
id: description
attributes:
@@ -57,6 +64,18 @@ body:
validations:
required: true
- type: textarea
id: version_info
attributes:
label: Version / Image Information
description: Provide the app version and, if using Docker, the exact image tag you are running.
placeholder: |
App version (Settings -> About): vX.Y.Z
Docker image tag (if applicable): latest or main
Tag guidance: use `latest` for the newest release, or `main` for the newest changes from the main branch (`main` is always as new as or newer than `latest`).
validations:
required: true
- type: input
id: browser
attributes:
-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
@@ -0,0 +1,42 @@
---
description: 'Provide principal-level software engineering guidance with focus on engineering excellence, technical leadership, and pragmatic implementation.'
name: 'Principal software engineer'
tools: ['changes', 'search/codebase', 'edit/editFiles', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runTasks', 'runTests', 'search', 'search/searchResults', 'runCommands/terminalLastCommand', 'runCommands/terminalSelection', 'testFailure', 'usages', 'vscodeAPI', 'github']
---
# Principal software engineer mode instructions
You are in principal software engineer mode. Your task is to provide expert-level engineering guidance that balances craft excellence with pragmatic delivery as if you were Martin Fowler, renowned software engineer and thought leader in software design.
## Core Engineering Principles
You will provide guidance on:
- **Engineering Fundamentals**: Gang of Four design patterns, SOLID principles, DRY, YAGNI, and KISS - applied pragmatically based on context
- **Clean Code Practices**: Readable, maintainable code that tells a story and minimizes cognitive load
- **Test Automation**: Comprehensive testing strategy including unit, integration, and end-to-end tests with clear test pyramid implementation
- **Quality Attributes**: Balancing testability, maintainability, scalability, performance, security, and understandability
- **Technical Leadership**: Clear feedback, improvement recommendations, and mentoring through code reviews
## Implementation Focus
- **Requirements Analysis**: Carefully review requirements, document assumptions explicitly, identify edge cases and assess risks
- **Implementation Excellence**: Implement the best design that meets architectural requirements without over-engineering
- **Pragmatic Craft**: Balance engineering excellence with delivery needs - good over perfect, but never compromising on fundamentals
- **Forward Thinking**: Anticipate future needs, identify improvement opportunities, and proactively address technical debt
## Technical Debt Management
When technical debt is incurred or identified:
- **MUST** offer to create GitHub Issues using the `create_issue` tool to track remediation
- Clearly document consequences and remediation plans
- Regularly recommend GitHub Issues for requirements gaps, quality issues, or design improvements
- Assess long-term impact of untended technical debt
## Deliverables
- Clear, actionable feedback with specific improvement recommendations
- Risk assessments with mitigation strategies
- Edge case identification and testing strategies
- Explicit documentation of assumptions and decisions
- Technical debt remediation plans with GitHub Issue creation
+69 -58
View File
@@ -12,10 +12,18 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
## Critical Safety Rules
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
- **Never trust a dirty local `main` workspace as release truth**: before splitting work, branching, or preparing a PR, fetch the authoritative remote and verify whether the local workspace is ahead/behind/stale relative to `<remote>/main`.
- **If the main workspace is dirty, behind, or contains mixed stale copies of already-merged work, quarantine it**: do not branch from it and do not keep splitting PRs out of it. Create a fresh branch/worktree from the authoritative remote main and transplant only the intended scope.
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
@@ -44,16 +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).
- Do not use these commands in agent flows:
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
- Use safe variants instead:
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
## Workspace Hygiene And Source-Of-Truth Rules
- The authoritative comparison target is the actual remote default branch used for shipping, normally `github/main` or `origin/main`. Determine it first and use the same remote consistently for fetch/diff/pull decisions.
- Before any PR split or branch creation, run a source-of-truth audit:
1. fetch the authoritative remote
2. inspect `git status`
3. compare local `main` against `<remote>/main`
4. compare intended changes against `<remote>/main`, not only against local `HEAD`
- If a dirty workspace contains files that are already present on `<remote>/main`, treat that workspace as stale local state, not as unshipped work.
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
---
@@ -118,8 +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` and CI is expected to pass.
1. Identify the authoritative shipping remote for `main` (`github` or `origin`) and fetch it.
2. Check for uncommitted changes: `git status`.
3. Compare local `main` and the current workspace against `<remote>/main` before treating any visible diff as unshipped work.
4. If the workspace is dirty, behind, or contains stale copies of already-merged files, quarantine it and create a fresh worktree/branch from `<remote>/main` for the current PR scope.
5. Confirm testing has been completed by `@testing-manager`.
6. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
7. Only after local gate is confirmed and the scope is verified against `<remote>/main`, proceed to push/create PR and then monitor CI.
### Step 2: Create Feature Branch
@@ -127,11 +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"
@@ -140,39 +165,29 @@ When code changes (features or bug fixes) are complete:
### Step 3: Push and Create PR
1. Push the branch:
1. Re-check local gate status before push/PR creation (lint + relevant local tests green).
2. Push the branch:
```bash
git push -u origin feat/short-description
```
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
```bash
gh pr create \
--title "fix: short description" \
--body "Closes #<ISSUE_NUMBER>
Description of changes" \
--assignee DanielVolz \
--label bug \
--project "@DanielVolz's MedAssist-ng project"
```
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
3. Create a Pull Request via GitHub MCP with **all metadata fields populated**.
- Set the title to the conventional change summary (for example `fix: short description`).
- Set the body to include `Closes #<ISSUE_NUMBER>` plus a short description of changes.
- Set assignee to `DanielVolz`.
- Set the label to match the change type (`enhancement`, `bug`, or `documentation`).
- Link the PR to `@DanielVolz's MedAssist-ng project`.
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
- The `--project` flag links the PR to the Project board.
3. **Present the PR URL to the user and wait for confirmation.**
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
4. **Present the PR URL to the user and wait for confirmation.**
### Step 4: Wait for CI and Merge
1. Monitor CI status:
```bash
gh pr checks <PR_NUMBER> --watch
```
1. Monitor CI status via GitHub MCP until all required checks complete.
Required checks: all repository-required checks must pass.
2. If CI fails: analyze the failure, fix it, push again, and re-check.
3. Once CI is green, **ask the user for merge confirmation**, then:
```bash
gh pr merge <PR_NUMBER> --squash --delete-branch
```
4. Switch back to main and pull:
3. Once CI is green, **ask the user for merge confirmation**, then merge the PR via GitHub MCP using squash merge and branch deletion.
4. Re-sync the authoritative local `main` before using it again as a source of truth for any next PR or release step. Do not continue from a previously dirty workspace without another source-of-truth audit.
5. Switch back to main and pull:
```bash
git checkout main
git pull origin main
@@ -241,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.
@@ -385,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)
@@ -438,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 |
+87 -13
View File
@@ -14,10 +14,18 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
- **Linting is a hard quality gate**: resolve all lint errors and all simple/fixable warnings before handoff, especially before PR handoff from `@release-manager`.
- **Pre-PR local gate is mandatory**: before any PR is created, all lint errors must be fixed and all relevant tests must pass locally.
- **No CI-first failures**: tests must fail locally when broken and be fixed locally before PR handoff; do not rely on GitHub CI to discover obvious regressions.
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
- **Use GitHub MCP for all GitHub workflow/PR inspection. Never use `gh` CLI.** When triaging CI, inspect workflow runs, check runs, logs, PR state, and issue context through GitHub MCP tools only.
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
- **Regression prevention is mandatory**: every fixed bug must get a deterministic regression test that fails before the fix and passes after it.
## CI/CD Ownership Boundary
@@ -27,9 +35,10 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
## Test Stack & Locations
- **Backend**: Vitest 2.1 + v8 coverage
- **Frontend unit/integration**: Vitest
- **E2E**: Playwright
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
- **Static quality gates**: TypeScript via `tsc --noEmit` and Biome via `npx biome check .`
Primary locations:
@@ -37,37 +46,81 @@ Primary locations:
- Frontend tests: `frontend/src/test/**`
- Playwright E2E: `frontend/e2e/**`
## Testing Strategy Defaults
- **Default to targeted validation, not shotgun runs**: start with the smallest test command that exercises the changed behavior.
- **Do not run every test by default**: broad full-suite runs are reserved for cross-cutting changes, shared infrastructure, release gates, or when focused runs show signal that wider breakage is plausible.
- **Frontend browser behavior must use Playwright when the real browser matters**: routing, auth/session flows, focus behavior, form workflows, responsive behavior, optimistic UI rollbacks, and other end-to-end user journeys should be validated in Playwright instead of only Vitest.
- **Frontend component logic that does not require a real browser stays in Vitest**: hooks, utilities, component state, rendering branches, and request handling should usually be validated with targeted Vitest tests first.
- **Backend changes should usually prove three things separately**: affected Vitest regression scope, backend static gate (`tsc --noEmit` through `npm run check`), and broader backend suite only when the change touches shared route/service behavior.
- **Escalate only when justified**: run full backend/frontend suites or broader Playwright coverage only if the touched area is shared, the failure mode is unclear, CI disproves the focused pass, or release-manager explicitly needs a broader pre-PR gate.
## Required Test Workflow
1. Identify changed behavior and expected outcomes.
2. Add/update tests near the affected feature.
3. Run the smallest relevant subset first.
4. Expand to broader suites if subset passes.
5. Report what was run, what passed, and any remaining known failures.
2. Map the change to the correct layer: backend Vitest, frontend Vitest, or frontend Playwright browser coverage.
3. Add/update tests near the affected feature.
4. Run the smallest relevant subset first.
5. Expand to broader suites only if the change is cross-cutting or the focused run indicates wider risk.
6. Run lint + required local test/build gates before PR handoff.
7. Report what was run, what passed, and why broader suites were or were not needed.
## Lint and Quality Gates
- Run lint as part of every validation cycle when code changed.
- Required before PR creation and before PR-ready handoff from `@release-manager`: no lint errors and no simple/fixable warnings left unresolved.
- If lint fails, fix root causes first, then re-run affected tests.
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
- Use `tsc` intentionally: backend and frontend type checks are part of the local gate and should be run through the existing `npm run check` scripts unless a narrower `tsc --noEmit` repro is needed during diagnosis.
Recommended commands:
```bash
npm run lint
cd backend && npm run check
cd frontend && npm run check
```
## Commands
### Backend
```bash
cd backend && CI=true npm test
cd backend && npx tsc --version
cd backend && npx vitest --version
cd backend && CI=true npm run test:run -- src/test/doses.test.ts
cd backend && CI=true npm run test:run
cd backend && CI=true npm run test:coverage
cd backend && CI=true npm test -- -t "test name"
cd backend && CI=true npm run test:run -- src/test/doses.test.ts src/test/integration.test.ts
cd backend && CI=true npm run test:run -- -t "test name"
```
### Frontend
```bash
cd frontend && CI=true npm test
cd frontend && npx tsc --version
cd frontend && npx vitest --version
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx
cd frontend && CI=true npm run test:run
cd frontend && CI=true npm run test:coverage
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx src/test/hooks/useDoses.test.ts
cd frontend && CI=true npm run test:run -- -t "test name"
cd frontend && npm run lint
cd frontend && npm run check
cd frontend && npm run build
```
### Playwright E2E
```bash
cd frontend && npm run test:e2e
cd frontend && npm run test:e2e -- --project=chromium
cd frontend && npx playwright --version
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --grep "schedule"
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- frontend/e2e/schedule.spec.ts
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
# Never use interactive UI/headed/report-server commands in agent runs.
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
```
@@ -78,13 +131,31 @@ cd frontend && npm run test:e2e -- --project=chromium
- Validate both status codes and response payloads.
- Add regression tests for every fixed bug.
- Keep tests deterministic and isolated.
- Validate observable behavior, not implementation details.
## E2E Test Patterns
- Use stable selectors and explicit assertions.
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
- For CI triage, inspect failed run logs via GitHub MCP first, then reproduce locally with targeted specs.
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
- Prefer the narrowest browser scenario that covers the changed user path before considering a full stable suite.
## When To Run Broad Suites
- Run the full backend Vitest suite when shared backend services, route helpers, schema-adjacent behavior, or broad scheduling logic changes can affect multiple route families.
- Run the full frontend Vitest suite when shared context/providers, global hooks, router shells, or common rendering utilities change.
- Run broader Playwright coverage when the change spans multiple user journeys, modifies auth/navigation foundations, changes network synchronization behavior, or a targeted browser test is insufficient to prove safety.
- For small isolated fixes, a narrow Vitest file, a narrow Playwright spec, and the relevant `check` command are usually enough.
## Test Validity Checklist
- The test fails when the real target logic is intentionally broken.
- The assertion verifies functional behavior, not just mocked calls.
- Mocks/stubs are minimal and do not replace the unit under test.
- The test is deterministic across repeated local and CI runs.
- The test protects against the specific regression that was fixed.
## CI Failure Triage
@@ -115,6 +186,9 @@ When test checks fail:
Testing work is complete when:
- Required tests exist and validate intended behavior.
- Tests are proven valid (not fake-green) and reliable.
- Lint is clean: no errors and no simple/fixable warnings left.
- Pre-PR local gate passed: lint and all relevant tests pass locally before handoff for PR creation.
- Relevant local test commands pass.
- CI test failures are resolved or clearly documented with rationale.
- No temporary debugging files remain in the workspace.
+12 -70
View File
@@ -1,77 +1,19 @@
# MedAssist-ng - AI Coding Instructions
# MedAssist-ng - Copilot Entry Point
## Purpose
## VERY IMPORTANT
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
## Project Orientation (Read First)
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
- **Primary backend modules**:
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
- **Primary frontend modules**:
- Pages: `frontend/src/pages/`
- Shared app state: `frontend/src/context/AppContext.tsx`
- Domain hooks: `frontend/src/hooks/`
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
## Required Startup Steps
Use this orientation for quick navigation before applying the rules below.
1. Read `AGENTS.md` first.
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
## Always-On Rules
## Scope
- English only for project artifacts.
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
- Testing work belongs to `@testing-manager`.
- PR/release/CI orchestration belongs to `@release-manager`.
- Keep changes local, focused, and consistent with existing UI/API patterns.
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
- Remove obsolete code when re-implementing — never leave dead code behind.
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
## MedAssist Essentials
- Frontend calls backend through `/api/*`.
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
---
## Skills (MANDATORY — read before every task)
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
| Skill | Trigger | File |
|---|---|---|
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
### Non-negotiable parity rules (always apply)
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
---
## Delegation
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
## Key References
- Canonical governance: `AGENTS.md`
- Skill files: `.github/skills/*/SKILL.md`
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
+14 -1
View File
@@ -7,6 +7,7 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:20"
open-pull-requests-limit: 10
labels:
- "dependencies"
@@ -22,6 +23,7 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:10"
open-pull-requests-limit: 10
labels:
- "dependencies"
@@ -37,9 +39,15 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
labels:
- "dependencies"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
@@ -47,7 +55,12 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:30"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
+1 -1
View File
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
## Skills
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
@@ -0,0 +1,69 @@
---
name: medassist-karpathy-core
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
---
# Skill Instructions
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
## Use When
- The request is ambiguous and assumptions must be made explicit.
- The change can easily balloon in scope.
- A bug fix or feature needs explicit success criteria and verification.
- You need to keep diffs minimal and directly tied to the request.
## Do Not Use When
- The task is trivial and can be completed safely without extra process overhead.
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
## Core Principles
### 1. Think Before Coding
- Do not assume silently.
- State assumptions explicitly.
- If multiple interpretations exist, present them instead of picking one invisibly.
- If uncertain or blocked by ambiguity, stop and ask.
- If a simpler approach exists, call it out.
### 2. Simplicity First
- Implement the minimum code required to solve the asked problem.
- Do not add speculative features, abstractions, or configurability.
- Avoid defensive handling for impossible scenarios.
- If the solution feels overcomplicated, simplify before finalizing.
### 3. Surgical Changes
- Touch only lines required for the request.
- Do not refactor unrelated areas.
- Match existing local style and patterns.
- Remove only unused code introduced by your own change.
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
### 4. Goal-Driven Execution
- Translate requests into verifiable outcomes before implementation.
- For multi-step tasks, define short steps with checks.
- Verify the requested behavior explicitly before declaring done.
Example execution frame:
```text
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
```
## Response Format
When this skill is used, report briefly:
- Assumptions made (or clarifications requested)
- Why the chosen approach is the simplest viable one
- What was changed (and what was intentionally not changed)
- Verification performed and result
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
- Avoid custom inline modal/button patterns that diverge from project design.
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
### Modal requirements (non-negotiable)
Every modal/overlay **must** follow these rules:
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
## Decision Heuristics
1. If an equivalent component exists, reuse it.
@@ -0,0 +1,27 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Mark and close stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: stale
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: pinned,security
operations-per-run: 200
@@ -0,0 +1,37 @@
name: Dependabot Automerge
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
permissions:
contents: write
pull-requests: write
jobs:
enable-automerge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for safe updates
if: >-
(steps.metadata.outputs.package-ecosystem == 'npm' ||
steps.metadata.outputs.package-ecosystem == 'github_actions') &&
(steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch')
uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ github.event.pull_request.number }}
merge-method: squash
+64 -15
View File
@@ -4,12 +4,27 @@ on:
push:
branches: [main]
tags: ['v*']
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose.yml'
- 'docker-compose.dev.yml'
- '.github/workflows/docker-build.yml'
workflow_dispatch:
inputs:
tag:
description: 'Image tag (leave empty for "latest")'
description: 'Image/release tag (e.g. v1.19.1 or latest)'
required: false
default: ''
create_release:
description: 'Create GitHub release entry (requires tag starting with v)'
required: false
default: false
type: boolean
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
# Default minimal permissions
permissions:
@@ -48,10 +63,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -59,7 +74,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
tags: |
@@ -70,7 +85,7 @@ jobs:
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -83,12 +98,12 @@ jobs:
sbom: false
# =============================================================================
# Create GitHub Release (only on tag push)
# Create GitHub Release (on tag push or manual dispatch with create_release)
# =============================================================================
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
permissions:
contents: write
@@ -98,10 +113,31 @@ jobs:
with:
fetch-depth: 0 # Fetch all history for changelog generation
- name: Resolve current tag
id: current_tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CURRENT_TAG="${{ github.event.inputs.tag }}"
else
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
fi
if [ -z "$CURRENT_TAG" ]; then
echo "Release tag is required. Provide workflow_dispatch input 'tag'."
exit 1
fi
if [[ "$CURRENT_TAG" != v* ]]; then
echo "Release tag must start with 'v' (example: v1.19.1)."
exit 1
fi
echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
- name: Check if release exists
id: check_release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if gh release view "$CURRENT_TAG" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $CURRENT_TAG already exists, skipping creation"
@@ -115,25 +151,36 @@ jobs:
if: steps.check_release.outputs.exists == 'false'
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true)
else
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
fi
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
echo "## What's Changed" > changelog.md
echo "## What's New" > changelog.md
echo "" >> changelog.md
echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md
echo "" >> changelog.md
echo "### Highlights" >> changelog.md
echo "" >> changelog.md
if [ -n "$PREV_TAG" ]; then
# Get commits between tags
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md
else
# First release - get recent commits
git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Recent shipped commits:" >> changelog.md
git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md
fi
echo "" >> changelog.md
@@ -151,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
+23 -6
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:
@@ -50,11 +65,13 @@ jobs:
run: npx playwright test --project=chromium
env:
CI: true
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_HTML_OPEN: never
JWT_SECRET: e2e-test-secret-that-is-long-enough
SESSION_SECRET: e2e-test-session-secret-long-enough
- name: Upload Playwright report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
@@ -62,7 +79,7 @@ jobs:
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-results
+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(', ')}]`);
}
+3 -3
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: |
@@ -73,7 +73,7 @@ jobs:
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: backend-coverage
@@ -118,7 +118,7 @@ jobs:
run: npm run build
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: frontend-coverage
@@ -0,0 +1,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']
});
+7 -1
View File
@@ -79,6 +79,12 @@ Thumbs.db
.turbo/
.roo/
.roomodes
.claude/
AGENTS.md
docs/TECH_STACK.md
doku
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
}
]
}
+81 -14
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react" alt="React 18" />
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
@@ -18,13 +18,13 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-615%2F615-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-807%2F807-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
> This app was 100% coded with [Claude Opus 4.6](https://www.anthropic.com/claude) and [GPT-5.3 Codex](https://openai.com/index/gpt-5/). Use at your own risk.
### ⚠️ Disclaimer
@@ -120,10 +120,10 @@ Share your medication schedule with others via a public link.
</details>
### 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 +141,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 +152,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 +178,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
git clone https://github.com/DanielVolz/medassist-ng.git
cd medassist-ng
cp .env.example .env
docker compose up -d
docker compose -p medassist-ng up -d
```
Open `http://localhost:4174` and start tracking your medications.
@@ -194,9 +195,24 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
| `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 +227,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 |
@@ -250,7 +303,9 @@ Generate secrets with: `openssl rand -hex 32`
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
Configure push notifications in Settings → Push, or set defaults via environment variables:
@@ -265,9 +320,9 @@ Configure push notifications in Settings → Push, or set defaults via environme
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
Complete list and details:
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
#### URL Examples
@@ -288,6 +343,7 @@ Get your keys at [pushover.net](https://pushover.net/):
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
**Discord**:
@@ -298,6 +354,7 @@ discord://TOKEN@WEBHOOK_ID
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
@@ -305,11 +362,21 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
# Development
```bash
docker compose -f docker-compose.dev.yml up
docker compose -p medassist-dev -f docker-compose.dev.yml up
```
- Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
Useful local commands:
```bash
npm run lint
cd backend && npm run test:run
cd frontend && npm run test:run
```
# Acknowledgements
@@ -0,0 +1,5 @@
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
@@ -0,0 +1,18 @@
CREATE TABLE `api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`key_hash` text(128) NOT NULL,
`token_prefix` text(24) DEFAULT '' NOT NULL,
`scope` text(10) DEFAULT 'write' NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`last_used_at` integer,
`expires_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -76,7 +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_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
}
]
+1420 -419
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.15.0",
"version": "1.20.2",
"private": true,
"type": "module",
"scripts": {
@@ -25,22 +25,26 @@
"@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.1",
"@types/node": "^25.2.3",
"@types/nodemailer": "^7.0.10",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18",
"@biomejs/biome": "^2.4.7",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.0",
"drizzle-kit": "^0.31.9",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
-4
View File
@@ -78,10 +78,6 @@ async function runMigrations() {
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
log.error(`[DB] Migration error: ${migrateResult.error}`);
} else if (migrateResult.warning) {
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
} else {
log.debug(`[DB] Drizzle migrations completed`);
}
// Run ALTER TABLE migrations for backward compatibility
+33 -7
View File
@@ -88,13 +88,12 @@ export async function runDrizzleMigrations(
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: unknown) {
// If the error is about existing schema objects, the DB is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns,
// or when tables were created before drizzle migrations were introduced
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
const msg = (err as Error).message ?? "";
// Duplicate column / already exists = DB is already up-to-date (expected for existing DBs)
if (msg.includes("duplicate column") || msg.includes("already exists")) {
return { success: true };
}
return { success: false, error: (err as Error).message };
return { success: false, error: msg };
}
}
@@ -126,6 +125,14 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
// Added for explicit medication lifecycle start date
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
// Added for form/lifecycle modeling (V1 medication forms)
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
`ALTER TABLE medications ADD COLUMN pill_form text`,
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
@@ -142,6 +149,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`,
@@ -182,7 +191,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) {
@@ -200,6 +223,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) {
+30
View File
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
medicationStartDate: text("medication_start_date").notNull().default(""),
medicationEndDate: text("medication_end_date"),
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
.notNull()
.default(true),
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
@@ -100,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),
@@ -137,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
// =============================================================================
+6
View File
@@ -179,6 +179,8 @@ type TranslationKeys = {
common: {
pill: string;
pills: string;
units: string;
ml: string;
blister: string;
blisters: string;
day: string;
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "pill",
pills: "pills",
units: "units",
ml: "ml",
blister: "blister",
blisters: "blisters",
day: "day",
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "Tablette",
pills: "Tabletten",
units: "Einheiten",
ml: "ml",
blister: "Blister",
blisters: "Blister",
day: "Tag",
+105 -4
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import type { IncomingHttpHeaders } from "node:http";
import { resolve } from "node:path";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
@@ -8,10 +10,13 @@ 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";
@@ -25,6 +30,7 @@ import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
// Re-export utilities from server-config for external use
export {
@@ -45,6 +51,81 @@ import {
parseCorsOrigins,
} from "./utils/server-config.js";
function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
const rawHeader = headers["x-correlation-id"];
if (typeof rawHeader !== "string") return null;
const trimmed = rawHeader.trim();
if (!trimmed) return null;
if (trimmed.length > 128) return null;
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null;
return trimmed;
}
function buildLoggerOptions(level: string) {
const runtimeEnv = process.env.NODE_ENV ?? "production";
const base = {
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
};
// Human-readable logs in development, structured JSON in production/test
if (runtimeEnv === "development") {
return {
...base,
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
};
}
return base;
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "MedAssist-ng API",
description: "MedAssist-ng backend API",
version: process.env.npm_package_version ?? "dev",
},
servers: [{ url: "/", description: "Current server" }],
tags: [
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key or JWT",
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "access_token",
description: "Session cookie set by login.",
},
},
},
},
hideUntagged: false,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
staticCSP: true,
transformSpecificationClone: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
@@ -57,6 +138,7 @@ export async function createApp(options?: {
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
openApiDocsEnabled?: boolean;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
@@ -69,10 +151,19 @@ export async function createApp(options?: {
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
};
const app = Fastify({
logger: { level: opts.logLevel },
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
// Build config
@@ -98,6 +189,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)) {
@@ -111,6 +203,7 @@ 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(settingsRoutes);
@@ -138,9 +231,15 @@ log.info("[DB] Migrations complete, starting server...");
const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
},
logger: buildLoggerOptions(env.LOG_LEVEL),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
const origins = parseCorsOrigins(env.CORS_ORIGINS);
@@ -176,6 +275,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/",
@@ -184,6 +284,7 @@ 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(settingsRoutes);
+127 -6
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";
// =============================================================================
@@ -47,7 +48,7 @@ export async function getAnonymousUserId(): Promise<number> {
export interface AuthState {
authEnabled: boolean;
registrationEnabled: boolean;
localAuthEnabled: boolean;
formLoginEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean;
@@ -59,15 +60,18 @@ export async function getAuthState(): Promise<AuthState> {
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
const hasUsers = result.count > 0;
const needsSetup = env.AUTH_ENABLED && !hasUsers;
return {
authEnabled: env.AUTH_ENABLED,
// Registration: enabled via ENV OR no users exist (first-time setup)
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
// Form login: enabled when auth + form login are both on, or forced on for first-user setup
formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED),
oidcEnabled: env.OIDC_ENABLED,
oidcProviderName: env.OIDC_PROVIDER_NAME,
hasUsers,
needsSetup: env.AUTH_ENABLED && !hasUsers,
needsSetup,
};
}
@@ -79,6 +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
// =============================================================================
@@ -91,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;
@@ -104,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
@@ -118,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" });
@@ -142,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;
}
+41 -4
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
@@ -28,7 +32,11 @@ const EnvSchema = z.object({
.string()
.transform((v) => v === "true")
.default("false"),
// Disable local auth when using SSO only
// Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z
.string()
.transform((v) => v === "true")
.default("true"),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -65,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) {
@@ -128,4 +139,30 @@ if (parsed.OIDC_ENABLED) {
}
}
export const env = parsed;
// Validate that at least one login method is available when auth is enabled
if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) {
console.error("=".repeat(60));
console.error("AUTHENTICATION CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error("AUTH_ENABLED=true but no login method is available.");
console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in.");
console.error("");
console.error("To fix this, either:");
console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login");
console.error(" 2. Set OIDC_ENABLED=true to allow SSO login");
console.error("=".repeat(60));
process.exit(1);
}
// Warn about ineffective registration when form login is disabled
if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
console.warn(
"[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)"
);
}
export const env: Env = {
...parsed,
// Docs UI/spec are enabled in non-production by default.
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
};
+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();
}
);
}
+302 -62
View File
@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import argon2 from "argon2";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
import {
ALLOWED_IMAGE_MIME_TYPES,
removeImageFiles,
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
// =============================================================================
// Argon2id Configuration - State of the Art Password Hashing
@@ -78,10 +85,44 @@ const updateProfileSchema = z.object({
.optional(),
});
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
const authErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
// =============================================================================
// Auth Routes
// =============================================================================
export async function authRoutes(app: FastifyInstance) {
const IMAGES_DIR = resolve(getDataDir(), "images");
// Token TTLs
const accessTtlMinutes = 15;
const refreshTtlDays = 14;
@@ -90,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
app.get(
"/auth/state",
{
config: { rateLimit: false },
schema: {
tags: ["auth"],
summary: "Get authentication state",
description: "Returns auth and login mode state before user login.",
response: {
200: {
type: "object",
properties: {
authEnabled: { type: "boolean" },
registrationEnabled: { type: "boolean" },
formLoginEnabled: { type: "boolean" },
oidcEnabled: { type: "boolean" },
hasUsers: { type: "boolean" },
oidcProviderName: { type: "string" },
},
},
},
},
},
async () => {
return getAuthState();
}
);
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
@@ -101,6 +166,40 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Register local user",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string", minLength: 3, maxLength: 50 },
password: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
},
},
response: {
201: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
},
},
message: { type: "string" },
},
},
400: authErrorSchema,
409: authErrorSchema,
},
},
},
async (request, reply) => {
// Check auth state
@@ -114,8 +213,8 @@ export async function authRoutes(app: FastifyInstance) {
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
if (!state.formLoginEnabled) {
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
}
// Validate input
@@ -148,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
})
.returning();
app.log.info(`User registered: ${username}`);
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
return reply.status(201).send({
ok: true,
@@ -168,6 +267,42 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Login with username and password",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" },
rememberMe: { type: "boolean" },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
rememberMe: true,
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
},
},
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const state = await getAuthState();
@@ -176,8 +311,8 @@ export async function authRoutes(app: FastifyInstance) {
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
if (!state.formLoginEnabled) {
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
}
const parsed = loginSchema.safeParse(request.body);
@@ -241,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
@@ -272,6 +407,15 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Refresh access token",
description: "Requires refresh token cookie context.",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -341,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;
@@ -366,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
@@ -395,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;
@@ -453,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;
@@ -462,36 +689,35 @@ export async function authRoutes(app: FastifyInstance) {
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: "No file uploaded" });
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
}
// Generate unique filename
const ext = data.filename.split(".").pop() || "jpg";
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
let uploadBuffer: Buffer;
try {
uploadBuffer = await streamToBuffer(data.file);
} catch (error) {
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
}
throw error;
}
// Save file
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagesDir = path.join(getDataDir(), "images");
await fs.mkdir(imagesDir, { recursive: true });
const buffer = await data.toBuffer();
await fs.writeFile(path.join(imagesDir, filename), buffer);
let filename: string;
try {
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
} catch {
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
}
// Delete old avatar if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
try {
await fs.unlink(path.join(imagesDir, user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Update user
@@ -509,6 +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;
@@ -522,13 +758,7 @@ export async function authRoutes(app: FastifyInstance) {
}
// Delete file
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
// Update user
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
@@ -545,6 +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;
@@ -555,19 +801,13 @@ export async function authRoutes(app: FastifyInstance) {
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
// Clear auth cookies
return reply
+493 -82
View File
@@ -2,10 +2,23 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import {
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// =============================================================================
// Validation Schemas
@@ -22,6 +35,32 @@ const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
const doseReadResponseSchema = {
type: "object",
properties: {
doses: {
type: "array",
items: {
type: "object",
properties: {
doseId: { type: "string" },
takenAt: { type: "number" },
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
},
},
},
},
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -38,36 +77,224 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
return authUser.id;
}
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) return null;
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
async function getActiveShareToken(token: string): Promise<{
share: typeof shareTokens.$inferSelect | null;
reason: "not_found" | "expired" | "ok";
}> {
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) return { share: null, reason: "not_found" };
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
return { share: null, reason: "expired" };
}
return { share, reason: "ok" };
}
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
const parsedDose = parseDoseId(doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
if (!medication) {
return false;
}
const medTakenBy = parseTakenByJson(medication.takenByJson);
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
return false;
}
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return false;
}
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
if (expectedPersons.length === 0) {
return parsedDose.personSuffix === null;
}
if (!parsedDose.personSuffix) {
return intake.takenBy === null;
}
return expectedPersons.includes(parsedDose.personSuffix);
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
stockCalculationMode: "automatic" | "manual";
}): Promise<boolean> {
const parsedDose = parseDoseId(options.doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
if (!medication) {
return false;
}
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
const intake = intakes[parsedDose.intakeIndex];
const scheduledOccurrenceMs = intake
? (() => {
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
).getTime();
})()
: parsedDose.timestampMs;
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
return (
computeMedicationCurrentStock({
medication,
doses,
stockCalculationMode: options.stockCalculationMode,
nowMs: stockBeforeDoseMs,
}) <= 0
);
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
});
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// Suppress request logs — polled every 5s by frontend
// ---------------------------------------------------------------------------
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/doses/taken",
{
preHandler: requireAuth,
logLevel: "warn",
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: doseReadResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
});
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -90,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,
@@ -107,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);
@@ -136,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);
@@ -173,6 +453,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
@@ -186,60 +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", async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
response: {
200: doseReadResponseSchema,
404: genericErrorSchema,
},
},
logLevel: "warn",
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
});
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
@@ -252,12 +596,20 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseId } = parsed.data;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if already marked
const [existing] = await db
.select()
@@ -265,17 +617,40 @@ export async function doseRoutes(app: FastifyInstance) {
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing) {
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
// Insert new record - marked by the takenBy person
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const outOfStock = await isDoseOutOfStock({
userId: share.userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
const parsedShareDose = parseDoseId(doseId);
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
await db.insert(doseTracking).values({
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
markedBy,
takenSource: "manual",
});
request.log.info(
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
}
);
@@ -283,28 +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;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
await db
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
return { success: true };
}
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
}
return { success: true };
});
);
}
+348 -211
View File
@@ -10,6 +10,12 @@ 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 {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
@@ -17,7 +23,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.1";
const EXPORT_VERSION = "1.3";
// =============================================================================
// Zod Schemas for Import Validation
@@ -27,6 +33,7 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
});
@@ -38,7 +45,9 @@ const inventorySchema = z.object({
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"),
packageType: z.enum(PACKAGE_TYPES).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
const medicationExportSchema = z.object({
@@ -46,11 +55,16 @@ const medicationExportSchema = z.object({
name: z.string().min(1),
genericName: z.string().nullable().optional(),
takenBy: z.array(z.string()).default([]),
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
@@ -122,6 +136,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();
@@ -136,6 +151,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
// =============================================================================
@@ -155,9 +233,14 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
}
// Parse intakes from DB format to export format (with per-intake takenBy)
function parseIntakesForExport(
row: typeof medications.$inferSelect
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
}> {
// Use the new parseIntakesJson which falls back to legacy format
const intakes = parseIntakesJson(
row.intakesJson,
@@ -169,6 +252,7 @@ function parseIntakesForExport(
usage: intake.usage,
every: intake.every,
start: intake.start,
intakeUnit: null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -257,236 +341,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),
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
"/export",
{
schema: {
querystring: exportQuerystringSchema,
response: {
200: exportResponseSchema,
401: genericErrorSchema,
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const 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!)
@@ -499,6 +604,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);
@@ -555,6 +683,7 @@ export async function exportRoutes(app: FastifyInstance) {
usage: s.usage,
every: s.every,
start: s.start,
intakeUnit: s.intakeUnit ?? null,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
@@ -570,7 +699,12 @@ export async function exportRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName || null,
takenByJson,
packageType: med.inventory.packageType ?? "blister",
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
@@ -581,6 +715,8 @@ export async function exportRoutes(app: FastifyInstance) {
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
@@ -659,6 +795,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,
});
}
+28 -7
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,11 +11,31 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const backendVersion = packageJson.version || "unknown";
export async function healthRoutes(app: FastifyInstance) {
// Exempt from rate limit - lightweight health check
app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
}));
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
app.get(
"/health",
{
config: { rateLimit: false },
logLevel: "warn",
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string", enum: ["ok"] },
version: { type: "string" },
smtpConfigured: { type: "boolean" },
},
},
},
},
},
async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
})
);
}
File diff suppressed because it is too large Load Diff
+82 -49
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,64 +66,91 @@ export async function oidcRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/oidc/login - Initiates OIDC flow
// ---------------------------------------------------------------------------
app.get("/auth/oidc/login", async (_request, reply) => {
try {
const config = await getOIDCConfig();
app.get(
"/auth/oidc/login",
{
schema: {
response: {
302: { type: "null", description: "Redirect to OIDC provider" },
500: genericErrorSchema,
},
},
},
async (request, reply) => {
try {
const config = await getOIDCConfig();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authUrl.href);
} catch (err: unknown) {
console.error("[OIDC] Login error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
}
}
});
);
// ---------------------------------------------------------------------------
// GET /auth/oidc/callback - Handles callback from OIDC provider
// ---------------------------------------------------------------------------
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
"/auth/oidc/callback",
{
schema: {
querystring: {
type: "object",
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
response: {
302: { type: "null", description: "Redirect back to frontend" },
},
},
},
async (request, reply) => {
const { code, state, error, error_description } = request.query;
// Handle OIDC provider errors
if (error) {
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
}
@@ -131,14 +161,14 @@ export async function oidcRoutes(app: FastifyInstance) {
// Verify state
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
if (!storedState.valid || storedState.value !== state) {
console.error("[OIDC] State mismatch");
request.log.warn("[OIDC] State mismatch during callback validation");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
}
// Get code verifier
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
if (!storedVerifier.valid || !storedVerifier.value) {
console.error("[OIDC] Missing code verifier");
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
}
@@ -159,7 +189,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Get user info
const sub = tokens.claims()?.sub;
if (!sub) {
console.error("[OIDC] Missing sub claim in token");
request.log.error("[OIDC] Missing sub claim in token response");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
}
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
@@ -174,7 +204,10 @@ export async function oidcRoutes(app: FastifyInstance) {
const oidcSubject = userInfo.sub;
if (!username || !oidcSubject) {
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
request.log.error(
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
"[OIDC] Missing required user info"
);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
}
@@ -205,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Set cookies (use app's centralized cookie options)
request.log.debug(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
@@ -214,7 +247,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: unknown) {
console.error("[OIDC] Callback error:", err);
request.log.error({ err }, "[OIDC] Callback processing failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
}
}
@@ -255,7 +288,7 @@ async function findOrCreateOIDCUser(
// Check if auto-create is enabled
if (!env.OIDC_AUTO_CREATE_USERS) {
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
// No logger is available in this helper, route-level logs already capture callback failures.
return null;
}
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;
});
);
}
File diff suppressed because it is too large Load Diff
+400 -142
View File
@@ -1,12 +1,20 @@
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { buildSharedMedicationOverview } from "../services/coverage.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -14,9 +22,6 @@ import {
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
// =============================================================================
// Validation Schemas
// =============================================================================
@@ -25,6 +30,72 @@ const createShareSchema = z.object({
scheduleDays: z.number().int().min(1).max(365).default(30),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const shareTokenPattern = /^[a-f0-9]{16}$/;
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
},
} as const;
const shareReadResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
scheduleDays: { type: "integer" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
shareMedicationOverview: { type: "boolean" },
medicationOverview: {
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
} as const;
const shareExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
ownerUsername: { type: "string" },
takenBy: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
generatedAt: { type: "string", format: "date-time" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -45,126 +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) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
// Get the username of the owner to show in the expired message
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills =
(med.packageType ?? "blister") === "bottle"
const totalPills = isAmountBasedPackageType(med.packageType)
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: med.packageType ?? "blister",
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: normalizePackageType(med.packageType),
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
const medicationOverview = shareMedicationOverview
? buildSharedMedicationOverview({
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
})
: null;
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/overview",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareOverviewResponseSchema,
404: genericErrorSchema,
410: shareOverviewExpiredResponseSchema,
},
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
});
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
expiredAt: share.expiresAt.toISOString(),
});
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const overview = buildSharedMedicationOverview({
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
generatedAt: new Date().toISOString(),
medications: overview,
};
}
);
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
body: createShareBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
expiresAt: { type: ["string", "null"] },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -179,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
@@ -197,25 +414,47 @@ export async function shareRoutes(app: FastifyInstance) {
});
}
// Generate unique token (8 bytes = 16 hex chars)
// Keep exactly one active share link per person/user.
// If a link already exists, return the same token and only update settings.
const [existingShare] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
};
}
const token = randomBytes(8).toString("hex");
// Set expiration date (1 year from now)
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
// Create share token
await db.insert(shareTokens).values({
userId: userId,
userId,
token,
takenBy,
scheduleDays,
expiresAt,
expiresAt: null,
});
request.log.info(
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: expiresAt.toISOString(),
expiresAt: null,
};
}
);
@@ -223,37 +462,56 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/share/people",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
people: { type: "array", items: { type: "string" } },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(eq(medications.userId, userId));
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
}
}
}
return { people: [...allPeople].sort() };
});
return { people: [...allPeople].sort() };
}
);
}
+220
View File
@@ -0,0 +1,220 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
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) => {
if (intake.every <= 0) return sum;
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage / intake.every;
}, 0);
}
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly);
let nextDate: Date | null = null;
for (const intake of intakes) {
if (intake.every <= 0) continue;
const startDate = parseLocalDateTime(intake.start);
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
let candidate = startDateOnly;
if (candidate.getTime() < today.getTime()) {
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
const intervals = Math.ceil(elapsedDays / intake.every);
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
}
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
nextDate = candidate;
}
}
return nextDate ? toDateOnlyString(nextDate) : null;
}
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 * MS_PER_DAY));
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,
};
});
}
+151
View File
@@ -0,0 +1,151 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
const MS_PER_DAY = 86_400_000;
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 period = Math.max(1, intake.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = intakeStart;
}
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 occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
consumed += occurrences * usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
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));
}
+237 -115
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;
@@ -106,8 +166,18 @@ 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(
med.name,
medDisplayName,
intakes,
medicationTakenBy,
med.pillWeightMg,
@@ -136,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,
@@ -145,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;
@@ -165,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
@@ -309,7 +412,7 @@ ${getFooterPlain(language)}`;
},
});
await transporter.sendMail({
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `💊 ${subject}`,
@@ -317,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 };
@@ -329,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;
@@ -370,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 }));
@@ -401,44 +534,31 @@ async function checkAndSendIntakeRemindersForUser(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) {
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
);
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
const medDisplayName = med.name || med.genericName || "";
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
const todaysIntakesForThisDefinition = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
med.name,
medDisplayName,
[intake],
REMINDER_MINUTES_BEFORE,
medicationTakenBy,
@@ -449,9 +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(
@@ -464,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(
med.name,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
);
const missedIntakes = allTodaysIntakes.filter(
const missedIntakes = todaysIntakesForThisDefinition.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
@@ -499,13 +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;
@@ -533,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] = {
@@ -544,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
@@ -565,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()
@@ -611,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)
@@ -667,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"})`
);
}
}
@@ -733,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})`
);
}
}
@@ -803,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})`
);
}
}
+254 -142
View File
@@ -8,6 +8,12 @@ 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,
@@ -19,6 +25,7 @@ import {
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseReminderState,
@@ -37,6 +44,36 @@ function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
response?: unknown;
};
function normalizeRecipients(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
.map((entry) => entry.trim())
.filter(Boolean);
}
function getDeliveryError(info: MailDeliveryInfo): string | null {
const accepted = normalizeRecipients(info.accepted);
const rejected = normalizeRecipients(info.rejected);
if (accepted.length > 0) return null;
if (rejected.length > 0) {
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
}
if (typeof info.response === "string" && info.response.trim()) {
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
}
return "SMTP did not confirm accepted recipients.";
}
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
@@ -179,6 +216,12 @@ type LowStockItem = {
isCritical: boolean;
};
function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
const lowDays = Math.max(1, Math.floor(baselineDays));
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
return { lowDays, criticalDays };
}
type PrescriptionReminderItem = {
name: string;
remainingRefills: number;
@@ -231,17 +274,25 @@ async function getMedicationsNeedingReminder(
const msPerDay = 86_400_000;
for (const row of rows) {
const packageType = normalizePackageType(row.packageType);
// Tube stock reminders are intentionally disabled:
// topical usage in grams cannot be mapped reliably to schedule events.
if (isTubePackageType(packageType)) continue;
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const blisters: Blister[] = intakes.map((i) => ({
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
every: i.every,
start: i.start,
}));
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>();
@@ -348,8 +399,13 @@ async function getMedicationsNeedingReminder(
if (daysLeft === null) continue;
const isCritical = daysLeft <= reminderDaysBefore;
const isLow = daysLeft < lowStockDays;
const isLiquid = isLiquidContainerPackageType(packageType);
const { lowDays, criticalDays } = isLiquid
? getLiquidReminderThresholds(reminderDaysBefore)
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
const isCritical = daysLeft <= criticalDays;
const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays;
if (isCritical || isLow) {
lowStock.push({
@@ -551,7 +607,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
},
});
await transporter.sendMail({
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject,
@@ -559,6 +615,11 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
@@ -567,6 +628,15 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
}
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
// Track stock-scheduler daily execution separately from intake updates.
// This prevents intake reminders from suppressing stock catch-up after restarts.
const state = loadReminderState();
const today = getTodayInTimezone();
saveReminderState({
...state,
lastStockSchedulerCheckDate: today,
});
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
@@ -617,12 +687,10 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
if (!stockSendLock) {
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send");
} else {
try {
logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
);
logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
@@ -636,7 +704,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}`);
}
}
@@ -678,7 +746,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}`);
}
}
@@ -689,6 +757,7 @@ async function checkAndSendReminderForUser(
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "stock",
@@ -709,90 +778,112 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
if (!prescriptionSendLock) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
);
logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send");
} else {
try {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
// This blocks duplicate sends when two reminder checks overlap in time.
const lockedState = loadReminderState();
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
if (!shouldSend) {
logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping");
}
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
const lines = allPrescriptionLow.map((m) => {
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
if (m.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
const preMarkedNotified =
!shouldSend || alreadyNotified
? lockedState.notifiedMedications
: [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])];
if (shouldSend && !alreadyNotified) {
saveReminderState({
lastAutoEmailSent: lockedState.lastAutoEmailSent,
lastAutoEmailDate: lockedState.lastAutoEmailDate,
lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate,
notifiedMedications: preMarkedNotified,
nextScheduledCheck: lockedState.nextScheduledCheck,
lastNotificationType: lockedState.lastNotificationType,
lastNotificationChannel: lockedState.lastNotificationChannel,
});
}
if (shouldSend) {
logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`);
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
const lines = allPrescriptionLow.map((m) => {
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
if (m.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
name: m.name,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: m.name,
refills: m.remainingRefills,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: m.name,
refills: m.remainingRefills,
expirySuffix,
})}`;
});
});
let emailSuccess = false;
let shoutrrrSuccess = false;
let emailSuccess = false;
let shoutrrrSuccess = false;
if (prescriptionEmailEnabled) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (prescriptionEmailEnabled) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass ?? "" },
});
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass ?? "" },
});
const subject =
allPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const subject =
allPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const bodyText =
emptyRx.length > 0
? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const bodyText =
emptyRx.length > 0
? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = allPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.lowThreshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
const tableRows = allPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.lowThreshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
</tr>`;
})
.join("");
})
.join("");
const html = `
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
@@ -832,75 +923,95 @@ async function checkAndSendReminderForUser(
</div>
</div>
`;
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
await transporter.sendMail({
from: smtpFrom,
to: settings.notificationEmail!,
subject,
text,
html,
});
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: settings.notificationEmail!,
subject,
text,
html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
}
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`);
}
}
}
}
if (prescriptionPushEnabled) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
if (prescriptionPushEnabled) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
);
}
}
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
}
}
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "prescription",
lastNotificationChannel: channel,
});
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
} else if (!alreadyNotified) {
// Roll back pre-mark when both channels failed so retries remain possible.
const currentState = loadReminderState();
saveReminderState({
lastAutoEmailSent: currentState.lastAutoEmailSent,
lastAutoEmailDate: currentState.lastAutoEmailDate,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: currentState.notifiedMedications.filter(
(key) => key !== userPrescriptionNotifiedKey
),
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: currentState.lastNotificationType,
lastNotificationChannel: currentState.lastNotificationChannel,
});
}
}
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "prescription",
lastNotificationChannel: channel,
});
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
}
} finally {
releaseReminderSendLock(prescriptionSendLock);
}
@@ -947,9 +1058,10 @@ export function startReminderScheduler(logger: ServiceLogger): void {
const today = getTodayInTimezone();
const currentHour = getCurrentHourInTimezone();
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
logger.info("[Reminder] Missed today's check, running now...");
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run one catch-up.
// This is intentionally a single current-state snapshot (no replay of missed days).
if (currentHour >= REMINDER_HOUR && state.lastStockSchedulerCheckDate !== today) {
logger.info("[Reminder] Missed today's check, running one catch-up snapshot (no historical replay)...");
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
}
+6 -5
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(() => {
@@ -28,7 +29,7 @@ vi.mock("../db/client.js", () => ({
vi.mock("../plugins/env.js", () => ({
env: {
AUTH_ENABLED: true,
LOCAL_AUTH_ENABLED: true,
FORM_LOGIN_ENABLED: true,
REGISTRATION_ENABLED: true,
OIDC_ENABLED: false,
NODE_ENV: "test",
@@ -97,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" });
@@ -144,7 +145,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
const data = response.json();
expect(data.authEnabled).toBe(true);
expect(data.registrationEnabled).toBe(true);
expect(data.localAuthEnabled).toBe(true);
expect(data.formLoginEnabled).toBe(true);
});
});
@@ -228,7 +229,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should reject short username", async () => {
@@ -242,7 +243,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should register with trimmed username when input has whitespace", async () => {
@@ -0,0 +1,486 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import 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");
});
});
+3 -3
View File
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
const getDbPaths = vi.fn().mockReturnValue({
dataDir: "/tmp/medassist-data",
dbPath: "/tmp/medassist-data/medassist.db",
url: "file:/tmp/medassist-data/medassist.db",
dbPath: "/tmp/medassist-data/medassist-ng.db",
url: "file:/tmp/medassist-data/medassist-ng.db",
});
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
@@ -102,7 +102,7 @@ describe("db/client bootstrap", () => {
await mod.migrationsReady;
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
+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" }),
]);
});
});
});
+343 -7
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(() => {
@@ -82,7 +83,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -101,6 +107,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
@@ -138,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,
@@ -240,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" });
@@ -338,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 () => {
@@ -496,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");
});
});
// ---------------------------------------------------------------------------
@@ -827,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 () => {
@@ -868,7 +995,6 @@ describe("E2E Tests with Real Routes", () => {
const json = response.json();
expect(json.status).toBe("ok");
expect(typeof json.smtpConfigured).toBe("boolean");
expect(typeof json.shoutrrrConfigured).toBe("boolean");
});
});
@@ -1289,7 +1415,6 @@ describe("E2E Tests with Real Routes", () => {
const json = response.json();
expect(json.status).toBe("ok");
expect(typeof json.smtpConfigured).toBe("boolean");
expect(typeof json.shoutrrrConfigured).toBe("boolean");
});
});
@@ -1924,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",
@@ -2297,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,
},
});
@@ -2337,7 +2525,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -2501,10 +2688,10 @@ describe("E2E Tests with Real Routes", () => {
});
// ---------------------------------------------------------------------------
// Package Type (bottle vs blister) Tests
// Package Type (blister, bottle, tube, liquid_container) Tests
// ---------------------------------------------------------------------------
describe("Package type handling (bottle vs blister)", () => {
describe("Package type handling (blister, bottle, tube, liquid_container)", () => {
const bottleMedication = {
name: "Vitamin D Drops",
packageType: "bottle",
@@ -2525,6 +2712,33 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const liquidContainerMedication = {
name: "Cough Syrup",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 180,
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",
@@ -2569,6 +2783,49 @@ describe("E2E Tests with Real Routes", () => {
expect(data.medications[0].totalPills).toBe(120);
});
it("should create and return liquid_container type medication", async () => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload: liquidContainerMedication,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.packageType).toBe("liquid_container");
expect(data.medicationForm).toBe("liquid");
expect(data.doseUnit).toBe("ml");
expect(data.looseTablets).toBe(180);
});
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: { ...liquidContainerMedication, takenBy: ["Daniel"] },
});
const shareResponse = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareResponse.statusCode).toBe(200);
const { token } = shareResponse.json();
const scheduleResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleResponse.statusCode).toBe(200);
const data = scheduleResponse.json();
expect(data.medications).toHaveLength(1);
expect(data.medications[0].packageType).toBe("liquid_container");
// Liquid container follows container semantics (stock from looseTablets only).
expect(data.medications[0].totalPills).toBe(180);
});
it("should calculate correct totalPills for shared blister medication", async () => {
await app.inject({
method: "POST",
@@ -2638,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",
@@ -2744,5 +3067,18 @@ describe("E2E Tests with Real Routes", () => {
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].packageType).toBe("blister");
});
it("should reject liquid medication form with non-liquid package type", async () => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packageType: "bottle",
},
});
expect(response.statusCode).toBe(400);
});
});
});
@@ -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();
});
});
+28 -1
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(() => {
@@ -76,7 +77,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -95,6 +101,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
@@ -132,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,
@@ -196,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, {
@@ -246,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" }],
},
});
@@ -299,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" }],
},
});
@@ -337,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" },
@@ -398,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" }],
},
});
@@ -535,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" }],
},
});
@@ -589,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" }],
},
});
+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",
+50 -11
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 {
@@ -93,7 +94,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -112,6 +118,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
@@ -149,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,
@@ -207,7 +216,7 @@ describe("Planner Routes", () => {
args: [],
});
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(plannerRoutes);
await app.ready();
@@ -284,7 +293,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -330,7 +339,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -434,7 +443,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -522,7 +531,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
@@ -697,7 +706,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -727,7 +736,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -763,7 +772,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -849,7 +858,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
@@ -982,7 +991,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
@@ -1036,6 +1045,36 @@ describe("Planner Routes", () => {
expect(title).not.toContain("Low");
expect(message).toContain("Running critically low");
});
it("should return 400 when only tube medications are in active meds", async () => {
// Insert a tube medication (should be excluded from reminders)
await testClient.execute({
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type)
VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`,
args: [],
});
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }],
},
});
// Expects 400 because tube medications are excluded from stock reminders
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "No active medications to notify" });
expect(mockSendMail).not.toHaveBeenCalled();
});
});
describe("POST /reminder/send-prescription", () => {
@@ -1082,7 +1121,7 @@ describe("Planner Routes", () => {
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
+301 -6
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 () => {
@@ -207,7 +297,12 @@ describe("Real route coverage: settings/export/report", () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_TOKEN = "secret";
nodemailerSendMail.mockResolvedValue(undefined);
nodemailerSendMail.mockResolvedValue({
accepted: ["person@example.com"],
rejected: [],
response: "250 2.0.0 OK",
messageId: "test-message-id",
});
const response = await app.inject({
method: "POST",
@@ -219,6 +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",
@@ -228,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);
@@ -261,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();
+11
View File
@@ -21,6 +21,7 @@ import {
parseIntakeReminderState,
parseReminderState,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
@@ -151,6 +152,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", () => {
@@ -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,
});
}
}
-44
View File
@@ -142,14 +142,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 +150,6 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -431,41 +422,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();
});
@@ -348,3 +349,46 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
});
});
describe("getLiquidReminderThresholds", () => {
// Import the function for testing (test-only export)
// The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number }
// Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2)
it("derives critical as ceil(baseline / 2) for typical baseline", () => {
// For baseline=7 days: low=7, critical=ceil(7/2)=4
const baseline = 7;
// Manually apply the formula to verify
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(7);
expect(expectedCritical).toBe(4);
});
it("derives critical correctly at boundary: baseline=1", () => {
// For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...))
const baseline = 1;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(1);
expect(expectedCritical).toBe(1);
});
it("derives thresholds correctly for even baseline (baseline=14)", () => {
// For baseline=14: low=14, critical=ceil(14/2)=7
const baseline = 14;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(14);
expect(expectedCritical).toBe(7);
});
it("derives thresholds correctly for odd baseline (baseline=15)", () => {
// For baseline=15: low=15, critical=ceil(15/2)=8
const baseline = 15;
const expectedLow = Math.max(1, Math.floor(baseline));
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
expect(expectedLow).toBe(15);
expect(expectedCritical).toBe(8);
});
});
+8 -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,8 @@ 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],
};
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, unlinkSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import sharp from "sharp";
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
export function getThumbFilename(imageFilename: string): string {
const ext = extname(imageFilename);
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
return `${base}-thumb.webp`;
}
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
const fullPath = resolve(imagesDir, imageFilename);
if (existsSync(fullPath)) unlinkSync(fullPath);
const thumbFilename = getThumbFilename(imageFilename);
if (thumbFilename !== imageFilename) {
const thumbPath = resolve(imagesDir, thumbFilename);
if (existsSync(thumbPath)) unlinkSync(thumbPath);
}
}
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalSize += buffer.length;
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
throw new Error("IMAGE_TOO_LARGE");
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
}
export async function writeOptimizedImageSet(
imagesDir: string,
filePrefix: string,
uploadBuffer: Buffer,
options?: {
maxEdgePx?: number;
thumbSizePx?: number;
fullQuality?: number;
thumbQuality?: number;
}
): Promise<{ filename: string; thumbFilename: string }> {
const maxEdgePx = options?.maxEdgePx ?? 1600;
const thumbSizePx = options?.thumbSizePx ?? 96;
const fullQuality = options?.fullQuality ?? 82;
const thumbQuality = options?.thumbQuality ?? 76;
const filename = `${filePrefix}-${Date.now()}.webp`;
const thumbFilename = getThumbFilename(filename);
const filepath = resolve(imagesDir, filename);
const thumbFilepath = resolve(imagesDir, thumbFilename);
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
.rotate()
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
.webp({ quality: fullQuality })
.toBuffer();
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
.rotate()
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
.webp({ quality: thumbQuality })
.toBuffer();
await writeFile(filepath, optimizedBuffer);
await writeFile(thumbFilepath, thumbBuffer);
return { filename, thumbFilename };
}
+8 -4
View File
@@ -23,18 +23,22 @@ function shouldLog(level: string): boolean {
return LOG_LEVELS[level] >= getLevel();
}
function ts(): string {
return new Date().toISOString();
}
export const log = {
debug(msg: string): void {
if (shouldLog("debug")) console.log(msg);
if (shouldLog("debug")) console.log(`[${ts()}] [DEBUG] ${msg}`);
},
info(msg: string): void {
if (shouldLog("info")) console.log(msg);
if (shouldLog("info")) console.log(`[${ts()}] [INFO] ${msg}`);
},
warn(msg: string): void {
if (shouldLog("warn")) console.warn(msg);
if (shouldLog("warn")) console.warn(`[${ts()}] [WARN] ${msg}`);
},
error(msg: string): void {
if (shouldLog("error")) console.error(msg);
if (shouldLog("error")) console.error(`[${ts()}] [ERROR] ${msg}`);
},
};
@@ -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";
}
+41 -1
View File
@@ -4,6 +4,7 @@
*/
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
@@ -13,10 +14,39 @@ export type Intake = {
usage: number;
every: number;
start: string;
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
};
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "tbsp";
/**
* Normalize intake usage for stock math.
*
* Stock semantics:
* - tube: no automatic depletion (unknown per-application amount)
* - liquid_container/liquid forms: convert tsp/tbsp to ml
* - others: usage as-is
*/
export function normalizeIntakeUsageForStock(
intake: Pick<Intake, "usage" | "intakeUnit">,
medicationForm?: string | null,
packageType?: string | null
): number {
const usage = Number(intake.usage);
if (!Number.isFinite(usage) || usage <= 0) return 0;
if (isTubePackageType(packageType)) return 0;
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;
if (intake.intakeUnit === "tbsp") return usage * 15;
return usage;
}
// =============================================================================
// Timezone utilities
// =============================================================================
@@ -122,7 +152,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
/** Calculate milliseconds until next check at the given reminder hour */
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
const next = getNextScheduledTime(reminderHour, tz);
return next.getTime() - Date.now();
const msUntilNext = next.getTime() - Date.now();
if (msUntilNext <= 0) {
return msUntilNext + 24 * 60 * 60 * 1000;
}
return msUntilNext;
}
// =============================================================================
@@ -195,6 +229,7 @@ export function parseIntakesJson(
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,
@@ -212,6 +247,7 @@ export function parseIntakesJson(
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
@@ -256,6 +292,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);
}
@@ -483,6 +520,7 @@ export function getUpcomingIntakes(
export type ReminderState = {
lastAutoEmailSent: string | null;
lastAutoEmailDate: string | null;
lastStockSchedulerCheckDate: string | null;
notifiedMedications: string[];
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | "prescription" | null;
@@ -505,6 +543,7 @@ export function createDefaultReminderState(): ReminderState {
return {
lastAutoEmailSent: null,
lastAutoEmailDate: null,
lastStockSchedulerCheckDate: null,
notifiedMedications: [],
nextScheduledCheck: null,
lastNotificationType: null,
@@ -524,6 +563,7 @@ export function parseReminderState(json: string): ReminderState {
return {
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
notifiedMedications: saved.notifiedMedications ?? [],
nextScheduledCheck: saved.nextScheduledCheck ?? null,
lastNotificationType: saved.lastNotificationType ?? null,
+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. |
-80
View File
@@ -1,80 +0,0 @@
# GitHub Project Setup
This repository includes a GitHub Actions workflow that automatically adds new issues to a GitHub Project for tracking feature requests and bugs.
## Setup Steps
### 1. Create a GitHub Project
1. Go to your GitHub profile → **Projects** → **New project**
2. Choose the **Board** template (recommended for feature tracking)
3. Name it e.g. **MedAssist-ng Roadmap**
4. Configure the default columns:
- **Triage** New issues land here
- **Backlog** Accepted but not yet started
- **In Progress** Currently being worked on
- **Done** Completed
### 2. Create a Personal Access Token (PAT)
The workflow needs a token with project permissions. The built-in `GITHUB_TOKEN` does not support GitHub Projects.
1. Go to **Settings****Developer settings****Personal access tokens** → **Fine-grained tokens**
2. Click **Generate new token**
3. Set:
- **Token name**: `add-to-project`
- **Expiration**: Choose an appropriate duration
- **Repository access**: Select **Only select repositories**`DanielVolz/medassist-ng`
- **Permissions**:
- Repository permissions: **Issues** → Read
- Organization permissions (if applicable): **Projects** → Read and write
- For **user-owned projects**, you need a **classic** token with the `project` scope instead
4. Copy the generated token
### 3. Add Repository Secrets and Variables
1. Go to the repository → **Settings****Secrets and variables** → **Actions**
2. Add a **secret**:
- Name: `ADD_TO_PROJECT_PAT`
- Value: The PAT from step 2
3. Add a **variable** (under the **Variables** tab):
- Name: `PROJECT_URL`
- Value: The full URL of your GitHub Project (e.g. `https://github.com/users/DanielVolz/projects/1`)
### 4. Verify
1. Create a test issue using the **✨ Feature Request** template
2. Check the **Actions** tab to see the workflow run
3. Verify the issue appears in your GitHub Project under **Triage**
## How It Works
The workflow (`.github/workflows/add-to-project.yml`) triggers when:
- A new issue is **opened**
- A label is **added** to an existing issue
Issues with any of these labels are automatically added to the project:
- `enhancement` Feature requests
- `bug` Bug reports
- `triage` New issues needing review
Both the feature request and bug report issue templates automatically apply the `triage` label, so all new issues from templates are captured.
## Customization
### Adding more labels
Edit `.github/workflows/add-to-project.yml` and add labels to the `labeled` field:
```yaml
labeled: enhancement, bug, triage, documentation
```
### Restricting to feature requests only
Change the `labeled` field to only include `enhancement`:
```yaml
labeled: enhancement
label-operator: OR
```
+74 -30
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { expect, test as setup } from "@playwright/test";
import { TEST_USER } from "./fixtures";
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
* 4. Log in via the UI.
*/
setup("authenticate", async ({ page }) => {
await applyVideoSafetyMode(page);
// Create .auth directory if it doesn't exist
const authDir = path.dirname(authFile);
if (!fs.existsSync(authDir)) {
@@ -68,40 +70,82 @@ setup("authenticate", async ({ page }) => {
// Wait for auth container
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// ---- 3. Ensure the test user exists ----
// ---- 3. Query auth state to determine login method ----
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
// ---- 4. Log in via UI ----
const usernameField = page.locator("#username");
const passwordField = page.locator("#password");
// Make sure we're on the login form (not register)
const isOnRegister = await page
.locator(".auth-subtitle")
.filter({ hasText: /Create Account/i })
.isVisible()
.catch(() => false);
if (isOnRegister) {
const switchBtn = page.locator("button.auth-link-btn");
if (await switchBtn.isVisible().catch(() => false)) {
await switchBtn.click();
await page.waitForTimeout(500);
let formLoginEnabled = true;
let oidcEnabled = false;
try {
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
if (stateRes.ok()) {
const state = await stateRes.json();
formLoginEnabled = state.formLoginEnabled !== false;
oidcEnabled = state.oidcEnabled === true;
}
} catch {
// Fallback: assume form login is available
}
await usernameField.clear();
await usernameField.fill(TEST_USER.username);
await passwordField.clear();
await passwordField.fill(TEST_USER.password);
// ---- 4. Ensure the test user exists (only if form login is available) ----
if (formLoginEnabled) {
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
}
// Click the submit button (not the SSO button)
await page.locator('button.auth-submit[type="submit"]').click();
// ---- 5. Log in via the appropriate method ----
if (formLoginEnabled) {
// Form login path: username/password
const usernameField = page.locator("#username");
const passwordField = page.locator("#password");
// Make sure we're on the login form (not register)
const isOnRegister = await page
.locator(".auth-subtitle")
.filter({ hasText: /Create Account/i })
.isVisible()
.catch(() => false);
if (isOnRegister) {
const switchBtn = page.locator("button.auth-link-btn");
if (await switchBtn.isVisible().catch(() => false)) {
await switchBtn.click();
await page.waitForTimeout(500);
}
}
await usernameField.clear();
await usernameField.fill(TEST_USER.username);
await passwordField.clear();
await passwordField.fill(TEST_USER.password);
// Click the submit button (not the SSO button)
await page.locator('button.auth-submit[type="submit"]').click();
} else if (oidcEnabled) {
// SSO-only path: click the SSO button and let the OIDC provider handle login.
// This requires the OIDC provider to be configured with test credentials
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
// or to auto-approve the test user.
await page.locator("button.sso-btn").click();
// Wait for OIDC redirect and callback — the provider may show its own login form
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
if (oidcUsername && oidcPassword) {
// Fill OIDC provider login form (generic selectors — override if needed)
await page.waitForURL(/.*/, { timeout: 15000 });
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
await oidcUserField.fill(oidcUsername);
await oidcPassField.fill(oidcPassword);
await page.locator('button[type="submit"]').first().click();
}
}
} else {
throw new Error("No login method available: form login and OIDC are both disabled");
}
// Wait for successful auth — app header should appear
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
+61 -5
View File
@@ -1,16 +1,28 @@
import { expect, type Page, test } from "@playwright/test";
async function isAuthEnabled(page: Page): Promise<boolean> {
interface AuthStateResponse {
authEnabled: boolean;
formLoginEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
registrationEnabled: boolean;
}
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
try {
const response = await page.request.get("/api/auth/state");
if (!response.ok()) return true;
const state = await response.json();
return state?.authEnabled !== false;
if (!response.ok()) return null;
return (await response.json()) as AuthStateResponse;
} catch {
return true;
return null;
}
}
async function isAuthEnabled(page: Page): Promise<boolean> {
const state = await getAuthState(page);
return state?.authEnabled !== false;
}
/**
* Authentication E2E Tests
*
@@ -110,4 +122,48 @@ test.describe("Authentication", () => {
const newText = await subtitle.textContent();
expect(newText).not.toBe(initialText);
});
test("should show SSO button when OIDC is enabled", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
const ssoButton = page.locator("button.sso-btn");
await expect(ssoButton).toBeVisible();
await expect(ssoButton).toContainText(state.oidcProviderName || "SSO");
});
test("should hide form login when formLoginEnabled is false", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// Username/password fields should not be visible
await expect(page.locator("#username")).not.toBeVisible();
await expect(page.locator("#password")).not.toBeVisible();
// SSO button should be the only login method
await expect(page.locator("button.sso-btn")).toBeVisible();
});
test("should show both login methods when OIDC and form login are enabled", async ({ page }) => {
const state = await getAuthState(page);
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
test.skip(!state?.oidcEnabled, "OIDC is not enabled");
test.skip(!state?.formLoginEnabled, "Form login is not enabled");
await page.goto("/");
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// Both login methods visible
await expect(page.locator("#username")).toBeVisible();
await expect(page.locator("#password")).toBeVisible();
await expect(page.locator("button.sso-btn")).toBeVisible();
});
});
+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();
+167 -27
View File
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
}
}
/**
* Reduce visual flashing in recorded videos by forcing a dark first paint and
* disabling most animations/transitions in test mode.
*/
export async function applyVideoSafetyMode(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
await page.addInitScript(() => {
const style = document.createElement("style");
style.id = "pw-video-safety-style";
style.textContent = `
html, body {
background: #111111 !important;
color-scheme: dark !important;
}
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
`;
document.documentElement.appendChild(style);
});
}
/**
* Extended test fixture that automatically mocks /auth/me on every page
* using user data from the JWT in the stored auth file.
@@ -72,6 +95,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
*/
export const test = base.extend<object>({
page: async ({ page }, use) => {
await applyVideoSafetyMode(page);
await setupAuthMeMock(page);
await use(page);
},
@@ -79,25 +103,43 @@ export const test = base.extend<object>({
/**
* Wait for the app to be fully loaded past any loading/initializing screens.
* Includes a single retry with page reload to handle transient auth failures
* (e.g. brief race between context setup and cookie application).
* Retries up to 2 times with page reload to handle transient auth or
* rate-limit failures.
*/
export async function waitForAppReady(page: Page): Promise<void> {
const hero = page.locator("header.hero");
try {
await expect(hero).toBeVisible({ timeout: 15000 });
} catch {
// Auth might have failed transiently — reload and retry once
await page.reload();
await expect(hero).toBeVisible({ timeout: 15000 });
for (let attempt = 0; attempt < 3; attempt++) {
try {
await expect(hero).toBeVisible({ timeout: 15000 });
return;
} catch {
if (attempt === 2) throw new Error("App failed to become ready after 3 attempts");
// Check for rate-limit error displayed in UI
const rateLimited = await page
.locator("text=rate limit, text=429, text=too many")
.first()
.isVisible()
.catch(() => false);
if (rateLimited) {
// Wait longer before retrying if rate-limited
await page.waitForTimeout(5000);
}
await page.reload();
}
}
}
/**
* Navigate to a page and wait for it to be ready.
* Handles transient navigation failures with a single retry.
*/
export async function navigateTo(page: Page, path: string): Promise<void> {
await page.goto(path);
const response = await page.goto(path);
if (response && response.status() === 429) {
// Rate-limited — wait and retry once
await page.waitForTimeout(5000);
await page.goto(path);
}
await waitForAppReady(page);
await page.waitForLoadState("networkidle");
}
@@ -135,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;
@@ -144,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;
@@ -172,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;
@@ -187,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,
@@ -219,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)));
@@ -235,13 +342,25 @@ export async function createMedicationViaAPI(data: {
/**
* Delete a medication via the backend API.
* Includes retry for rate-limited responses.
*/
export async function deleteMedicationViaAPI(id: number): Promise<void> {
const token = getAuthCookie();
await fetch(`${API_BASE}/api/medications/${id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
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;
}
return;
}
}
/**
@@ -249,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;
@@ -266,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;
@@ -282,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",
@@ -292,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");
+17 -8
View File
@@ -233,7 +233,7 @@ test.describe("Medication Editing", () => {
// Change intake from 1 pill daily to 2 pills every 7 days
const intakeRow = page.locator(".blister-row").first();
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i);
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill("2");
@@ -247,7 +247,7 @@ test.describe("Medication Editing", () => {
// Verify the changes persisted
await clickEditMed(page, "Edit Intake Med");
const savedRow = page.locator(".blister-row").first();
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
});
@@ -279,7 +279,7 @@ test.describe("Medication Editing", () => {
// Fill the new intake row
const secondRow = page.locator(".blister-row").nth(1);
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
await saveEditAndVerify(page, "Add Intake Med");
@@ -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 }) => {
+193
View File
@@ -0,0 +1,193 @@
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
/**
* Medication Lifecycle Integration Tests
*
* End-to-end workflows that verify changes propagate across pages:
* create verify on medications check in planner check in schedule edit delete
*/
test.describe("Medication lifecycle", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
const MED_NAME = "Lifecycle TestMed";
const MED_EDITED = "Lifecycle Edited";
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("create medication via API and verify it appears on all pages", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Step 1: Create medication
const created = await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
expect(created.id).toBeTruthy();
// Step 2: Verify on medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
// Step 3: Verify in planner
await navigateTo(page, "/planner");
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
await expect(page.locator(".table")).toBeVisible({ timeout: 15000 });
await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible();
// Step 4: Verify in schedule
await navigateTo(page, "/schedule");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
});
test("edit medication name via UI and verify update propagates", async ({ page }) => {
await deleteAllMedicationsViaAPI();
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create a fresh medication for this test
await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
// Navigate to medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
// Open edit view from medication row actions
const medRow = page.locator(".med-row").filter({ hasText: MED_NAME });
await expect(medRow.first()).toBeVisible({ timeout: 10000 });
await medRow.first().locator("button.info").click();
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
timeout: 5000,
});
// Update the name
const form = page.locator("form.form-grid:visible").first();
const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first();
await nameInput.fill(MED_EDITED);
// Save
const submitButton = form.locator('button[type="submit"]').first();
await expect(submitButton).toBeEnabled({ timeout: 5000 });
await submitButton.click();
// Wait for modal to close or save to complete
await page.waitForLoadState("networkidle");
// Verify edited name appears on medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 });
// Old name should no longer appear
await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 });
});
test("delete medication via API and verify it disappears from all pages", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create and then delete
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 5,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
// Verify it exists first
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 });
// Delete via API
await deleteAllMedicationsViaAPI();
// Verify gone from medications page
await navigateTo(page, "/medications");
await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 });
// Verify planner shows no results for this med
await navigateTo(page, "/planner");
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
// Either no table or table without the medication name
const table = page.locator(".table");
const tableVisible = await table.isVisible().catch(() => false);
if (tableVisible) {
await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 });
}
});
test("medication with multiple intakes shows all schedule entries", async ({ page }) => {
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
const todayEvening = (() => {
const d = new Date();
d.setHours(20, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: "MultiIntake Med",
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [
{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false },
{ usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false },
],
});
// Verify schedule shows this medication
await navigateTo(page, "/schedule");
await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 });
// The medication should appear at least twice (morning + evening)
const medEntries = page.getByText("MultiIntake Med");
expect(await medEntries.count()).toBeGreaterThanOrEqual(2);
});
});
+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 }) => {
+98
View File
@@ -0,0 +1,98 @@
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
/**
* Performance Tests
*
* Verify the schedule timeline and planner render within acceptable
* time limits when many medications exist.
*/
test.describe("Performance with many medications", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 120000 });
const MED_COUNT = 20;
const MED_PREFIX = "PerfTest Med";
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
const todayMorning = (() => {
const d = new Date();
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
// Create medications sequentially (API rate limits prevent parallel)
for (let i = 1; i <= MED_COUNT; i++) {
await createMedicationViaAPI({
name: `${MED_PREFIX} ${i}`,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
});
}
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => {
const start = Date.now();
await navigateTo(page, "/schedule");
// Wait for schedule entries to render
const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card");
await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 });
const renderTime = Date.now() - start;
// Verify all medications appear
for (let i = 1; i <= MED_COUNT; i++) {
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
}
// Goal: render under 10 seconds
expect(renderTime).toBeLessThan(10000);
});
test("medications page renders within 10 seconds with 20 medications", async ({ page }) => {
const start = Date.now();
await navigateTo(page, "/medications");
// Wait for medication cards to render
const medEntries = page.locator(".medication-card, .card, .table-row");
await expect(medEntries.first()).toBeVisible({ timeout: 15000 });
const renderTime = Date.now() - start;
// Verify count — all 20 should be visible
for (let i = 1; i <= MED_COUNT; i++) {
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
}
expect(renderTime).toBeLessThan(10000);
});
test("planner calculates within 15 seconds with 20 medications", async ({ page }) => {
await navigateTo(page, "/planner");
const start = Date.now();
await page.waitForLoadState("networkidle");
await page.locator('form.planner button[type="submit"]').click();
await expect(page.locator(".table")).toBeVisible({ timeout: 20000 });
const calcTime = Date.now() - start;
// All medications should appear in the results
const rows = page.locator(".table .table-row");
expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT);
// Goal: calculate and render under 15 seconds
expect(calcTime).toBeLessThan(15000);
});
});
+50 -9
View File
@@ -106,7 +106,7 @@ test.describe("Planner with medications", () => {
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
});
test("should show usage data in results rows", async ({ page }) => {
test("should show correct usage values in results rows", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(page);
@@ -116,10 +116,15 @@ test.describe("Planner with medications", () => {
const rows = resultsTable.locator(".table-row");
expect(await rows.count()).toBeGreaterThanOrEqual(2);
const firstRowText = await rows.first().textContent();
expect(firstRowText).toBeTruthy();
// Check for "pill" (matches both "pill" and "pills")
expect(firstRowText!.toLowerCase()).toContain("pill");
// Each medication has usage=1, every=1 → plannerUsage should reflect the period
// Verify the usage column contains a numeric <strong> value and "pill(s)"
for (const row of await rows.all()) {
const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column
const usageStrong = usageCell.locator("strong");
await expect(usageStrong).toBeVisible();
const usageText = await usageStrong.textContent();
expect(Number(usageText)).toBeGreaterThan(0);
}
});
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
@@ -139,9 +144,16 @@ test.describe("Planner with medications", () => {
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// Low-stock med (3 pills) should have a danger chip over 90 days
// Low-stock med (3 pills, usage 1/day, 90 days) should have danger status
const dangerChips = resultsTable.locator(".status-chip.danger");
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
// Find the low-stock med row and verify its usage value ~90 pills
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
await expect(lowStockRow).toBeVisible();
const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent();
expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed
expect(Number(lowUsage)).toBeLessThanOrEqual(95);
});
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
@@ -161,9 +173,16 @@ test.describe("Planner with medications", () => {
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// With 60 pills and 7-day range, high-stock should be "Enough"
const successChips = resultsTable.locator(".status-chip.success");
expect(await successChips.count()).toBeGreaterThanOrEqual(1);
// High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough"
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
await expect(highStockRow).toBeVisible();
const highStatus = highStockRow.locator(".status-chip.success");
await expect(highStatus).toBeVisible();
// Verify usage is ~7 pills for the 7-day range
const highUsage = await highStockRow.locator("[data-label] strong").first().textContent();
expect(Number(highUsage)).toBeGreaterThanOrEqual(5);
expect(Number(highUsage)).toBeLessThanOrEqual(10);
});
test("should show table header with correct columns", async ({ page }) => {
@@ -180,6 +199,28 @@ test.describe("Planner with medications", () => {
await expect(tableHead.getByText(/Status/i)).toBeVisible();
});
test("should display available stock for each medication", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(page);
const resultsTable = page.locator(".table");
await expect(resultsTable).toBeVisible({ timeout: 10000 });
// High-stock med should show a blister + loose-pill stock breakdown
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
await expect(highStockRow).toBeVisible();
const highStockText = await highStockRow.textContent();
expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i);
expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i);
// Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
await expect(lowStockRow).toBeVisible();
const lowStockText = await lowStockRow.textContent();
// Should show 3 loose pills
expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i);
});
test("should reset form and clear results", async ({ page }) => {
await navigateTo(page, "/planner");
await calculatePlanner(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);
});
});
+7 -4
View File
@@ -150,17 +150,20 @@ test.describe("Schedule Timeline", () => {
test("should show overview table with stock status", async ({ page }) => {
await navigateTo(page, "/dashboard");
// Overview table has class .table.table-7
const overviewTable = page.locator(".table.table-7");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible();
await expect(overviewTable.locator(".table-head")).toBeVisible();
});
test("should display share button in schedules section", async ({ page }) => {
await navigateTo(page, "/dashboard");
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
const shareBtn = page.locator("button.share-btn");
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");
+25 -15
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");
@@ -136,7 +140,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 +148,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 +182,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.getByText(MED_ALICE);
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 +231,39 @@ 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.getByText(MED_ALICE)).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.getByText(MED_ALICE)).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.getByText(MED_BOB)).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.getByText(MED_BOB)).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 +275,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")
+264
View File
@@ -0,0 +1,264 @@
import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
expect,
navigateTo,
type TestMedication,
test,
} from "./fixtures";
/**
* Tooltip Visibility Regression Tests
*
* Ensures that tooltip pseudo-elements on MedDetail footer icon buttons
* are not clipped by ancestor overflow or hidden behind modal overlays.
* This is a regression guard tooltips have repeatedly broken due to
* CSS overflow/z-index changes on modal containers.
*/
test.describe("MedDetail footer tooltip visibility", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 60000 });
const MED_NAME = "Tooltip Test Med";
const createdMeds: TestMedication[] = [];
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
createdMeds.push(
await createMedicationViaAPI({
name: MED_NAME,
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
/**
* Open the MedDetail modal by clicking a medication row in the Dashboard overview table.
*/
async function openMedDetailModal(page: import("@playwright/test").Page) {
await navigateTo(page, "/dashboard");
const overviewTable = page.locator(".dashboard-overview-section .table").first();
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
await medRow.click();
const modal = page.locator(".modal-overlay.med-detail-overlay");
await expect(modal).toBeVisible({ timeout: 5000 });
return modal;
}
test("no ancestor of footer tooltip buttons has overflow:hidden", async ({ page }) => {
const modal = await openMedDetailModal(page);
const footer = modal.locator(".med-detail-footer");
await expect(footer).toBeVisible();
// Walk up from footer through modal-content to modal-overlay and check overflow
const overflowHiddenAncestors = await page.evaluate(() => {
const footer = document.querySelector(".med-detail-footer");
if (!footer) return ["footer not found"];
const problems: string[] = [];
let el: HTMLElement | null = footer as HTMLElement;
while (el && !el.classList.contains("modal-overlay")) {
const computed = window.getComputedStyle(el);
const overflowX = computed.overflowX;
const overflowY = computed.overflowY;
if (overflowX === "hidden" || overflowY === "hidden") {
const id = el.id ? `#${el.id}` : "";
const cls = el.className ? `.${el.className.split(" ").join(".")}` : "";
problems.push(`${el.tagName.toLowerCase()}${id}${cls} has overflow: ${overflowX}/${overflowY}`);
}
el = el.parentElement;
}
return problems;
});
expect(
overflowHiddenAncestors,
`Tooltip ancestors must not clip with overflow:hidden: ${overflowHiddenAncestors.join("; ")}`
).toHaveLength(0);
});
test("tooltip z-index is above modal overlay", async ({ page }) => {
const _modal = await openMedDetailModal(page);
// Get modal overlay z-index and tooltip pseudo-element z-index from CSS
const { modalZIndex, tooltipZIndex, arrowZIndex } = await page.evaluate(() => {
const overlay = document.querySelector(".modal-overlay");
const overlayZ = overlay ? Number.parseInt(window.getComputedStyle(overlay).zIndex, 10) : 0;
// Read the tooltip ::after z-index from stylesheets
let ttZ = 0;
let arrZ = 0;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const cssRule = rule as CSSStyleRule;
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::after")) {
const z = Number.parseInt(cssRule.style.zIndex, 10);
if (z > ttZ) ttZ = z;
}
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::before")) {
const z = Number.parseInt(cssRule.style.zIndex, 10);
if (z > arrZ) arrZ = z;
}
}
} catch {
// cross-origin sheets — skip
}
}
return { modalZIndex: overlayZ, tooltipZIndex: ttZ, arrowZIndex: arrZ };
});
expect(
tooltipZIndex,
`Tooltip ::after z-index (${tooltipZIndex}) must be > modal overlay z-index (${modalZIndex})`
).toBeGreaterThan(modalZIndex);
expect(
arrowZIndex,
`Tooltip ::before z-index (${arrowZIndex}) must be > modal overlay z-index (${modalZIndex})`
).toBeGreaterThan(modalZIndex);
});
test("edit button tooltip is visible on hover", async ({ page }) => {
const modal = await openMedDetailModal(page);
const editBtn = modal.locator(".med-detail-footer button.tooltip-trigger.info.icon-only");
await expect(editBtn).toBeVisible();
// Hover to activate tooltip
await editBtn.hover();
// Small wait for CSS transition
await page.waitForTimeout(300);
// Verify the tooltip pseudo-element is visible and within viewport
const isVisible = await page.evaluate(() => {
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.info.icon-only");
if (!btn) return { visible: false, reason: "button not found" };
const style = window.getComputedStyle(btn, "::after");
const opacity = Number.parseFloat(style.opacity);
const visibility = style.visibility;
if (opacity < 0.5 || visibility === "hidden") {
return {
visible: false,
reason: `opacity=${opacity}, visibility=${visibility}`,
};
}
return { visible: true, reason: "ok" };
});
expect(isVisible.visible, `Edit tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
});
test("stock correction button tooltip is visible on hover", async ({ page }) => {
const modal = await openMedDetailModal(page);
const stockBtn = modal.locator(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
await expect(stockBtn).toBeVisible();
await stockBtn.hover();
await page.waitForTimeout(300);
const isVisible = await page.evaluate(() => {
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
if (!btn) return { visible: false, reason: "button not found" };
const style = window.getComputedStyle(btn, "::after");
const opacity = Number.parseFloat(style.opacity);
const visibility = style.visibility;
if (opacity < 0.5 || visibility === "hidden") {
return {
visible: false,
reason: `opacity=${opacity}, visibility=${visibility}`,
};
}
return { visible: true, reason: "ok" };
});
expect(isVisible.visible, `Stock correction tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
});
test("export button tooltip is visible on hover", async ({ page }) => {
const modal = await openMedDetailModal(page);
const exportBtn = modal.locator(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
// Export button only shows when blisters exist — skip if not present
if (!(await exportBtn.isVisible().catch(() => false))) {
test.skip(true, "Export button not visible (no blisters)");
return;
}
await exportBtn.hover();
await page.waitForTimeout(300);
const isVisible = await page.evaluate(() => {
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
if (!btn) return { visible: false, reason: "button not found" };
const style = window.getComputedStyle(btn, "::after");
const opacity = Number.parseFloat(style.opacity);
const visibility = style.visibility;
if (opacity < 0.5 || visibility === "hidden") {
return {
visible: false,
reason: `opacity=${opacity}, visibility=${visibility}`,
};
}
return { visible: true, reason: "ok" };
});
expect(isVisible.visible, `Export tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
});
test("close button tooltip in header is visible on hover", async ({ page }) => {
const modal = await openMedDetailModal(page);
const closeBtn = modal.locator("button.modal-close.tooltip-trigger");
await expect(closeBtn).toBeVisible();
await closeBtn.hover();
await page.waitForTimeout(300);
const isVisible = await page.evaluate(() => {
const btn = document.querySelector(".med-detail-overlay button.modal-close.tooltip-trigger");
if (!btn) return { visible: false, reason: "button not found" };
const style = window.getComputedStyle(btn, "::after");
const opacity = Number.parseFloat(style.opacity);
const visibility = style.visibility;
if (opacity < 0.5 || visibility === "hidden") {
return {
visible: false,
reason: `opacity=${opacity}, visibility=${visibility}`,
};
}
return { visible: true, reason: "ok" };
});
expect(isVisible.visible, `Close button tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
});
});

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