Compare commits

...

68 Commits

Author SHA1 Message Date
Daniel Volz 6daafcc0dd chore: release v1.24.0 2026-05-11 11:20:20 +02:00
dependabot[bot] 795aa59acb build(deps): bump actions/add-to-project from 1.0.2 to 2.0.0
Bumps [actions/add-to-project](https://github.com/actions/add-to-project) from 1.0.2 to 2.0.0.
- [Release notes](https://github.com/actions/add-to-project/releases)
- [Commits](https://github.com/actions/add-to-project/compare/v1.0.2...v2.0.0)

---
updated-dependencies:
- dependency-name: actions/add-to-project
  dependency-version: 2.0.0
  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-05-11 11:09:58 +02:00
dependabot[bot] 1514483cf1 build(deps): bump the minor-and-patch group in /frontend with 9 updates
Bumps the minor-and-patch group in /frontend with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.8` | `26.1.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.5` | `19.2.6` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.5` | `19.2.6` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.6` | `17.0.7` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.2` | `7.15.0` |
| [zod](https://github.com/colinhacks/zod) | `4.4.2` | `4.4.3` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.14` | `2.4.15` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.6.0` | `25.6.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.10` | `8.0.12` |


Updates `i18next` from 26.0.8 to 26.1.0
- [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/v26.0.8...v26.1.0)

Updates `react` from 19.2.5 to 19.2.6
- [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.6/packages/react)

Updates `react-dom` from 19.2.5 to 19.2.6
- [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.6/packages/react-dom)

Updates `react-i18next` from 17.0.6 to 17.0.7
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.6...v17.0.7)

Updates `react-router-dom` from 7.14.2 to 7.15.0
- [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.15.0/packages/react-router-dom)

Updates `zod` from 4.4.2 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.2...v4.4.3)

Updates `@biomejs/biome` from 2.4.14 to 2.4.15
- [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.15/packages/@biomejs/biome)

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

Updates `vite` from 8.0.10 to 8.0.12
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.12
  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-05-11 11:09:32 +02:00
dependabot[bot] 554e2ba5ae build(deps-dev): bump the minor-and-patch group in /backend with 2 updates
Bumps the minor-and-patch group in /backend with 2 updates: [@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 `@biomejs/biome` from 2.4.14 to 2.4.15
- [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.15/packages/@biomejs/biome)

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

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.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-05-11 11:09:26 +02:00
dependabot[bot] 4ae845d412 build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#611)
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.14 to 2.4.15
- [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.15/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.15
  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-05-11 09:34:27 +02:00
dependabot[bot] 59519ae214 build(deps-dev): bump lint-staged from 16.4.0 to 17.0.4 (#612)
Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 16.4.0 to 17.0.4.
- [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.4.0...v17.0.4)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 17.0.4
  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-05-11 09:33:48 +02:00
Daniel Volz 328f732066 fix: replace ntfy reminder with action confirmation
* fix: replace ntfy reminder with action confirmation

* fix: correct notification actions branch payload

* fix: format notification actions follow-up
2026-05-11 09:24:29 +02:00
dependabot[bot] ec478f7601 build(deps): bump fast-uri from 3.1.0 to 3.1.2 in /backend
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

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-05-11 00:05:43 +02:00
github-actions[bot] c25076e83b chore: update test count badges [skip ci] 2026-05-10 21:59:27 +00:00
Daniel Volz b4dc1074e8 feat: wire interactive intake reminder actions 2026-05-10 23:55:04 +02:00
github-actions[bot] aa2313427a chore: update test count badges [skip ci] 2026-05-10 21:54:09 +00:00
Daniel Volz 6a31019fdc feat: add public notification action routes 2026-05-10 23:48:52 +02:00
github-actions[bot] ae5d6cc3e8 chore: update test count badges [skip ci] 2026-05-10 21:29:11 +00:00
Daniel Volz 5060d135ba feat: add reminder skip frontend flow 2026-05-10 23:24:18 +02:00
Daniel Volz 4019716b9b feat: add reminder skip backend state
* feat: extract dose tracking action service

* Use dose tracking service in protected routes

* Restore dose route compatibility

* Restore dose tracking service dependencies
2026-05-10 23:24:02 +02:00
Daniel Volz 7df17ef705 feat: extract dose tracking action service
* feat: extract dose tracking action service

* Use dose tracking service in protected routes

* Restore dose route compatibility
2026-05-10 23:23:45 +02:00
github-actions[bot] b3c46ea179 chore: update test count badges [skip ci] 2026-05-10 21:08:18 +00:00
Daniel Volz 255746d9f5 feat: restore ntfy interactive settings test delivery support
Squash merge PR #591: feat: restore ntfy interactive settings test delivery support
2026-05-10 23:04:24 +02:00
Daniel Volz d99bc3d99e chore: restore missing drizzle snapshot
chore: restore missing drizzle snapshot
2026-05-10 22:51:19 +02:00
Daniel Volz f265d090c6 fix: simplify OIDC failure redirects
fix: simplify OIDC failure redirects
2026-05-10 22:50:39 +02:00
github-actions[bot] 8473ed8387 chore: update test count badges [skip ci] 2026-05-10 20:15:43 +00:00
Daniel Volz c38964cd70 fix: restore backend ntfy and refill CI baseline
Closes #605
2026-05-10 22:09:06 +02:00
Daniel Volz 72ba4d1272 chore: support proxied frontend dev hosts 2026-05-10 20:49:15 +02:00
Daniel Volz eba77c9520 fix: preserve frontend medication deep links 2026-05-10 20:25:14 +02:00
Daniel Volz d4b8ddc590 fix: restore shared schedule skip actions
(cherry picked from commit eca068b1c7f494bbb2caf7edcd480bf67f76df33)
2026-05-10 20:13:39 +02:00
Daniel Volz 4d6c568668 docs: refresh default user settings reference 2026-05-10 19:33:13 +02:00
Daniel Volz 12dc77455c chore: tighten local agent workspace rules (#576)
* chore: tighten local agent workspace rules

* chore: ignore local generated agent artifacts
2026-05-10 19:19:12 +02:00
dependabot[bot] 7554a79898 build(deps): bump fast-uri from 3.1.0 to 3.1.2 in /backend (#564)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

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-05-10 15:53:03 +02:00
Daniel Volz 70f2392a71 fix: harden dashboard notification focus rendering 2026-05-10 15:03:23 +02:00
Daniel Volz ba789f9794 fix: harden dashboard takenBy rendering 2026-05-10 14:22:33 +02:00
Daniel Volz 277fc3e686 fix(refill): stabilize stock and amount package semantics 2026-05-08 11:03:25 +02:00
dependabot[bot] b838f0e8ea build(deps): bump zod from 3.25.76 to 4.4.3 in /backend
* build(deps): bump zod from 3.25.76 to 4.4.3 in /backend

Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.4.3.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.4.3)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* fix: adapt backend validation for zod v4

---------

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-05-04 10:56:44 +02:00
Daniel Volz 0b888cf00a Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  build(deps): bump the minor-and-patch group in /backend with 4 updates (#561)
  build(deps): bump the minor-and-patch group in /frontend with 5 updates (#560)
  build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#559)
  build(deps): bump the minor-and-patch group in /backend with 7 updates (#554)
  build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#552)
  build(deps): bump the minor-and-patch group in /frontend with 8 updates (#553)
  chore: update test count badges [skip ci]
  Improve coverage for image upload and schedule helper logic with focused unit tests (#551)
  build(deps-dev): bump @biomejs/biome from 2.4.11 to 2.4.12 in the minor-and-patch group
  build(deps): bump the minor-and-patch group in /frontend with 6 updates
  build(deps): bump the minor-and-patch group in /backend with 4 updates
  build(deps): bump fastify from 5.8.4 to 5.8.5 in /backend
2026-05-04 10:56:22 +02:00
dependabot[bot] dbc722a898 build(deps): bump the minor-and-patch group in /backend with 4 updates (#561)
Bumps the minor-and-patch group in /backend with 4 updates: [jose](https://github.com/panva/jose), [nodemailer](https://github.com/nodemailer/nodemailer), [openid-client](https://github.com/panva/openid-client) and [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `jose` from 6.2.2 to 6.2.3
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.2...v6.2.3)

Updates `nodemailer` from 8.0.6 to 8.0.7
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.6...v8.0.7)

Updates `openid-client` from 6.8.3 to 6.8.4
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v6.8.3...v6.8.4)

Updates `@biomejs/biome` from 2.4.13 to 2.4.14
- [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.14/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: nodemailer
  dependency-version: 8.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openid-client
  dependency-version: 6.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.14
  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-05-04 10:17:26 +02:00
dependabot[bot] 15a44d4f55 build(deps): bump the minor-and-patch group in /frontend with 5 updates (#560)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.11.0` | `1.14.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.4` | `17.0.6` |
| [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.4.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.13` | `2.4.14` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.1.0` | `29.1.1` |


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

Updates `react-i18next` from 17.0.4 to 17.0.6
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.4...v17.0.6)

Updates `zod` from 4.3.6 to 4.4.2
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.2)

Updates `@biomejs/biome` from 2.4.13 to 2.4.14
- [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.14/packages/@biomejs/biome)

Updates `jsdom` from 29.1.0 to 29.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.0...v29.1.1)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: zod
  dependency-version: 4.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.1.1
  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-05-04 10:16:51 +02:00
dependabot[bot] 4de138015d build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#559)
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.13 to 2.4.14
- [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.14/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.14
  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-05-04 10:14:08 +02:00
Daniel Volz 3bb8b93a4c chore: add .planning/codebase map (7 documents, gsd-map-codebase) 2026-04-30 12:37:45 +02:00
dependabot[bot] 3af8a5a704 build(deps): bump the minor-and-patch group in /backend with 7 updates (#554)
Bumps the minor-and-patch group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@fastify/static](https://github.com/fastify/fastify-static) | `9.1.1` | `9.1.3` |
| [@fastify/swagger-ui](https://github.com/fastify/fastify-swagger-ui) | `5.2.5` | `5.2.6` |
| [@libsql/client](https://github.com/tursodatabase/libsql-client-ts/tree/HEAD/packages/libsql-client) | `0.17.2` | `0.17.3` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.5` | `8.0.6` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.12` | `2.4.13` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.4` | `4.1.5` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |


Updates `@fastify/static` from 9.1.1 to 9.1.3
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](https://github.com/fastify/fastify-static/compare/v9.1.1...v9.1.3)

Updates `@fastify/swagger-ui` from 5.2.5 to 5.2.6
- [Release notes](https://github.com/fastify/fastify-swagger-ui/releases)
- [Commits](https://github.com/fastify/fastify-swagger-ui/compare/v5.2.5...v5.2.6)

Updates `@libsql/client` from 0.17.2 to 0.17.3
- [Release notes](https://github.com/tursodatabase/libsql-client-ts/releases)
- [Changelog](https://github.com/tursodatabase/libsql-client-ts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tursodatabase/libsql-client-ts/commits/v0.17.3/packages/libsql-client)

Updates `nodemailer` from 8.0.5 to 8.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.5...v8.0.6)

Updates `@biomejs/biome` from 2.4.12 to 2.4.13
- [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.13/packages/@biomejs/biome)

Updates `@vitest/coverage-v8` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@fastify/swagger-ui"
  dependency-version: 5.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@libsql/client"
  dependency-version: 0.17.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: nodemailer
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.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-04-27 20:56:41 +02:00
dependabot[bot] f301f24182 build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#552)
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.12 to 2.4.13
- [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.13/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.13
  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-04-27 20:56:00 +02:00
dependabot[bot] 6dc1e68392 build(deps): bump the minor-and-patch group in /frontend with 8 updates (#553)
Bumps the minor-and-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.6` | `26.0.8` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.8.0` | `1.11.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.1` | `7.14.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.12` | `2.4.13` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.4` | `4.1.5` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.2` | `29.1.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.9` | `8.0.10` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |


Updates `i18next` from 26.0.6 to 26.0.8
- [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/v26.0.6...v26.0.8)

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

Updates `react-router-dom` from 7.14.1 to 7.14.2
- [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.14.2/packages/react-router-dom)

Updates `@biomejs/biome` from 2.4.12 to 2.4.13
- [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.13/packages/@biomejs/biome)

Updates `@vitest/coverage-v8` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8)

Updates `jsdom` from 29.0.2 to 29.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.2...v29.1.0)

Updates `vite` from 8.0.9 to 8.0.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.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-04-27 20:53:37 +02:00
github-actions[bot] e4b1630922 chore: update test count badges [skip ci] 2026-04-21 07:16:32 +00:00
Copilot c7be73786b Improve coverage for image upload and schedule helper logic with focused unit tests (#551)
Agent-Logs-Url: https://github.com/DanielVolz/medassist-ng/sessions/a5af7c91-2dd4-4a79-838e-dbb79fc08f6d

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-04-21 09:12:36 +02:00
dependabot[bot] cdfb19bde2 build(deps-dev): bump @biomejs/biome from 2.4.11 to 2.4.12 in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.11 to 2.4.12
- [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.12/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.12
  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-04-20 18:39:50 +02:00
dependabot[bot] f7da65e7a1 build(deps): bump the minor-and-patch group in /frontend with 6 updates
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.4` | `26.0.6` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.2` | `17.0.4` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.14.0` | `7.14.1` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.11` | `2.4.12` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `6.0.3` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.8` | `8.0.9` |


Updates `i18next` from 26.0.4 to 26.0.6
- [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/v26.0.4...v26.0.6)

Updates `react-i18next` from 17.0.2 to 17.0.4
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.2...v17.0.4)

Updates `react-router-dom` from 7.14.0 to 7.14.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.14.1/packages/react-router-dom)

Updates `@biomejs/biome` from 2.4.11 to 2.4.12
- [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.12/packages/@biomejs/biome)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

Updates `vite` from 8.0.8 to 8.0.9
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.9/packages/vite)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.9
  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-04-20 18:38:40 +02:00
dependabot[bot] 27e42c0935 build(deps): bump the minor-and-patch group in /backend with 4 updates
Bumps the minor-and-patch group in /backend with 4 updates: [@fastify/static](https://github.com/fastify/fastify-static), [openid-client](https://github.com/panva/openid-client), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [typescript](https://github.com/microsoft/TypeScript).


Updates `@fastify/static` from 9.1.0 to 9.1.1
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](https://github.com/fastify/fastify-static/compare/v9.1.0...v9.1.1)

Updates `openid-client` from 6.8.2 to 6.8.3
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3)

Updates `@biomejs/biome` from 2.4.11 to 2.4.12
- [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.12/packages/@biomejs/biome)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openid-client
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 6.0.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-04-20 18:38:02 +02:00
dependabot[bot] 67ad693b31 build(deps): bump fastify from 5.8.4 to 5.8.5 in /backend
Bumps [fastify](https://github.com/fastify/fastify) from 5.8.4 to 5.8.5.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.4...v5.8.5)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.8.5
  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-04-16 07:00:31 +02:00
Daniel Volz ab3facc47a Merge github/dependabot/github_actions/softprops/action-gh-release-3
* github/dependabot/github_actions/softprops/action-gh-release-3:
  build(deps): bump softprops/action-gh-release from 2 to 3
2026-04-14 17:19:14 +02:00
Daniel Volz ce02b4211a Merge github/dependabot/github_actions/dependabot/fetch-metadata-3
* github/dependabot/github_actions/dependabot/fetch-metadata-3:
  build(deps): bump dependabot/fetch-metadata from 2 to 3
2026-04-14 17:19:14 +02:00
Daniel Volz 40bd7ba3b7 Merge github/dependabot/github_actions/actions/github-script-9
* github/dependabot/github_actions/actions/github-script-9:
  build(deps): bump actions/github-script from 8 to 9
2026-04-14 17:19:14 +02:00
Daniel Volz 826d85937c Merge github/dependabot/npm_and_yarn/backend/fastify/multipart-10.0.0
* github/dependabot/npm_and_yarn/backend/fastify/multipart-10.0.0:
  build(deps): bump @fastify/multipart from 9.4.0 to 10.0.0 in /backend
2026-04-14 17:19:14 +02:00
Daniel Volz 6d98a049bc Merge github/dependabot/npm_and_yarn/backend/minor-and-patch-38fe55292f
* github/dependabot/npm_and_yarn/backend/minor-and-patch-38fe55292f:
  build(deps): bump the minor-and-patch group in /backend with 6 updates
2026-04-14 17:19:14 +02:00
Daniel Volz 435ca5f1d6 Merge github/dependabot/npm_and_yarn/minor-and-patch-0d57c39e52
* github/dependabot/npm_and_yarn/minor-and-patch-0d57c39e52:
  build(deps-dev): bump @biomejs/biome in the minor-and-patch group
2026-04-14 17:19:13 +02:00
Daniel Volz ecf9cfb539 Merge branch 'main' into dependabot/github_actions/softprops/action-gh-release-3 2026-04-13 09:28:51 +02:00
dependabot[bot] dafa5abab4 build(deps): bump the minor-and-patch group in /frontend with 10 updates (#538)
Bumps the minor-and-patch group in /frontend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.3` | `26.0.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.7.0` | `1.8.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.5` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.10` | `2.4.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.2` | `25.6.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.2` | `4.1.4` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.1` | `29.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.5` | `8.0.8` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |


Updates `i18next` from 26.0.3 to 26.0.4
- [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/v26.0.3...v26.0.4)

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

Updates `react` from 19.2.4 to 19.2.5
- [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.5/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.5
- [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.5/packages/react-dom)

Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [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.11/packages/@biomejs/biome)

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

Updates `@vitest/coverage-v8` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-v8)

Updates `jsdom` from 29.0.1 to 29.0.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.1...v29.0.2)

Updates `vite` from 8.0.5 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.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-04-13 09:28:08 +02:00
dependabot[bot] cc5141c997 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:25 +00:00
dependabot[bot] 22725fa566 build(deps): bump dependabot/fetch-metadata from 2 to 3
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:22 +00:00
dependabot[bot] a5fe76545e build(deps): bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:45:19 +00:00
dependabot[bot] 527f4251e5 build(deps): bump @fastify/multipart from 9.4.0 to 10.0.0 in /backend
Bumps [@fastify/multipart](https://github.com/fastify/fastify-multipart) from 9.4.0 to 10.0.0.
- [Release notes](https://github.com/fastify/fastify-multipart/releases)
- [Commits](https://github.com/fastify/fastify-multipart/compare/v9.4.0...v10.0.0)

---
updated-dependencies:
- dependency-name: "@fastify/multipart"
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:38:56 +00:00
dependabot[bot] 5064de3bff build(deps): bump the minor-and-patch group in /backend with 6 updates
Bumps the minor-and-patch group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@fastify/static](https://github.com/fastify/fastify-static) | `9.0.0` | `9.1.0` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.4.1` | `17.4.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.10` | `2.4.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.2` | `25.6.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.2` | `4.1.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.4` |


Updates `@fastify/static` from 9.0.0 to 9.1.0
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](https://github.com/fastify/fastify-static/compare/v9.0.0...v9.1.0)

Updates `dotenv` from 17.4.1 to 17.4.2
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.1...v17.4.2)

Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [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.11/packages/@biomejs/biome)

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

Updates `@vitest/coverage-v8` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/coverage-v8)

Updates `vitest` from 4.1.2 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: dotenv
  dependency-version: 17.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:38:43 +00:00
dependabot[bot] 40d6f33676 build(deps-dev): bump @biomejs/biome in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.4.10 to 2.4.11
- [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.11/packages/@biomejs/biome)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 06:17:31 +00:00
Daniel Volz 0dab318b66 chore: release v1.23.0 (#536) 2026-04-10 22:40:01 +02:00
github-actions[bot] 932524125e chore: update test count badges [skip ci] 2026-04-10 20:38:49 +00:00
Daniel Volz c291c88f2b fix(notifications): fallback to generic medication names (#532)
* fix(notifications): fallback to generic medication names

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:34:06 +02:00
Daniel Volz e42e4f5639 fix(stock): ignore doses from other medications (#533)
* fix(stock): ignore doses from other medications

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:33:58 +02:00
Daniel Volz b70fc88921 chore(gitignore): ignore local agent workspace artifacts (#534) 2026-04-10 22:31:54 +02:00
Daniel Volz 95aec8350a fix(settings): stabilize timezone edit UX and tooltip visibility (#535) 2026-04-10 22:31:22 +02:00
Daniel Volz 401228699f Add searchable timezone settings override for reminder scheduling 2026-04-10 21:08:16 +02:00
Daniel Volz 0d2b21199e chore(release): bump app version to 1.22.3
chore(release): bump app version to 1.22.3
2026-04-10 19:01:40 +02:00
107 changed files with 9946 additions and 1533 deletions
+9 -2
View File
@@ -13,6 +13,12 @@ PORT=3000
CORS_ORIGINS=http://localhost:4174 CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn LOG_LEVEL=warn
# Public base URL used for notification action links.
# Required for intake reminder action buttons/links.
# PUBLIC_APP_URL=https://medassist.example.com
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
# Levels: debug, info, warn, error, silent # Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker), # Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection) # and frontend browser console (via build-time injection)
@@ -37,7 +43,8 @@ LOG_LEVEL=warn
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false # production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true # OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) # Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
# Users can override this per account in Settings -> Timezone.
TZ=Europe/Berlin TZ=Europe/Berlin
# ============================================================================= # =============================================================================
@@ -148,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults # UI defaults
# DEFAULT_LANGUAGE=en # en or de # DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual # DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links # DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false # DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false # DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+7 -1
View File
@@ -1,11 +1,17 @@
# MedAssist-ng - Copilot Entry Point # MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT ## VERY IMPORTANT - Prioritized Constraints
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss. - Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review. - Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement. - This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules. Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
## Required Startup Steps ## Required Startup Steps
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project name: Add issue to project
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/add-to-project@v1.0.2 - uses: actions/add-to-project@v2.0.0
with: with:
project-url: ${{ vars.PROJECT_URL }} project-url: ${{ vars.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
- name: Read Dependabot metadata - name: Read Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@v2 uses: dependabot/fetch-metadata@v3
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ steps.current_tag.outputs.value }} tag_name: ${{ steps.current_tag.outputs.value }}
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
(github.event_name == 'pull_request' && github.event.pull_request.merged == true) (github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps: steps:
- name: Move project item to Done - name: Move project item to Done
uses: actions/github-script@v8 uses: actions/github-script@v9
with: with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: | script: |
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Sync fields - name: Sync fields
uses: actions/github-script@v8 uses: actions/github-script@v9
with: with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: | script: |
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Build weekly summary - name: Build weekly summary
id: summary id: summary
uses: actions/github-script@v8 uses: actions/github-script@v9
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
@@ -59,7 +59,7 @@ jobs:
core.setOutput('body', body); core.setOutput('body', body);
- name: Publish report issue - name: Publish report issue
uses: actions/github-script@v8 uses: actions/github-script@v9
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
+26 -2
View File
@@ -83,18 +83,42 @@ Thumbs.db
AGENTS.md AGENTS.md
docs/TECH_STACK.md docs/TECH_STACK.md
doku/ doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md doku/memory_notes.md
doku/report.md doku/report.md
plan/ plan/
.copilot-tracking/ .copilot-tracking/
.playwright-cli/ .playwright-cli/
.agents/
skills-lock.json
# =================== # ===================
# Local Spec Kit artifacts # Local Spec Kit workspace state
# =================== # ===================
.specify/ .specify/
specs/ specs/
docs/SPEC_KIT.md docs/SPEC_KIT.md
.github/agents/medassist-feature-orchestrator.agent.md .github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md .github/agents/speckit.*.agent.md
.github/prompts/speckit.*.prompt.md .github/prompts/speckit.*.prompt.md
.github/skills/accessibility/
.github/skills/frontend-design/
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
# Local GSD/copilot generated workspace artifacts (not for upstream)
.github/agents/copilot-instructions.md
.github/agents/gsd-*.agent.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/get-shit-done/
.github/gsd-file-manifest.json
.github/prompts/speckit.*.prompt.md
.github/skills/gsd-*/
.planning/
doku/memory_notes.md
doku/report.md
ops/medtest/
+168
View File
@@ -0,0 +1,168 @@
<!-- refreshed: 2026-04-30 -->
# Architecture
**Analysis Date:** 2026-04-30
## System Overview
```text
┌─────────────────────────────────────────────────────────────┐
│ Frontend SPA (React) │
├──────────────────┬──────────────────┬───────────────────────┤
│ App Shell/Routes │ Shared State │ Feature Pages │
│ `frontend/src/ │ `frontend/src/ │ `frontend/src/pages/` │
│ App.tsx` │ context/` │ │
└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Backend API (Fastify) │
│ `backend/src/index.ts` + `backend/src/routes/` │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SQLite Persistence + Migration Layer │
│ `backend/src/db/schema.ts` + `backend/src/db/client.ts` │
└─────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
| Component | Responsibility | File |
|-----------|----------------|------|
| Frontend bootstrap | Mount providers/router and start app tree | `frontend/src/main.tsx` |
| App router/shell | Public share routes, authenticated shell routes, global modal composition | `frontend/src/App.tsx` |
| Frontend orchestration | Compose domain hooks and expose app-level state/actions | `frontend/src/context/AppContext.tsx` |
| API proxy boundary | Rewrite `/api/*` requests to backend root routes | `frontend/vite.config.ts` |
| Backend composition root | Register plugins/routes, await migrations, start schedulers | `backend/src/index.ts` |
| Route handlers | HTTP contracts, validation, auth hooks, response shaping | `backend/src/routes/*.ts` |
| Domain services | Shared domain logic and scheduler behavior | `backend/src/services/*.ts` |
| Persistence | Table definitions + compatibility migration/runtime initialization | `backend/src/db/schema.ts`, `backend/src/db/client.ts` |
## Pattern Overview
**Overall:** Layered modular monolith (single frontend SPA + single backend process)
**Key Characteristics:**
- Frontend uses React Router + context/hook composition (`frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`).
- Backend uses route modules with shared service modules (`backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
- Data persistence is centralized in Drizzle schema + startup migrations (`backend/src/db/schema.ts`, `backend/src/db/client.ts`).
## Layers
**Frontend Presentation + Orchestration:**
- Purpose: Render UI, route navigation, manage client state, invoke API.
- Location: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/`, `frontend/src/context/`, `frontend/src/hooks/`.
- Contains: pages, modals, app shell, hook-based API callers.
- Depends on: backend `/api/*`, i18n, shared frontend utils/types.
- Used by: browser clients.
**Backend HTTP/API Layer:**
- Purpose: Expose REST endpoints, authenticate/authorize requests, validate input, map to service/db logic.
- Location: `backend/src/index.ts`, `backend/src/routes/`, `backend/src/plugins/`.
- Contains: Fastify app setup, route registration, auth middleware.
- Depends on: services, db client/schema, env plugin.
- Used by: frontend SPA and API consumers.
**Domain Services Layer:**
- Purpose: Reusable business logic for scheduling, notifications, stock math, parsing.
- Location: `backend/src/services/`, `backend/src/utils/`.
- Contains: reminder scheduler, notification builders/delivery, medication helpers.
- Depends on: db models and utilities.
- Used by: routes and startup process.
**Persistence Layer:**
- Purpose: Define DB schema and keep existing SQLite instances compatible.
- Location: `backend/src/db/schema.ts`, `backend/src/db/client.ts`, `backend/drizzle/`.
- Contains: tables, migration execution, backward-compatible alter migrations.
- Depends on: Drizzle + libsql client.
- Used by: routes/services.
## Data Flow
### Primary Request Path
1. Frontend page triggers API call via `/api/*` fetch (`frontend/src/pages/PlannerPage.tsx:307`).
2. Vite proxy rewrites `/api` prefix to backend route root (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
3. Fastify route handles request under `/planner/send-email` with auth + validation (`backend/src/routes/planner.ts:141`, `backend/src/routes/planner.ts:158`).
4. Route loads user settings and dispatches channel delivery helpers (`backend/src/routes/planner.ts:221`, `backend/src/routes/planner.ts:432`, `backend/src/routes/planner.ts:829`).
### Public Share Flow
1. Frontend routes public token URL to shared schedule view (`frontend/src/App.tsx:35`).
2. Shared schedule component fetches token payload from `/api/share/:token` (`frontend/src/components/SharedSchedule.tsx:311`).
3. Backend public share route reads token/settings and returns filtered medication schedule (`backend/src/routes/share.ts:125`, `backend/src/routes/share.ts:156`).
**State Management:**
- Frontend: context-centric state aggregation (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
- Backend: DB-backed state with runtime scheduler state persisted through notification state utilities (`backend/src/services/reminder-scheduler.ts:42`).
## Key Abstractions
**Auth Context + Guards:**
- Purpose: unify session/API-key auth across protected routes.
- Examples: `backend/src/plugins/auth.ts`, `backend/src/routes/settings.ts`.
- Pattern: route-level `preHandler` guard plus request decoration (`backend/src/routes/settings.ts:138`, `backend/src/plugins/auth.ts:236`).
**Notification Delivery Contract:**
- Purpose: keep route-triggered and scheduler-triggered notifications consistent.
- Examples: `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/notifications/delivery.ts`.
- Pattern: shared builders/delivery/state helpers imported into both paths (`backend/src/routes/planner.ts:23`, `backend/src/services/reminder-scheduler.ts:39`).
**Frontend App Context Aggregator:**
- Purpose: centralize shared medication/settings/dose/share/refill state for page/modal consumers.
- Examples: `frontend/src/context/AppContext.tsx`, `frontend/src/context/ShareContext.tsx`.
- Pattern: compose domain hooks, expose typed value via provider (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
## Entry Points
**Frontend bootstrap:**
- Location: `frontend/src/main.tsx`
- Triggers: browser loads `index.html`.
- Responsibilities: initialize theme/provider stack and router (`frontend/src/main.tsx:12`, `frontend/src/main.tsx:15`).
**Backend process entry:**
- Location: `backend/src/index.ts`
- Triggers: `npm run dev`/`npm start` in backend package.
- Responsibilities: await migrations, register routes, start HTTP listener and schedulers (`backend/src/index.ts:231`, `backend/src/index.ts:305`, `backend/src/index.ts:309`, `backend/src/index.ts:334`).
## Architectural Constraints
- **Threading:** Single Node.js event loop process with in-process schedulers started at runtime (`backend/src/index.ts:309`, `backend/src/index.ts:323`).
- **Global state:** Module/global singletons exist in auth and context layers (`backend/src/plugins/auth.ts:15`, `frontend/src/context/AppContext.tsx:222`).
- **Circular imports:** Not detected from sampled route/service/db/frontend orchestration files.
- **API boundary:** Frontend network calls must use `/api/*` so proxy rewrite applies (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
## Anti-Patterns
### Duplicated Backend App Wiring
**What happens:** Route/plugin registration appears in both `createApp(...)` and top-level startup path.
**Why it's wrong:** Two bootstrap paths increase divergence risk when new routes/plugins are added in one path but not the other.
**Do this instead:** Keep a single shared app-construction function used by both test/runtime startup paths (`backend/src/index.ts:133`, `backend/src/index.ts:207`, `backend/src/index.ts:289`).
### Oversized Frontend Orchestration Context
**What happens:** `AppContext` aggregates many unrelated concerns (medications, settings, doses, sharing, import/export, modal history) in one large provider.
**Why it's wrong:** High coupling and broad rerender surface make safe changes harder and increase regression risk.
**Do this instead:** Preserve existing provider contract, but move new domain concerns into focused hooks/providers and re-export through composition only when needed (`frontend/src/context/AppContext.tsx`, file size ~1035 lines).
## Error Handling
**Strategy:** Fail fast at route boundary with explicit status codes and schema validation, then log context-rich errors.
**Patterns:**
- Route validation + immediate 400 responses for invalid input (`backend/src/routes/medications.ts:76`, `backend/src/routes/medications.ts:584`).
- Planner routes return explicit channel/config errors (`backend/src/routes/planner.ts:204`, `backend/src/routes/planner.ts:509`).
- Frontend captures network errors and maps them to normalized error codes for UI handling (`frontend/src/hooks/useMedications.ts:80`).
## Cross-Cutting Concerns
**Logging:** Fastify logger options configured centrally with environment-aware formatting (`backend/src/index.ts:66`, `backend/src/index.ts:161`).
**Validation:** Zod validation for medication payloads and explicit OpenAPI schema contracts in routes (`backend/src/routes/medications.ts:76`, `backend/src/routes/planner.ts:157`).
**Authentication:** Route-level auth hooks and dual API-key/session handling (`backend/src/routes/planner.ts:141`, `backend/src/plugins/auth.ts:113`, `backend/src/plugins/auth.ts:236`).
---
*Architecture analysis: 2026-04-30*
+122
View File
@@ -0,0 +1,122 @@
# Codebase Concerns
**Analysis Date:** 2026-04-30
## Tech Debt
**Backend startup duplication and config drift:**
- Issue: `backend/src/index.ts` contains two parallel server setup paths (the exported `createApp(...)` flow and the top-level runtime bootstrap). Plugin/route registration and rate-limit defaults are duplicated in both branches.
- Files: `backend/src/index.ts`
- Impact: Configuration behavior can diverge between test/programmatic app construction and production startup (for example, `createApp` uses fixed `rateLimit` max `300`, while runtime startup uses `process.env.RATE_LIMIT_MAX` fallback `100`).
- Fix approach: Extract one canonical app-construction function and let both runtime startup and tests consume it; remove duplicated registration blocks.
**Notification architecture leakage and duplicated composition logic:**
- Issue: Notification delivery service code imports a route-layer helper (`sendShoutrrrNotification`) from settings routes, and large HTML/text reminder composition blocks are duplicated across manual and automatic reminder paths.
- Files: `backend/src/services/notifications/delivery.ts`, `backend/src/routes/settings.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`
- Impact: Layer boundary violations increase coupling, and duplicated notification formatting logic makes behavior regressions likely when changing message content or channel behavior.
- Fix approach: Move `sendShoutrrrNotification` to a service-layer module, make routes call service APIs only, and centralize email/push payload builders for planner + scheduler flows.
**Migration artifact ambiguity in drizzle numbering:**
- Issue: There are two migration files with `0008_` prefix, but the journal tracks only one `0008` tag and then jumps to `0009`.
- Files: `backend/drizzle/0008_add_obsolete_medications.sql`, `backend/drizzle/0008_add_prescription_tracking.sql`, `backend/drizzle/meta/_journal.json`
- Impact: Developer confusion and higher risk of migration-order mistakes during future schema changes.
- Fix approach: Align migration file names and journal tags so each migration number is unique and journal order is obvious.
**Monolithic UI/editor and route modules with broad lint suppressions:**
- Issue: Core interaction files are very large and rely on file-level `biome-ignore-all` suppressions for multiple rule categories.
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/SharedSchedule.tsx`, `frontend/src/components/MedDetailModal.tsx`, `backend/src/routes/medications.ts`
- Impact: Refactors become high-risk; local regressions are harder to isolate; suppressed rule categories hide legitimate quality issues in future edits.
- Fix approach: Split by domain slices (state orchestration vs rendering vs helper transforms), then replace file-level suppressions with narrow, local exceptions only where justified.
## Known Bugs
**Environment-dependent behavior mismatch between test app factory and runtime app:**
- Symptoms: Programmatic app creation and runtime startup can apply different operational defaults (rate limiting and selected config pathways).
- Files: `backend/src/index.ts`
- Trigger: Using `createApp(...)` in tests/integration contexts while production startup uses the top-level runtime branch.
- Workaround: Explicitly pass runtime-equivalent options into `createApp(...)` in tests until startup construction is unified.
## Security Considerations
**Server-side outbound notification surface is broad and sensitive to parser correctness:**
- Risk: The app performs server-side HTTP requests to user-configurable notification URLs, including multiple protocol handlers (`pushover://`, `telegram://`, `gotify://`, generic webhook URLs).
- Files: `backend/src/routes/settings.ts`
- Current mitigation: URL sanitation/validation and hostname checks are present (`sanitizeNotificationUrl`, `validateNotificationHostname` usage in route logic).
- Recommendations: Add focused security regression tests for sanitizer bypasses and callback URL edge cases, and keep all outbound request execution in a dedicated service layer.
**Auth-off bootstrap path creates implicit default user state:**
- Risk: In auth-disabled mode, startup creates/relies on a default user path automatically.
- Files: `backend/src/db/client.ts`
- Current mitigation: Controlled by `AUTH_ENABLED` environment setting.
- Recommendations: Add startup log warnings when running without auth outside development and enforce explicit environment confirmation in deployment templates.
## Performance Bottlenecks
**Reminder scheduling uses repeated full scans over users and medication/dose datasets:**
- Problem: Reminder checks iterate all user settings and compute stock/prescription reminders with repeated in-memory loops over medication and dose collections.
- Files: `backend/src/services/reminder-scheduler.ts`, `backend/src/utils/scheduler-utils.ts`
- Cause: Polling/check strategy prioritizes correctness and compatibility over incremental indexing.
- Improvement path: Introduce incremental candidate selection (changed-medication windows, per-user next-check indices) and reduce repeated whole-set scans.
**Intake reminder scheduler polls every minute and may scale linearly with active schedules:**
- Problem: Intake reminder check loop runs continuously at 60s interval and processes all due reminders/users each tick.
- Files: `backend/src/services/intake-reminder-scheduler.ts`
- Cause: Fixed-interval scheduler (`CHECK_INTERVAL_MS = 60 * 1000`) with loop-driven due-item selection.
- Improvement path: Move toward next-due-time scheduling or bucketing strategy; keep minute polling as fallback only.
## Fragile Areas
**Reminder state persistence and lock handling mix sync file IO with best-effort catches:**
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
- Why fragile: Reminder state writes are synchronous file writes and some read paths swallow errors (`catch {}`), while lock/state files are local filesystem coordination primitives.
- Safe modification: Keep file format backward-compatible, add explicit error telemetry, and add tests for concurrent/failed write scenarios before changing scheduler state logic.
- Test coverage: No direct tests detected for `notifications/delivery.ts` and only limited direct state-function assertions.
**Desktop/mobile medication edit parity depends on two large independent UI paths:**
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/medications/MedicationEditCoordinator.tsx`
- Why fragile: The same editing domain is implemented in separate surfaces, each with dense UI logic and custom interaction handling.
- Safe modification: Apply shared form-section components first, then update desktop and mobile in the same change; validate both paths with targeted tests.
- Test coverage: Coverage exists (`MedicationEditCoordinator`, `MobileEditModal`, `MedicationDialogs` tests), but parity regressions remain a recurring risk due to file size/complexity.
## Scaling Limits
**Current reminder architecture is single-node/local-state oriented:**
- Current capacity: Scheduler state and lock coordination are local files under data directory (`reminder-state.json`, `scheduler-locks/*`).
- Limit: Horizontal multi-instance scaling can duplicate work or require externalized coordination.
- Scaling path: Move reminder state/locks to DB or distributed lock backend and make scheduler execution leader-aware.
**SQLite file-backed persistence constrains concurrent write scaling:**
- Current capacity: Single SQLite file with local filesystem path resolution.
- Limit: Higher write concurrency and distributed deployments will hit filesystem/database locking and throughput limits.
- Scaling path: Keep SQLite for local/small deployments; define migration path to managed DB for larger multi-user workloads.
## Dependencies at Risk
**Route-to-service coupling in notification stack:**
- Risk: Service-layer delivery module depends on route-layer helper import.
- Impact: Refactors of route modules can break unrelated notification infrastructure and complicate testing boundaries.
- Migration plan: Move shared notification send helpers into `backend/src/services/notifications/*` and keep route modules thin.
## Missing Critical Features
**Risk-driven scheduler stress/integration test suite for state-lock edge cases:**
- Problem: Complex scheduler/state code paths rely on file coordination and mixed channel delivery outcomes, but dedicated stress/chaos-style verification is limited.
- Blocks: High-confidence scaling and reliability changes in reminder subsystems.
## Test Coverage Gaps
**Notification delivery abstraction lacks direct unit tests:**
- What's not tested: Direct behavior of SMTP transport creation/result validation and push delivery helpers in the dedicated delivery module.
- Files: `backend/src/services/notifications/delivery.ts`
- Risk: Regressions in recipient validation, SMTP response handling, or provider fallback can ship unnoticed.
- Priority: High
**Reminder state persistence/locking has limited direct verification:**
- What's not tested: Corrupted file recovery, concurrent state writes, and lock stale-file behavior under failure modes.
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
- Risk: Duplicate sends or missed sends after crashes/restarts are difficult to detect early.
- Priority: High
---
*Concerns audit: 2026-04-30*
+116
View File
@@ -0,0 +1,116 @@
# Coding Conventions
**Analysis Date:** 2026-04-30
## Naming Patterns
**Files:**
- Frontend React components and pages use PascalCase file names (for example `frontend/src/components/MobileEditModal.tsx`, `frontend/src/pages/MedicationsPage.tsx`).
- Hooks use `useX` camelCase naming in files and symbols (for example `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useScheduleController.ts`).
- Backend routes/services use kebab-case file names with domain suffixes (for example `backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
- Test files use `*.test.ts` or `*.test.tsx` in dedicated test folders (for example `backend/src/test/planner.test.ts`, `frontend/src/test/components/MobileEditModal.test.tsx`).
**Functions:**
- Use camelCase names for functions and methods (for example `parseIntakesWithUnits` in `backend/src/services/medications-service.ts`, `loadMeds` in `frontend/src/hooks/useMedications.ts`).
- Use verb-first names for side-effect operations (`loadMeds`, `deleteMed`, `uploadMedImage` in `frontend/src/hooks/useMedications.ts`).
**Variables:**
- Use camelCase for local variables and state (`refillHistoryExpanded`, `scheduleDays`, `showFutureDays` in `frontend/src/context/AppContext.tsx`).
- Constant maps and singleton keys use UPPER_SNAKE_CASE (`LOG_LEVELS` in `backend/src/utils/logger.ts`, `APP_CONTEXT_SINGLETON_KEY` in `frontend/src/context/AppContext.tsx`).
**Types:**
- Type aliases and interfaces use PascalCase (`AppContextValue` in `frontend/src/context/AppContext.tsx`, `TestContext` in `backend/src/test/setup.ts`).
- Return-shape interfaces use `UseXReturn` convention for hooks (`UseMedicationsReturn` in `frontend/src/hooks/useMedications.ts`).
## Code Style
**Formatting:**
- Tool used: Biome (`biome.json`, scripts in `frontend/package.json`, `backend/package.json`, `package.json`).
- Key settings from `biome.json`:
- `indentStyle: tab`
- `indentWidth: 2`
- `lineWidth: 120`
- JavaScript quote style is double quotes, semicolons enabled, trailing commas `es5`.
**Linting:**
- Tool used: Biome linter (`biome.json`).
- Key rules enforced/relevant:
- `style.useConst: error`
- `style.noNestedTernary: warn`
- `correctness.noUnusedVariables: warn`
- `suspicious.noExplicitAny: warn`
- Project governance in `AGENTS.md` reinforces readable code, early returns, and no nested ternaries.
## Import Organization
**Order:**
1. Node built-ins first in backend modules (for example `node:path` in `backend/src/routes/medications.ts`, `node:crypto` in `backend/src/index.ts`).
2. External packages second (`fastify`, `zod`, `drizzle-orm` in backend; `react`, `@testing-library/*` in frontend).
3. Internal modules last with relative paths (`../db/client.js`, `../../types`).
**Path Aliases:**
- Not detected in TypeScript configs (`frontend/tsconfig.json`, `backend/tsconfig.json` do not define `paths`).
- Relative imports are the standard.
## Error Handling
**Patterns:**
- Backend validates request data with Zod schemas and `.refine(...)` constraints before route logic (`backend/src/routes/medications.ts`).
- Backend route tests assert explicit status codes and body shape (`backend/src/test/routes-real.test.ts`, `backend/src/test/planner.test.ts`).
- Frontend hooks often normalize recoverable API errors into UI-safe states (`frontend/src/hooks/useMedications.ts` converts network failures into `NETWORK_ERROR`).
- Some frontend fetch flows still use tolerant fallbacks (`catch(() => setMeds([]))` in `frontend/src/hooks/useMedications.ts`), so future changes should prefer explicit user-facing error channels per `AGENTS.md` fail-clear guidance.
## Logging
**Framework:**
- Backend startup logger wrapper over console with level filtering in `backend/src/utils/logger.ts`.
- Runtime HTTP logging via Fastify logger options in `backend/src/index.ts` (`buildLoggerOptions`, request correlation IDs).
- Frontend logging utility mirrors backend level semantics (`frontend/src/utils/logger.ts`).
**Patterns:**
- Central log-level maps (`LOG_LEVELS`) and `shouldLog` gating are standard in both frontend and backend logger modules.
- Correlation ID propagation is enforced at request boundaries (`backend/src/index.ts` onRequest hook setting `x-correlation-id`).
## Comments
**When to Comment:**
- Comments are used for rationale and test setup intent, not line-by-line narration.
- Typical examples:
- Migration/setup intent in `backend/src/test/setup.ts`
- E2E stability rationale in `frontend/e2e/fixtures/index.ts`
- Timeout/determinism notes in `frontend/vitest.config.ts` and `frontend/playwright.base.config.ts`
**JSDoc/TSDoc:**
- Used selectively for exported utilities and test helpers (`backend/src/test/setup.ts`, `frontend/e2e/fixtures/index.ts`, `frontend/src/utils/logger.ts`).
- Not mandatory for every function; concise type annotations plus targeted comments are preferred.
## Function Design
**Size:**
- Small-to-medium focused functions are common in services/hooks (`parseRawIntakeUnits`, `normalizeDateTime` in `backend/src/services/medications-service.ts`).
- Larger orchestrator modules exist where domain aggregation is required (`frontend/src/context/AppContext.tsx`).
**Parameters:**
- Object parameters are used for extensibility in test factories and route payload shapes (`CreateMedicationOptions` in `backend/src/test/setup.ts`).
- Explicit primitive parameters used for concise helpers (`clickEditMed(page, medName)` in `frontend/e2e/medication-edit.spec.ts`).
**Return Values:**
- Explicit return types are common on exported functions (`Promise<TestContext>`, `UseMedicationsReturn`).
- Guard-clause returns are common for invalid input or unavailable state (`if (!intakesJson) return [];` in `backend/src/services/medications-service.ts`).
## Module Design
**Exports:**
- Named exports are preferred for utilities, hooks, and service functions (`backend/src/services/notifications/index.ts`, `frontend/src/hooks/index.ts`).
- Mixed export style is used where legacy/default exports remain practical (`default` exports in component barrel `frontend/src/components/index.ts`).
**Barrel Files:**
- Barrel files are actively used for stable import surfaces:
- `frontend/src/components/index.ts`
- `frontend/src/hooks/index.ts`
- `backend/src/services/notifications/index.ts`
- Practical rule for new code: export domain-level public APIs through local barrels, keep deep internal helpers imported directly.
---
*Convention analysis: 2026-04-30*
+111
View File
@@ -0,0 +1,111 @@
# External Integrations
**Analysis Date:** 2026-04-30
## APIs & External Services
**Medication Data APIs:**
- European Medicines Agency (EMA) JSON catalog - medication lookup seed and periodic catalog refresh
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`EMA_MEDICINES_URL`)
- Auth: none detected in code
- RxNorm (NLM RxNav REST) - normalized name/search enrichment and strength/form hints
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`RXNORM_BASE_URL`)
- Auth: none detected in code
- openFDA NDC API - product/package metadata enrichment
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`OPENFDA_NDC_URL`)
- Auth: none detected in code
**Authentication/Identity Provider Integration:**
- OIDC providers (Authelia, Authentik, Pocket ID, Keycloak documented) - SSO login/callback flow
- SDK/Client: `openid-client` used in `backend/src/routes/oidc.ts`
- Auth: `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` validated in `backend/src/plugins/env.ts`
**Messaging/Notifications:**
- SMTP providers - transactional reminder/test emails
- SDK/Client: `nodemailer` in `backend/src/services/notifications/delivery.ts`
- Auth: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` or `SMTP_TOKEN`, `SMTP_FROM`, `SMTP_SECURE`
- Push endpoints via Shoutrrr-compatible URL parsing
- SDK/Client: native `fetch` in `backend/src/routes/settings.ts` (`sendShoutrrrNotification`)
- Auth: URL-embedded creds/token per provider and optional basic auth extracted/sanitized in code
- Explicit external push provider endpoints used directly:
- `https://api.pushover.net/1/messages.json` in `backend/src/routes/settings.ts`
- `https://api.telegram.org` in `backend/src/routes/settings.ts`
## Data Storage
**Databases:**
- SQLite (file-based, local persistent volume)
- Connection: `DATA_DIR` (path resolution), optional `DOTENV_PATH` for env source
- Client: `@libsql/client` + `drizzle-orm` in `backend/src/db/client.ts`
- Migration pipeline:
- SQL migration artifacts in `backend/drizzle/*.sql`
- Runtime migration/alter execution in `backend/src/db/client.ts` and `backend/src/db/migration-utils.ts`
**File Storage:**
- Local filesystem only
- Backend data root resolved by `backend/src/db/path-utils.ts`
- Image/static user files served from `/images` in `backend/src/index.ts`
- Compose bind mount `./data:/app/data` in `docker-compose.yml`
**Caching:**
- In-process memory cache only for selected integration data
- OIDC discovery config cache in `backend/src/routes/oidc.ts` (`oidcConfig`)
- EMA catalog snapshot + refresh promise in `backend/src/services/medication-enrichment.ts`
- No external cache service detected (no Redis/Memcached dependency in package manifests)
## Authentication & Identity
**Auth Provider:**
- Custom session/JWT auth with optional OIDC SSO extension
- Implementation: Fastify cookie + JWT plugin, refresh token table, API key hashing in `backend/src/plugins/auth.ts`, `backend/src/routes/auth.ts`, `backend/src/plugins/jwt.ts`, `backend/src/routes/oidc.ts`
## Monitoring & Observability
**Error Tracking:**
- None detected for third-party SaaS error tracking (no Sentry/Rollbar/etc. dependencies)
**Logs:**
- Structured app logging via Fastify/Pino in `backend/src/index.ts`
- Pretty logging in dev through `pino-pretty` (`backend/package.json`, logger setup in `backend/src/index.ts`)
- Frontend/nginx log behavior controlled through env and `frontend/nginx-entrypoint.sh` (documented in `.env.example`)
## CI/CD & Deployment
**Hosting:**
- Container image publishing to GitHub Container Registry (`ghcr.io`) in `.github/workflows/docker-build.yml`
- Runtime deployment model is self-hosted Docker Compose stack (`docker-compose.yml`)
**CI Pipeline:**
- GitHub Actions for lint/type/test (`.github/workflows/test.yml`)
- Playwright E2E job (`.github/workflows/e2e.yml`)
- Docker build/push and optional release automation (`.github/workflows/docker-build.yml`)
## Environment Configuration
**Required env vars:**
- Core runtime: `PORT`, `CORS_ORIGINS`, `LOG_LEVEL`, `TZ` (`backend/src/plugins/env.ts`, `.env.example`)
- Auth when enabled: `AUTH_ENABLED=true` with `JWT_SECRET`, `REFRESH_SECRET`, `COOKIE_SECRET` (`backend/src/plugins/env.ts`)
- OIDC when enabled: `OIDC_ENABLED=true` with issuer/client/redirect vars (`backend/src/plugins/env.ts`)
- Email notifications: `SMTP_HOST`, `SMTP_USER`, plus pass/token and sender config (`backend/src/services/notifications/delivery.ts`, `.env.example`)
- Data location: `DATA_DIR` used by DB path resolver (`backend/src/db/path-utils.ts`)
**Secrets location:**
- Local runtime env file `.env` (present in repository root; values not inspected)
- CI secrets managed by GitHub Actions secret store (e.g., `${{ secrets.GITHUB_TOKEN }}` in `.github/workflows/docker-build.yml`)
## Webhooks & Callbacks
**Incoming:**
- OIDC callback endpoint: `/auth/oidc/callback` in `backend/src/routes/oidc.ts`
- No inbound third-party webhook receiver route detected in backend routes
**Outgoing:**
- Outbound HTTP notifications to webhook-style targets from `sendShoutrrrNotification` in `backend/src/routes/settings.ts`
- Provider-specific outgoing callbacks/APIs:
- Pushover API endpoint
- Telegram Bot API endpoint
- Outbound SMTP delivery through configured mail host (`backend/src/services/notifications/delivery.ts`)
---
*Integration audit: 2026-04-30*
+86
View File
@@ -0,0 +1,86 @@
# Technology Stack
**Analysis Date:** 2026-04-30
## Languages
**Primary:**
- TypeScript (ESM) - Backend and frontend application code in `backend/src/**/*.ts` and `frontend/src/**/*.{ts,tsx}`
- SQL (SQLite migrations) - Schema evolution files in `backend/drizzle/*.sql`
**Secondary:**
- CSS - UI styling in `frontend/src/**/*.css` and CSS modules such as `frontend/src/features/schedule/TimelineSurface.module.css`
- YAML - CI/CD and compose configuration in `.github/workflows/*.yml`, `docker-compose.yml`, `docker-compose.dev.yml`
- Shell - Container/runtime entrypoints in `backend/docker-entrypoint.sh`, `frontend/nginx-entrypoint.sh`
## Runtime
**Environment:**
- Node.js 22 runtime baseline (`node:22-slim` in `backend/Dockerfile`, `frontend/Dockerfile`; `actions/setup-node@v6` with `node-version: '22'` in `.github/workflows/test.yml` and `.github/workflows/e2e.yml`)
**Package Manager:**
- npm (scripts in root `package.json`, `backend/package.json`, `frontend/package.json`)
- Lockfile: present (`backend/package-lock.json`, `frontend/package-lock.json` referenced by workflow cache in `.github/workflows/test.yml`)
## Frameworks
**Core:**
- Fastify 5 (`fastify`, `@fastify/*` in `backend/package.json`; app bootstrap in `backend/src/index.ts`)
- React 19 (`react`, `react-dom` in `frontend/package.json`; app entry in `frontend/src/main.tsx`)
- Vite 8 (`vite` and `@vitejs/plugin-react` in `frontend/package.json`; config in `frontend/vite.config.ts`)
- Drizzle ORM + libSQL client (`drizzle-orm`, `@libsql/client` in `backend/package.json`; DB init in `backend/src/db/client.ts`)
- Mantine 8 UI system (`@mantine/*` in `frontend/package.json`; provider in `frontend/src/ui/providers/AppUiProvider.tsx`)
**Testing:**
- Vitest 4 (`vitest`, `@vitest/coverage-v8` in backend/frontend package manifests; configs in `backend/vitest.config.ts`, `frontend/vitest.config.ts`)
- Playwright (`@playwright/test` in `frontend/package.json`; configs in `frontend/playwright*.config.ts`; CI run in `.github/workflows/e2e.yml`)
- Testing Library (`@testing-library/*` in `frontend/package.json`)
**Build/Dev:**
- TypeScript compiler (`tsc` scripts in `backend/package.json` and frontend type-check via `frontend/package.json`)
- TSX watcher for backend dev (`tsx watch src/index.ts` in `backend/package.json`)
- Biome for lint/format (`biome.json`, lint/check scripts across package manifests)
- Drizzle Kit for DB migration generation (`drizzle-kit` in `backend/package.json`, config in `backend/drizzle.config.ts`)
## Key Dependencies
**Critical:**
- `fastify` and `@fastify/*` - HTTP API runtime, security middleware, docs middleware (`backend/src/index.ts`)
- `drizzle-orm` + `@libsql/client` - SQLite data access and migration execution (`backend/src/db/client.ts`)
- `openid-client` + `jose` - OIDC SSO and token operations (`backend/src/routes/oidc.ts`, `backend/package.json`)
- `nodemailer` - SMTP notification delivery (`backend/src/services/notifications/delivery.ts`)
- `react`, `react-router-dom`, `@mantine/*` - SPA UI shell, routing, and component system (`frontend/src/main.tsx`, `frontend/src/App.tsx`)
- `i18next` + `react-i18next` - Localization runtime (`frontend/src/i18n/index.ts`)
**Infrastructure:**
- `dotenv` + `zod` - env loading/validation (`backend/src/plugins/env.ts`)
- `sharp` - image processing pipeline support (`backend/package.json`, image route usage in medication flows)
- `@fastify/swagger` + `@fastify/swagger-ui` - OpenAPI docs on `/docs` (`backend/src/index.ts`)
## Configuration
**Environment:**
- Runtime env schema and validation in `backend/src/plugins/env.ts`
- Example variable inventory in `.env.example`
- Frontend proxy target via `BACKEND_URL` in `frontend/vite.config.ts` and compose files
**Build:**
- Backend TS build config: `backend/tsconfig.json`
- Frontend TS + Vite config: `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, `frontend/vite.config.ts`
- DB migration tooling config: `backend/drizzle.config.ts`
- Quality tooling config: `biome.json`
## Platform Requirements
**Development:**
- Node.js 22 with npm for local runs (`backend/package.json`, `frontend/package.json` scripts)
- Optional Docker Compose local stack (`docker-compose.dev.yml`)
- Browser runtime for frontend and Playwright browser binaries for E2E (`frontend/package.json`, `.github/workflows/e2e.yml`)
**Production:**
- Containerized deployment using prebuilt images from GHCR (`docker-compose.yml` references `ghcr.io/danielvolz/medassist-ng-backend:latest` and `ghcr.io/danielvolz/medassist-ng-frontend:latest`)
- Backend persistent filesystem for SQLite/data in mounted `./data` (`docker-compose.yml`, DB path resolver in `backend/src/db/path-utils.ts`)
---
*Stack analysis: 2026-04-30*
+138
View File
@@ -0,0 +1,138 @@
# Codebase Structure
**Analysis Date:** 2026-04-30
## Directory Layout
```
medassist/
├── frontend/ # React + Vite SPA, UI, hooks, page routes, frontend tests
├── backend/ # Fastify API, domain services, DB schema/migrations, backend tests
├── backend/drizzle/ # SQL migration files + drizzle meta journal
├── docs/ # Product/ops docs and screenshots
├── doku/ # Local-only working notes and reports (ignored)
├── .github/ # CI workflows, agents, local skill/runtime metadata
├── .planning/codebase/ # Generated codebase mapping documents
├── data/ # Runtime/local SQLite backups and scheduler files
└── package.json # Root workspace scripts for lint orchestration
```
## Directory Purposes
**frontend/src:**
- Purpose: Product UI and client-side app logic.
- Contains: `pages/`, `components/`, `context/`, `hooks/`, `ui/`, `utils/`, `i18n/`, `test/`.
- Key files: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`.
**backend/src:**
- Purpose: HTTP API, auth, domain services, and persistence access.
- Contains: `routes/`, `services/`, `plugins/`, `db/`, `utils/`, `test/`.
- Key files: `backend/src/index.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/db/client.ts`.
**backend/drizzle:**
- Purpose: SQL migration history for SQLite compatibility.
- Contains: numbered migration files and `meta/_journal.json`.
- Key files: `backend/drizzle/0000_init.sql`, `backend/drizzle/0014_add_user_settings_timezone.sql`.
**frontend/e2e:**
- Purpose: Playwright end-to-end scenarios and fixtures.
- Contains: browser tests + auth fixtures.
- Key files: `frontend/e2e/fixtures/` and spec files under `frontend/e2e/`.
**docs + doku:**
- Purpose: formal docs (`docs/`) and local-only work tracking (`doku/`).
- Contains: behavior/spec docs, screenshots, local report/memory logs.
- Key files: `docs/TECH_STACK.md`, `doku/memory_notes.md`, `doku/report.md`.
## Key File Locations
**Entry Points:**
- `frontend/src/main.tsx`: Browser bootstrap; mounts providers and router.
- `frontend/src/App.tsx`: Route graph and global modal/shell orchestration.
- `backend/src/index.ts`: Fastify app setup + startup runtime.
**Configuration:**
- `frontend/vite.config.ts`: Dev server, `/api` proxy rewrite, build-time constants.
- `frontend/vitest.config.ts`: Frontend unit test config.
- `backend/vitest.config.ts`: Backend unit/integration test config.
- `backend/drizzle.config.ts`: Drizzle migration configuration.
- `.gitignore`: Local-only/generated path policy (including `.planning/`, `doku/`, `data/`, coverage/test artifacts).
**Core Logic:**
- `backend/src/routes/`: API contracts and request handlers.
- `backend/src/services/`: Scheduler, notifications, medication helpers.
- `backend/src/db/schema.ts`: Source-of-truth table definitions.
- `frontend/src/context/`: Shared app orchestration state.
- `frontend/src/pages/`: Screen-level composition.
**Testing:**
- `frontend/src/test/`: Frontend unit/component tests.
- `frontend/e2e/`: Playwright E2E tests.
- `backend/src/test/`: Backend route/service/db tests.
## Naming Conventions
**Files:**
- React components/pages use PascalCase: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MedDetailModal.tsx`.
- Hooks use `use*` naming: `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useSettings.ts`.
- Backend routes/services use kebab-case: `backend/src/routes/medication-enrichment.ts`, `backend/src/services/reminder-scheduler.ts`.
- Migrations use numbered descriptive names: `backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql`.
**Directories:**
- Feature/layer folders are lowercase: `frontend/src/context`, `backend/src/services`.
- Test directories stay colocated by runtime side (`frontend/src/test`, `backend/src/test`).
## Where to Add New Code
**New Feature:**
- Primary code:
- Frontend UI route/screen: `frontend/src/pages/` (compose from existing `components/`, `hooks/`, `ui/`).
- Backend endpoint: `backend/src/routes/` + matching domain logic in `backend/src/services/`.
- Persistence additions: `backend/src/db/schema.ts` plus migration updates in `backend/src/db/client.ts` and `backend/drizzle/`.
- Tests:
- Frontend unit/component: `frontend/src/test/`.
- Backend unit/integration: `backend/src/test/`.
- E2E flow: `frontend/e2e/`.
**New Component/Module:**
- Implementation:
- Shared UI primitive/layout: `frontend/src/ui/`.
- Domain-specific UI component: `frontend/src/components/` (or nested feature folder).
- Backend reusable domain behavior: `backend/src/services/`.
**Utilities:**
- Shared helpers:
- Frontend: `frontend/src/utils/`.
- Backend: `backend/src/utils/`.
- DB-specific helpers: `backend/src/db/` focused utility modules.
## Special Directories
**frontend/dist, backend/dist:**
- Purpose: build output artifacts.
- Generated: Yes.
- Committed: No (`dist/` ignored in `.gitignore`).
**frontend/playwright-report, frontend/test-results, frontend/coverage, backend/coverage:**
- Purpose: test artifacts/reports.
- Generated: Yes.
- Committed: No (ignored in `.gitignore`).
**data/:**
- Purpose: runtime/local DB, reminder state, scheduler locks.
- Generated: Yes.
- Committed: No (`data/` ignored in `.gitignore`).
**doku/:**
- Purpose: local work memory/reporting and internal notes.
- Generated: Mixed (manual local notes + artifacts).
- Committed: No (`doku/` ignored in `.gitignore`).
**.planning/codebase/:**
- Purpose: generated architecture/stack/convention/concern maps for GSD planning/execution.
- Generated: Yes.
- Committed: No (`.planning/` ignored by policy in this workspace).
---
*Structure analysis: 2026-04-30*
+203
View File
@@ -0,0 +1,203 @@
# Testing Patterns
**Analysis Date:** 2026-04-30
## Test Framework
**Runner:**
- Vitest 4.x for unit/integration tests in both packages:
- Frontend config: `frontend/vitest.config.ts`
- Backend config: `backend/vitest.config.ts`
- Config evidence:
- Frontend uses `environment: 'jsdom'` with React setup file `frontend/src/test/setup.ts`.
- Backend uses `environment: 'node'` with setup file `backend/src/test/setup.ts`.
**Assertion Library:**
- Vitest `expect`.
- Frontend extends DOM assertions via `@testing-library/jest-dom` in `frontend/src/test/setup.ts`.
**Run Commands:**
```bash
cd frontend && npm test # Watch/unit tests
cd frontend && npm run test:run # CI-style frontend run
cd frontend && npm run test:coverage # Frontend coverage
cd backend && npm test # Watch/unit tests
cd backend && npm run test:run # CI-style backend run
cd backend && npm run test:coverage # Backend coverage
cd frontend && npm run test:e2e # Stable Playwright suite
cd frontend && npm run test:e2e:all # Cross-browser Playwright suite
```
## Test File Organization
**Location:**
- Backend unit/integration tests are in `backend/src/test/*.test.ts`.
- Frontend unit/component/hook/context tests are in `frontend/src/test/**`.
- Browser E2E tests are in `frontend/e2e/*.spec.ts`.
**Naming:**
- Unit/integration: `*.test.ts` or `*.test.tsx` (for example `backend/src/test/routes-real.test.ts`, `frontend/src/test/components/MedicationDialogs.test.tsx`).
- E2E: `*.spec.ts` (for example `frontend/e2e/medication-edit.spec.ts`).
**Structure:**
```
backend/src/test/
setup.ts
*.test.ts
frontend/src/test/
setup.ts
App.test.tsx
components/*.test.tsx
context/*.test.tsx
hooks/*.test.ts
pages/*.test.tsx
utils/*.test.ts
frontend/e2e/
auth.setup.ts
fixtures/index.ts
*.spec.ts
```
## Test Structure
**Suite Organization:**
```typescript
describe("Feature Area", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("handles expected behavior", async () => {
// arrange
// act
// assert
expect(result).toEqual(expected);
});
});
```
Pattern evidence: `frontend/src/test/components/MobileEditModal.test.tsx`, `backend/src/test/planner.test.ts`.
**Patterns:**
- Setup pattern:
- Frontend centralizes browser mocks in `frontend/src/test/setup.ts` (fetch, localStorage, clipboard, history, i18n).
- Backend provides reusable app/database factories in `backend/src/test/setup.ts` (`buildTestApp`, `createTestUser`, `createTestMedication`).
- Teardown pattern:
- `afterAll` closes Fastify app and DB clients (`backend/src/test/planner.test.ts`, `backend/src/test/integration.test.ts`).
- Assertion pattern:
- Route tests assert both HTTP status and response body (`backend/src/test/routes-real.test.ts`).
- UI tests assert presence and behavior via Testing Library role/test-id queries (`frontend/src/test/components/MedicationDialogs.test.tsx`).
## Mocking
**Framework:**
- Vitest mocks (`vi.mock`, `vi.fn`, `vi.hoisted`, `vi.stubGlobal`).
**Patterns:**
```typescript
const { testClient, testDb } = vi.hoisted(() => {
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return { testClient: client, testDb: db };
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
```
Pattern evidence: `backend/src/test/integration.test.ts`, `backend/src/test/routes-real.test.ts`.
```typescript
vi.mock("../../components/ConfirmModal", () => ({
ConfirmModal: ({ onConfirm }) => <button onClick={onConfirm}>confirm</button>,
}));
```
Pattern evidence: `frontend/src/test/components/MedicationDialogs.test.tsx`.
**What to Mock:**
- External side effects and infrastructure boundaries: SMTP/nodemailer, fetch network calls, auth/plugin env modules, browser APIs.
- Component dependencies in focused unit tests (replace heavy children with stubs).
**What NOT to Mock:**
- Core business behavior under direct test (route handlers in route tests, hook logic in hook tests, E2E API + UI flow in Playwright).
## Fixtures and Factories
**Test Data:**
```typescript
const userId = await createTestUser(client, { username: "testuser" });
const medId = await createTestMedication(client, { userId, name: "Test Medication" });
```
Pattern evidence: `backend/src/test/setup.ts`, used by `backend/src/test/medications.test.ts`.
```typescript
export const test = base.extend({
page: async ({ page }, use) => {
await applyVideoSafetyMode(page);
await setupAuthMeMock(page);
await use(page);
},
});
```
Pattern evidence: `frontend/e2e/fixtures/index.ts`.
**Location:**
- Backend factories/utilities: `backend/src/test/setup.ts`.
- Frontend E2E shared fixtures and API helpers: `frontend/e2e/fixtures/index.ts`.
## Coverage
**Requirements:**
- Frontend global thresholds in `frontend/vitest.config.ts`: lines/functions/branches/statements = 75.
- Backend global thresholds in `backend/vitest.config.ts`: lines 60, functions 65, branches 50, statements 60.
**View Coverage:**
```bash
cd frontend && npm run test:coverage
cd backend && npm run test:coverage
```
## Test Types
**Unit Tests:**
- Component/hook/utils tests in `frontend/src/test/**`.
- Utility/service route-unit style tests in `backend/src/test/*.test.ts`.
**Integration Tests:**
- Backend route interaction and multi-route behavior tests in files like:
- `backend/src/test/integration.test.ts`
- `backend/src/test/routes-real.test.ts`
**E2E Tests:**
- Playwright used with setup project and browser projects (`frontend/playwright.base.config.ts`).
- Auth/session and API seeding helpers in `frontend/e2e/fixtures/index.ts`.
## Common Patterns
**Async Testing:**
```typescript
await waitFor(() => {
expect(mockFn).toHaveBeenCalledTimes(1);
});
```
Pattern evidence: `frontend/src/test/context/AppContext.test.tsx`.
```typescript
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(200);
```
Pattern evidence: `backend/src/test/routes-real.test.ts`.
**Error Testing:**
```typescript
const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { rows: [] } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing planner data" });
```
Pattern evidence: `backend/src/test/planner.test.ts`.
---
*Testing analysis: 2026-04-30*
+13 -3
View File
@@ -18,8 +18,8 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-639%2F639-brightgreen?logo=vitest" alt="Backend Tests 454/454" /> <img src="https://img.shields.io/badge/Backend_Tests-692%2F692-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" /> <img src="https://img.shields.io/badge/Frontend_Tests-911%2F911-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p> </p>
### 🤖 AI-Generated Code ### 🤖 AI-Generated Code
@@ -203,7 +203,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS | | `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. | | `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. | | `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 | | `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
Recommended values for API docs by environment: Recommended values for API docs by environment:
@@ -305,6 +305,8 @@ API reference:
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder | | `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning | | `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
### Push Notifications (Shoutrrr) ### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format. MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
@@ -376,6 +378,14 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
- API docs UI: `http://localhost:3000/docs` (when docs are enabled) - API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled) - OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
Useful local commands: Useful local commands:
```bash ```bash
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1773348659979, "when": 1773348659979,
"tag": "0013_add_share_medication_overview", "tag": "0013_add_share_medication_overview",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
} }
] ]
} }
+290 -405
View File
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.22.2", "version": "1.24.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -19,36 +19,37 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/formbody": "^8.0.2",
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4", "@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0", "@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5", "@fastify/swagger-ui": "^5.2.6",
"@libsql/client": "^0.17.2", "@libsql/client": "^0.17.3",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"dotenv": "^17.4.1", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"fastify": "^5.8.4", "fastify": "^5.8.5",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"jose": "^6.2.2", "jose": "^6.2.3",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.7",
"openid-client": "^6.8.2", "openid-client": "^6.8.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"zod": "^3.23.8" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.15",
"@types/node": "^25.5.2", "@types/node": "^25.6.2",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^4.1.5",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"overrides": { "overrides": {
+43
View File
@@ -33,6 +33,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`, `ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`, `ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`, `ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
@@ -58,6 +59,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
@@ -95,6 +97,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0, loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now')) refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`, )`,
`CREATE TABLE IF NOT EXISTS notification_action_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_key TEXT NOT NULL UNIQUE,
sequence_id TEXT NOT NULL,
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
dose_ids_json TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'en',
scheduled_for INTEGER,
expires_at INTEGER NOT NULL,
resolved_action TEXT,
resolved_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS api_keys ( `CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -120,9 +147,25 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
} }
} }
const postCreateAlterMigrations = [
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
];
for (const sql of postCreateAlterMigrations) {
try {
await client.execute(sql);
} catch (e: unknown) {
if (!(e as Error).message?.includes("duplicate column")) {
errors.push((e as Error).message);
}
}
}
const createIndexMigrations = [ const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`, `CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`, `CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
]; ];
+1
View File
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90, expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
timezone text NOT NULL DEFAULT '',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1, share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0, upcoming_today_only integer NOT NULL DEFAULT 0,
+43 -4
View File
@@ -105,10 +105,12 @@ export const userSettings = sqliteTable("user_settings", {
expiryWarningDays: integer("expiry_warning_days").notNull().default(90), expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences // UI preferences
language: text("language", { length: 10 }).notNull().default("en"), language: text("language", { length: 10 }).notNull().default("en"),
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users // Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), // Current MedAssist versions no longer read or expose this setting in product flows.
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Whether shared schedule links also embed the medication overview section // Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false), shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences // UI timeline visibility preferences
@@ -182,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
}); });
// =============================================================================
// Notification Action Groups - Shared action state for reminder notifications
// =============================================================================
export const notificationActionGroups = sqliteTable("notification_action_groups", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
groupKey: text("group_key", { length: 255 }).notNull().unique(),
sequenceId: text("sequence_id", { length: 255 }).notNull(),
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
doseIdsJson: text("dose_ids_json").notNull(),
title: text("title", { length: 255 }).notNull(),
message: text("message").notNull(),
language: text("language", { length: 10 }).notNull().default("en"),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
resolvedAction: text("resolved_action", { length: 20 }),
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Notification Action Tokens - Hashed tokens for public reminder responses
// =============================================================================
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
groupId: integer("group_id")
.notNull()
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
kind: text("kind", { length: 20 }).notNull(),
usedAt: integer("used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// ============================================================================= // =============================================================================
// Dose Tracking - Tracks when doses are marked as taken // Dose Tracking - Tracks when doses are marked as taken
// ============================================================================= // =============================================================================
@@ -193,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000" doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`), takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
}); });
// ============================================================================= // =============================================================================
+6
View File
@@ -109,6 +109,8 @@ type TranslationKeys = {
stockTitle: string; stockTitle: string;
stockTitleMultiple: string; stockTitleMultiple: string;
intakeTitle: string; intakeTitle: string;
intakeTakenConfirmation: string;
intakeSkippedConfirmation: string;
pillsLeft: string; pillsLeft: string;
daysLeft: string; daysLeft: string;
pillsAt: string; pillsAt: string;
@@ -234,6 +236,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low", stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low", stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min", intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
intakeTakenConfirmation: "✅ This dose was marked as taken.",
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
pillsLeft: "{count} pills", pillsLeft: "{count} pills",
daysLeft: "{count} days left", daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}", pillsAt: "{count} pills at {time}",
@@ -355,6 +359,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig", stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig", stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.", intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
pillsLeft: "{count} Tabletten", pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig", daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}", pillsAt: "{count} Tabletten um {time}",
+59 -4
View File
@@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js"; import { healthRoutes } from "./routes/health.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js"; import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js"; import { medicationRoutes } from "./routes/medications.js";
import { notificationActionRoutes } from "./routes/notification-actions.js";
import { oidcRoutes } from "./routes/oidc.js"; import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js"; import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js"; import { refillRoutes } from "./routes/refills.js";
@@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) {
return base; return base;
} }
function buildHelmetOptions(_isProduction: boolean) {
return {};
}
function isPublicNotificationActionPath(url: string | undefined): boolean {
if (!url) {
return false;
}
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) { async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return; if (!enabled) return;
@@ -166,6 +180,7 @@ export async function createApp(options?: {
app.addHook("onRequest", (request, reply, done) => { app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id; request.correlationId = request.id;
reply.header("x-correlation-id", request.id); reply.header("x-correlation-id", request.id);
done(); done();
}); });
@@ -182,8 +197,26 @@ export async function createApp(options?: {
// Register plugins // Register plugins
await app.register(sensible); await app.register(sensible);
await app.register(helmet); await app.register(helmet, buildHelmetOptions(opts.isProduction));
await app.register(cors, { origin: opts.corsOrigins, credentials: true }); await app.register(cors, {
hook: "preHandler",
delegator: (request, callback) => {
if (isPublicNotificationActionPath(request.raw.url)) {
callback(null, {
origin: true,
credentials: false,
methods: ["GET", "HEAD", "POST", "OPTIONS"],
preflightContinue: true,
});
return;
}
callback(null, {
origin: opts.corsOrigins,
credentials: true,
});
},
});
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" }); await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret }); await app.register(cookie, { secret: opts.cookieSecret });
@@ -212,6 +245,7 @@ export async function createApp(options?: {
await app.register(medicationEnrichmentRoutes); await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(plannerRoutes); await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes); await app.register(shareRoutes);
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes); await app.register(exportRoutes);
@@ -266,8 +300,26 @@ app.decorate("config", {
}); });
await app.register(sensible); await app.register(sensible);
await app.register(helmet); await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
await app.register(cors, { origin: origins, credentials: true }); await app.register(cors, {
hook: "preHandler",
delegator: (request, callback) => {
if (isPublicNotificationActionPath(request.raw.url)) {
callback(null, {
origin: true,
credentials: false,
methods: ["GET", "HEAD", "POST", "OPTIONS"],
preflightContinue: true,
});
return;
}
callback(null, {
origin: origins,
credentials: true,
});
},
});
await app.register(rateLimit, { await app.register(rateLimit, {
max: Number(process.env.RATE_LIMIT_MAX) || 100, max: Number(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: "1 minute", timeWindow: "1 minute",
@@ -294,6 +346,7 @@ await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes); await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(plannerRoutes); await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes); await app.register(shareRoutes);
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes); await app.register(exportRoutes);
@@ -309,6 +362,7 @@ const start = async () => {
startReminderScheduler({ startReminderScheduler({
info: (msg) => app.log.info(msg), info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg), debug: (msg) => app.log.debug(msg),
warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg), error: (msg) => app.log.error(msg),
}); });
@@ -323,6 +377,7 @@ const start = async () => {
startIntakeReminderScheduler({ startIntakeReminderScheduler({
info: (msg) => app.log.info(msg), info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg), debug: (msg) => app.log.debug(msg),
warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg), error: (msg) => app.log.error(msg),
}); });
} catch (err) { } catch (err) {
+17 -16
View File
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"), NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z PORT: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("3000")
.default("3000"), .transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"), LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
OPENAPI_DOCS_ENABLED: z OPENAPI_DOCS_ENABLED: z
.string() .string()
.transform((v) => v === "true") .transform((v) => v === "true")
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
// Master switch: Enable/disable authentication (default: disabled for easy setup) // Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z AUTH_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
// Allow new user registrations (auto-enabled if no users exist) // Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z REGISTRATION_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
// Disable username/password form login (useful for OIDC-only setups) // Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z FORM_LOGIN_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("true")
.default("true"), .transform((v) => v === "true"),
// JWT Secrets - only required when AUTH_ENABLED=true // JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(), JWT_SECRET: z.string().min(10).optional(),
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
// Token TTL settings // Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z ACCESS_TOKEN_TTL_MINUTES: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("15")
.default("15"), .transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z REFRESH_TOKEN_TTL_DAYS: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("7")
.default("7"), .transform((v) => parseInt(v, 10)),
// ========================================================================== // ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.) // OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ========================================================================== // ==========================================================================
OIDC_ENABLED: z OIDC_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(),
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"), OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z OIDC_AUTO_CREATE_USERS: z
.string() .string()
.transform((v) => v === "true") .default("true")
.default("true"), .transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub' OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
}); });
+2 -2
View File
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = registerSchema.safeParse(request.body); const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
}); });
} }
@@ -616,7 +616,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = updateProfileSchema.safeParse(request.body); const parsed = updateProfileSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
}); });
} }
+27 -31
View File
@@ -6,6 +6,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js"; import { computeMedicationCurrentStock } from "../services/current-stock.js";
import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { import {
applyOpenApiRouteStandards, applyOpenApiRouteStandards,
@@ -61,6 +62,15 @@ const doseReadResponseSchema = {
}, },
} as const; } as const;
function getValidationErrorMessage(error: z.ZodError): string {
const firstIssue = error.issues[0];
if (!firstIssue) {
return "Invalid input";
}
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
}
// Helper to get user ID from request // Helper to get user ID from request
// Returns anonymous user ID when auth is disabled // Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> { async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -301,40 +311,28 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = markDoseSchema.safeParse(request.body); const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: getValidationErrorMessage(parsed.error),
}); });
} }
const { doseId } = parsed.data; const { doseId } = parsed.data;
// Check if already marked const result = await markDoseTakenForUser({
const [existing] = await db userId,
.select() doseId,
.from(doseTracking) source: "manual",
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); markedBy: null,
});
if (existing) { if (!result.success) {
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
return reply.status(statusCode).send({ error: result.message, code: result.code });
}
if (result.status === "already_taken") {
return { success: true, message: "Already marked" }; 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,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
return { success: true }; return { success: true };
} }
); );
@@ -423,23 +421,22 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = dismissDosesSchema.safeParse(request.body); const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: getValidationErrorMessage(parsed.error),
}); });
} }
const { doseIds } = parsed.data; const { doseIds } = parsed.data;
// Insert dismissed records for each dose that doesn't exist yet // Preserve the existing route semantics for dismiss: any non-dismissed record
// becomes dismissed, regardless of whether it already has a taken timestamp.
let dismissedCount = 0; let dismissedCount = 0;
for (const doseId of doseIds) { for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db const [existing] = await db
.select() .select()
.from(doseTracking) .from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId))); .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) { if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) { if (!existing.dismissed) {
await db await db
.update(doseTracking) .update(doseTracking)
@@ -448,7 +445,6 @@ export async function doseRoutes(app: FastifyInstance) {
dismissedCount++; dismissedCount++;
} }
} else { } else {
// Create new dismissed record
await db.insert(doseTracking).values({ await db.insert(doseTracking).values({
userId, userId,
doseId, doseId,
@@ -590,7 +586,7 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = shareDoseSchema.safeParse(request.body); const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: getValidationErrorMessage(parsed.error),
}); });
} }
+55 -38
View File
@@ -23,7 +23,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// ============================================================================= // =============================================================================
// Export Format Version (bump this when format changes) // Export Format Version (bump this when format changes)
// ============================================================================= // =============================================================================
const EXPORT_VERSION = "1.4"; const EXPORT_VERSION = "1.5";
// ============================================================================= // =============================================================================
// Zod Schemas for Import Validation // Zod Schemas for Import Validation
@@ -96,7 +96,8 @@ const doseHistorySchema = z.object({
const refillHistoryExportSchema = z.object({ const refillHistoryExportSchema = z.object({
medicationRef: z.string(), // References _exportId medicationRef: z.string(), // References _exportId
packsAdded: z.number().int().min(0).default(0), packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0), loosePillsAdded: z.number().int().min(0).optional(),
quantityAdded: z.number().int().min(0).optional(),
usedPrescription: z.boolean().default(false), usedPrescription: z.boolean().default(false),
refillDate: z.string(), // ISO datetime refillDate: z.string(), // ISO datetime
}); });
@@ -108,37 +109,44 @@ const shareLinkSchema = z.object({
regenerateToken: z.boolean().default(true), regenerateToken: z.boolean().default(true),
}); });
const settingsExportSchema = z const settingsSchemaBase = z.object({
.object({ // Email notifications
// Email notifications emailEnabled: z.boolean().default(false),
emailEnabled: z.boolean().default(false), notificationEmail: z.string().nullable().optional(),
notificationEmail: z.string().nullable().optional(), emailStockReminders: z.boolean().default(true),
emailStockReminders: z.boolean().default(true), emailIntakeReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true), emailPrescriptionReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true), // Push notifications
// Push notifications shoutrrrEnabled: z.boolean().optional(),
shoutrrrEnabled: z.boolean().optional(), shoutrrrUrl: z.string().nullable().optional(),
shoutrrrUrl: z.string().nullable().optional(), shoutrrrStockReminders: z.boolean().default(true),
shoutrrrStockReminders: z.boolean().default(true), shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true), shoutrrrPrescriptionReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true), // Reminder settings
// Reminder settings reminderDaysBefore: z.number().int().default(7),
reminderDaysBefore: z.number().int().default(7), repeatDailyReminders: z.boolean().default(false),
repeatDailyReminders: z.boolean().default(false), skipRemindersForTakenDoses: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false), repeatRemindersEnabled: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false), reminderRepeatIntervalMinutes: z.number().int().default(30),
reminderRepeatIntervalMinutes: z.number().int().default(30), maxNaggingReminders: z.number().int().default(5),
maxNaggingReminders: z.number().int().default(5), // Stock thresholds
// Stock thresholds lowStockDays: z.number().int().default(30),
lowStockDays: z.number().int().default(30), normalStockDays: z.number().int().default(90),
normalStockDays: z.number().int().default(90), highStockDays: z.number().int().default(180),
highStockDays: z.number().int().default(180), expiryWarningDays: z.number().int().default(90),
expiryWarningDays: z.number().int().default(90), // UI preferences
// UI preferences language: z.string().default("en"),
language: z.string().default("en"), stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), shareMedicationOverview: z.boolean().default(false),
shareStockStatus: z.boolean().default(true), });
shareMedicationOverview: z.boolean().default(false),
const exportSettingsSchema = settingsSchemaBase.optional();
const importSettingsSchema = settingsSchemaBase
.extend({
// Accept the removed field from legacy exports so old backups still import,
// but do not map it back into current runtime settings.
shareStockStatus: z.boolean().optional(),
}) })
.optional(); .optional();
@@ -149,7 +157,7 @@ const importDataSchema = z.object({
medications: z.array(medicationExportSchema).default([]), medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]), doseHistory: z.array(doseHistorySchema).default([]),
refillHistory: z.array(refillHistoryExportSchema).default([]), refillHistory: z.array(refillHistoryExportSchema).default([]),
settings: settingsExportSchema, settings: importSettingsSchema,
shareLinks: z.array(shareLinkSchema).default([]), shareLinks: z.array(shareLinkSchema).default([]),
}); });
@@ -210,7 +218,7 @@ const importBodyOpenApiSchema = {
}, },
], ],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }], doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }], refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" }, settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }], shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
}, },
@@ -370,6 +378,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 1. Load all medications // 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const medicationById = new Map(meds.map((med) => [med.id, med]));
// Build medication ID to export ID mapping // Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>(); const medIdToExportId = new Map<number, string>();
@@ -509,7 +518,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: settings.expiryWarningDays, expiryWarningDays: settings.expiryWarningDays,
language: settings.language, language: settings.language,
stockCalculationMode: settings.stockCalculationMode, stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false, shareMedicationOverview: settings.shareMedicationOverview ?? false,
} }
: undefined; : undefined;
@@ -548,6 +556,13 @@ export async function exportRoutes(app: FastifyInstance) {
.map((refill) => { .map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId); const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip if (!exportId) return null; // Orphaned refill, skip
const medication = medicationById.get(refill.medicationId);
const packageType = normalizePackageType(medication?.packageType);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const quantityAdded =
packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"
? (refill.loosePillsAdded ?? 0)
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
// Safely convert refillDate to ISO string // Safely convert refillDate to ISO string
let refillDateIso: string; let refillDateIso: string;
@@ -568,6 +583,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationRef: exportId, medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0, packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0, loosePillsAdded: refill.loosePillsAdded ?? 0,
quantityAdded,
usedPrescription: refill.usedPrescription ?? false, usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso, refillDate: refillDateIso,
}; };
@@ -778,6 +794,8 @@ export async function exportRoutes(app: FastifyInstance) {
// 5. Import settings // 5. Import settings
if (importData.settings) { if (importData.settings) {
// Legacy exports may still contain shareStockStatus. The current app no longer
// uses that setting, so imports accept it for compatibility and then ignore it.
await db.insert(userSettings).values({ await db.insert(userSettings).values({
userId, userId,
emailEnabled: importData.settings.emailEnabled ?? false, emailEnabled: importData.settings.emailEnabled ?? false,
@@ -802,7 +820,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: importData.settings.expiryWarningDays ?? 90, expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en", language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false, shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
}); });
} }
@@ -830,7 +847,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationId: newMedId, medicationId: newMedId,
userId, userId,
packsAdded: refill.packsAdded ?? 0, packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0, loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false, usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate), refillDate: new Date(refill.refillDate),
}); });
+7 -4
View File
@@ -1203,15 +1203,18 @@ export async function medicationRoutes(app: FastifyInstance) {
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
const allowsBottleCapacityUpdate = packageType === "bottle"; const allowsBottleCapacityUpdate = packageType === "bottle";
if (allowsAmountBaseUpdate) { if (allowsAmountBaseUpdate) {
if (totalPills !== undefined) updateFields.totalPills = totalPills; const normalizedAmountBase = looseTablets ?? totalPills;
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets; if (normalizedAmountBase !== undefined) {
updateFields.totalPills = normalizedAmountBase;
updateFields.looseTablets = normalizedAmountBase;
}
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
} }
if (allowsBottleCapacityUpdate && totalPills !== undefined) { if (allowsBottleCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills; updateFields.totalPills = totalPills;
} }
if (packCount !== undefined) updateFields.packCount = packCount; if (packCount !== undefined) updateFields.packCount = packCount;
if (looseTablets !== undefined) { if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
updateFields.looseTablets = looseTablets; updateFields.looseTablets = looseTablets;
} }
@@ -1654,7 +1657,7 @@ export async function medicationRoutes(app: FastifyInstance) {
async (req, reply) => { async (req, reply) => {
const parsed = dismissUntilSchema.safeParse(req.body); const parsed = dismissUntilSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" }); return reply.status(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
} }
const userId = await getUserId(req, reply); const userId = await getUserId(req, reply);
+642
View File
@@ -0,0 +1,642 @@
import formbody from "@fastify/formbody";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
import { getTranslations, type Language } from "../i18n/translations.js";
import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
import {
getNotificationActionTokenRecord,
isNotificationActionExpired,
} from "../services/notification-actions-service.js";
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
import { sendPushNotification } from "../services/notifications/delivery.js";
import { sanitizeNotificationUrl } from "../services/settings-service.js";
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
const querySchema = z.object({
action: z.enum(["taken", "skip", "dismiss"]).optional(),
});
type NotificationMutationAction = "taken" | "skip";
function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
if (action === "taken") {
return "taken";
}
if (action === "skip" || action === "dismiss") {
return "skip";
}
return null;
}
const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
const reminderFooterSeparator = "\n\n---\n";
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function toHtmlText(value: string): string {
return escapeHtml(value).replaceAll("\n", "<br />");
}
function getLanguage(language: string | null): Language {
return language === "de" ? "de" : "en";
}
function wantsHtml(request: FastifyRequest): boolean {
return request.headers.accept?.includes("text/html") ?? false;
}
function applyPublicNotificationCorsHeaders(
request: FastifyRequest,
reply: { header: (name: string, value: string) => unknown }
) {
const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
reply.header("access-control-allow-origin", requestOrigin);
reply.header("access-control-allow-methods", publicNotificationActionMethods);
reply.header("access-control-allow-headers", "content-type");
if (requestOrigin !== "*") {
reply.header("vary", "Origin");
}
}
function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
if (resolvedAction === "taken") {
return {
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
bodyText:
language === "de"
? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
: "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
jsonMessage:
language === "de"
? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
: "This dose is already marked as taken. Changes can only be made in MedAssist.",
};
}
return {
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
bodyText:
language === "de"
? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
: "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
jsonMessage:
language === "de"
? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
};
}
function getActionRecordedText(language: Language, action: NotificationMutationAction) {
if (action === "taken") {
return {
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
};
}
return {
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
};
}
function buildReplacementReminderMessage(
language: Language,
action: NotificationMutationAction,
originalMessage: string
): string {
const tr = getTranslations(language);
const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation;
const separatorIndex = originalMessage.indexOf(reminderFooterSeparator);
if (separatorIndex >= 0) {
const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd();
const footer = originalMessage.slice(separatorIndex);
return `${beforeFooter}\n\n${confirmationLine}${footer}`;
}
return `${originalMessage.trimEnd()}\n\n${confirmationLine}`;
}
async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
const [settings] = await db
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
.from(userSettings)
.where(eq(userSettings.userId, userId));
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
return;
}
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
if ("error" in sanitized || !sanitized.isNtfy) {
return;
}
const clearUrl = new URL(sanitized.url);
clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
const headers: Record<string, string> = {};
if (sanitized.auth) {
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
}
const response = await fetch(clearUrl.toString(), {
method: "PUT",
headers,
redirect: "error",
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
}
async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
const normalizedSequenceId = sequenceId.trim();
if (normalizedSequenceId.length === 0) {
return;
}
const [settings] = await db
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
.from(userSettings)
.where(eq(userSettings.userId, userId));
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
return;
}
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
if ("error" in sanitized || !sanitized.isNtfy) {
return;
}
const deleteUrl = new URL(sanitized.url);
deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
const headers: Record<string, string> = {};
if (sanitized.auth) {
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
}
const response = await fetch(deleteUrl.toString(), {
method: "DELETE",
headers,
redirect: "error",
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
}
async function replaceNtfyNotificationSequence(options: {
userId: number;
sequenceId: string;
language: Language;
title: string;
originalMessage: string;
action: NotificationMutationAction;
viewUrl: string | null;
}): Promise<boolean> {
const normalizedSequenceId = options.sequenceId.trim();
if (normalizedSequenceId.length === 0) {
return false;
}
const [settings] = await db
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
.from(userSettings)
.where(eq(userSettings.userId, options.userId));
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
return false;
}
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
if ("error" in sanitized || !sanitized.isNtfy) {
return false;
}
const labels = getNotificationActionLabels(options.language);
const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage);
const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, {
actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined,
viewUrl: options.viewUrl ?? undefined,
clickUrl: options.viewUrl ?? undefined,
sequenceId: normalizedSequenceId,
tags: ["pill"],
});
if (!result.success) {
throw new Error(result.error ?? "Failed to replace ntfy notification");
}
return true;
}
function renderPage(options: {
language: Language;
title: string;
message: string;
bodyTitle: string;
bodyText: string;
viewUrl: string | null;
actionButtons: Array<{ label: string; formAction?: string }>;
}): string {
const labels = getNotificationActionLabels(options.language);
const forms =
options.actionButtons.length > 0
? `<div class="actions">${options.actionButtons
.map((button) => {
const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
return `<form method="POST"${formAction}><button type="submit">${escapeHtml(button.label)}</button></form>`;
})
.join("")}</div>`
: "";
const viewLink = options.viewUrl
? `<p><a href="${escapeHtml(options.viewUrl)}">${escapeHtml(labels.view)}</a></p>`
: "";
return `<!DOCTYPE html>
<html lang="${options.language}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(options.bodyTitle)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f4f5f7; color: #1f2937; }
main { max-width: 640px; margin: 48px auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); }
h1 { margin-top: 0; font-size: 1.5rem; }
.card { padding: 16px; border-radius: 12px; background: #f9fafb; margin: 16px 0 24px; }
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
form { margin: 0; }
button, a { display: inline-flex; align-items: center; justify-content: center; min-width: 140px; border-radius: 10px; padding: 12px 16px; font: inherit; text-decoration: none; }
button { border: none; background: #0f766e; color: white; cursor: pointer; }
form:last-of-type button { background: #475569; }
a { background: #e2e8f0; color: #0f172a; }
p { line-height: 1.5; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(options.bodyTitle)}</h1>
<p>${escapeHtml(options.bodyText)}</p>
<div class="card">
<strong>${escapeHtml(options.title)}</strong>
<p>${toHtmlText(options.message)}</p>
</div>
${forms}
${viewLink}
</main>
</body>
</html>`;
}
function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
const normalizedTokenAction = normalizeNotificationAction(tokenKind);
if (normalizedTokenAction) {
return normalizedTokenAction;
}
const parsedQuery = querySchema.safeParse(request.query);
if (parsedQuery.success && parsedQuery.data.action) {
return normalizeNotificationAction(parsedQuery.data.action);
}
const body = request.body;
if (body && typeof body === "object" && "action" in body) {
const actionValue = (body as { action?: unknown }).action;
if (typeof actionValue === "string") {
return normalizeNotificationAction(actionValue);
}
}
return null;
}
function buildNotificationActionLogContext(
record: Awaited<ReturnType<typeof getNotificationActionTokenRecord>> extends infer T ? Exclude<T, null> : never,
extra: Record<string, unknown> = {}
) {
return {
groupId: record.group.id,
userId: record.group.userId,
tokenKind: record.token.kind,
doseCount: record.doseIds.length,
hasViewUrl: record.viewUrl !== null,
...extra,
};
}
function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record<string, unknown> = {}) {
return {
method: request.method,
hasOrigin: typeof request.headers.origin === "string",
expectsHtml: wantsHtml(request),
...extra,
};
}
export async function notificationActionRoutes(app: FastifyInstance) {
await app.register(formbody);
applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
applyPublicNotificationCorsHeaders(request, reply);
return reply.status(204).send();
});
app.get<{ Params: { token: string } }>(
"/notification-actions/:token",
{
config: {
rateLimit: { max: 30, timeWindow: "1 minute" },
},
schema: {
tags: ["notification-actions"],
params: {
type: "object",
required: ["token"],
properties: { token: { type: "string", minLength: 1 } },
},
response: {
404: genericErrorSchema,
405: genericErrorSchema,
410: genericErrorSchema,
},
},
},
async (request, reply) => {
applyPublicNotificationCorsHeaders(request, reply);
const record = await getNotificationActionTokenRecord(request.params.token);
if (!record) {
request.log.warn(
buildNotificationRequestLogContext(request),
"[NotificationActions] Unknown notification action token requested"
);
return reply.status(404).send({ error: "Notification action not found" });
}
if (isNotificationActionExpired(record)) {
request.log.warn(
buildNotificationActionLogContext(record),
"[NotificationActions] Rejected expired notification action GET request"
);
return reply.status(410).send({ error: "Notification action has expired" });
}
if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
request.log.warn(
buildNotificationActionLogContext(record),
"[NotificationActions] Rejected direct GET for unresolved non-respond token"
);
return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
}
const language = getLanguage(record.group.language ?? null);
const labels = getNotificationActionLabels(language);
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
let bodyTitle: string;
let bodyText: string;
let actionButtons: Array<{ label: string; formAction?: string }> = [];
if (resolvedAction) {
({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
} else {
if (record.token.kind === "taken") {
bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
bodyText =
language === "de"
? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
: "Confirm that this dose should be marked as taken.";
actionButtons = [{ label: labels.taken }];
} else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
bodyText =
language === "de"
? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
: "Confirm that this intake should be marked as skipped.";
actionButtons = [{ label: labels.skip }];
} else {
bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
bodyText =
language === "de"
? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
: "Choose an action for this medication reminder.";
actionButtons = [
{ label: labels.taken, formAction: "?action=taken" },
{ label: labels.skip, formAction: "?action=skip" },
];
}
}
return reply.type("text/html; charset=utf-8").send(
renderPage({
language,
title: record.group.title,
message: record.group.message,
bodyTitle,
bodyText,
viewUrl: record.viewUrl,
actionButtons: resolvedAction ? [] : actionButtons,
})
);
}
);
app.post<{ Params: { token: string } }>(
"/notification-actions/:token",
{
config: {
rateLimit: { max: 30, timeWindow: "1 minute" },
},
schema: {
tags: ["notification-actions"],
params: {
type: "object",
required: ["token"],
properties: { token: { type: "string", minLength: 1 } },
},
response: {
400: genericErrorSchema,
404: genericErrorSchema,
409: genericErrorSchema,
410: genericErrorSchema,
},
},
},
async (request, reply) => {
applyPublicNotificationCorsHeaders(request, reply);
const record = await getNotificationActionTokenRecord(request.params.token);
if (!record) {
request.log.warn(
buildNotificationRequestLogContext(request),
"[NotificationActions] Unknown notification action token requested"
);
return reply.status(404).send({ error: "Notification action not found" });
}
if (isNotificationActionExpired(record)) {
request.log.warn(
buildNotificationActionLogContext(record),
"[NotificationActions] Rejected expired notification action POST request"
);
return reply.status(410).send({ error: "Notification action has expired" });
}
const action = parseRequestedAction(request, record.token.kind);
if (!action) {
request.log.warn(
buildNotificationActionLogContext(record),
"[NotificationActions] Missing or invalid action for notification action POST request"
);
return reply.status(400).send({ error: "Notification action is required" });
}
const language = getLanguage(record.group.language ?? null);
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
if (resolvedAction) {
request.log.info(
buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
"[NotificationActions] Ignored notification action because it was already resolved"
);
const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
if (wantsHtml(request)) {
return reply.type("text/html; charset=utf-8").send(
renderPage({
language,
title: record.group.title,
message: record.group.message,
bodyTitle: alreadyProcessedText.bodyTitle,
bodyText: alreadyProcessedText.bodyText,
viewUrl: record.viewUrl,
actionButtons: [],
})
);
}
return reply.send({
success: true,
action: resolvedAction,
alreadyProcessed: true,
message: alreadyProcessedText.jsonMessage,
});
}
if (action === "taken") {
for (const [doseIndex, doseId] of record.doseIds.entries()) {
const result = await markDoseTakenForUser({
userId: record.group.userId,
doseId,
source: "notification",
markedBy: null,
});
if (!result.success) {
request.log.warn(
buildNotificationActionLogContext(record, {
requestedAction: action,
failedDoseIndex: doseIndex,
code: result.code,
}),
"[NotificationActions] Failed to record taken notification action"
);
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
return reply.status(statusCode).send({ error: result.message, code: result.code });
}
}
} else {
await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
}
await db
.update(notificationActionGroups)
.set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
.where(eq(notificationActionGroups.id, record.group.id));
await db
.update(notificationActionTokens)
.set({ usedAt: new Date() })
.where(eq(notificationActionTokens.id, record.token.id));
request.log.info(
buildNotificationActionLogContext(record, { requestedAction: action }),
"[NotificationActions] Recorded notification action"
);
const recordedText = getActionRecordedText(language, action);
let replacedNtfyNotification = false;
try {
replacedNtfyNotification = await replaceNtfyNotificationSequence({
userId: record.group.userId,
sequenceId: record.group.sequenceId,
language,
title: record.group.title,
originalMessage: record.group.message,
action,
viewUrl: record.viewUrl,
});
} catch (error) {
request.log.warn(
buildNotificationActionLogContext(record, { requestedAction: action, error }),
"[NotificationActions] Failed to replace ntfy notification after resolved action"
);
}
if (!replacedNtfyNotification) {
try {
await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
} catch (error) {
request.log.warn(
buildNotificationActionLogContext(record, { requestedAction: action, error }),
"[NotificationActions] Failed to delete ntfy notification after resolved action"
);
try {
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
} catch (clearError) {
request.log.warn(
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
"[NotificationActions] Failed to clear ntfy notification after delete fallback"
);
}
}
}
if (wantsHtml(request)) {
return reply.type("text/html; charset=utf-8").send(
renderPage({
language,
title: record.group.title,
message: record.group.message,
bodyTitle: recordedText.bodyTitle,
bodyText: recordedText.bodyText,
viewUrl: record.viewUrl,
actionButtons: [],
})
);
}
return reply.send({ success: true, action });
}
);
}
+9 -9
View File
@@ -119,7 +119,7 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(authUrl.href); return reply.redirect(authUrl.href);
} catch (err: unknown) { } catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed"); request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); return reply.redirect(getFrontendUrl());
} }
} }
); );
@@ -151,25 +151,25 @@ export async function oidcRoutes(app: FastifyInstance) {
// Handle OIDC provider errors // Handle OIDC provider errors
if (error) { if (error) {
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error"); app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`); return reply.redirect(getFrontendUrl());
} }
if (!code || !state) { if (!code || !state) {
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`); return reply.redirect(getFrontendUrl());
} }
// Verify state // Verify state
const storedState = request.unsignCookie(request.cookies.oidc_state || ""); const storedState = request.unsignCookie(request.cookies.oidc_state || "");
if (!storedState.valid || storedState.value !== state) { if (!storedState.valid || storedState.value !== state) {
request.log.warn("[OIDC] State mismatch during callback validation"); request.log.warn("[OIDC] State mismatch during callback validation");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`); return reply.redirect(getFrontendUrl());
} }
// Get code verifier // Get code verifier
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || ""); const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
if (!storedVerifier.valid || !storedVerifier.value) { if (!storedVerifier.valid || !storedVerifier.value) {
request.log.warn("[OIDC] Missing/invalid code verifier cookie"); request.log.warn("[OIDC] Missing/invalid code verifier cookie");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`); return reply.redirect(getFrontendUrl());
} }
try { try {
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const sub = tokens.claims()?.sub; const sub = tokens.claims()?.sub;
if (!sub) { if (!sub) {
request.log.error("[OIDC] Missing sub claim in token response"); request.log.error("[OIDC] Missing sub claim in token response");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`); return reply.redirect(getFrontendUrl());
} }
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub); const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) }, { hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
"[OIDC] Missing required user info" "[OIDC] Missing required user info"
); );
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`); return reply.redirect(getFrontendUrl());
} }
// Clean cookies // Clean cookies
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const user = await findOrCreateOIDCUser(username, oidcSubject, reply); const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
if (!user) { if (!user) {
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`); return reply.redirect(getFrontendUrl());
} }
// Update last login // Update last login
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(`${frontendUrl}/dashboard`); return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: unknown) { } catch (err: unknown) {
request.log.error({ err }, "[OIDC] Callback processing failed"); request.log.error({ err }, "[OIDC] Callback processing failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`); return reply.redirect(getFrontendUrl());
} }
} }
); );
+46 -24
View File
@@ -18,10 +18,11 @@ const refillSchema = z
.object({ .object({
packsAdded: z.number().int().min(0).default(0), packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0), loosePillsAdded: z.number().int().min(0).default(0),
quantityAdded: z.number().int().min(0).default(0),
usePrescription: z.boolean().default(false), usePrescription: z.boolean().default(false),
}) })
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, { .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
message: "Must add at least one pack or some loose pills", message: "Must add at least one pack or some quantity",
}); });
const refillBodyOpenApiSchema = { const refillBodyOpenApiSchema = {
@@ -29,12 +30,14 @@ const refillBodyOpenApiSchema = {
properties: { properties: {
packsAdded: { type: "integer", minimum: 0, default: 0 }, packsAdded: { type: "integer", minimum: 0, default: 0 },
loosePillsAdded: { type: "integer", minimum: 0, default: 0 }, loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
quantityAdded: { type: "integer", minimum: 0, default: 0 },
usePrescription: { type: "boolean", default: false }, usePrescription: { type: "boolean", default: false },
}, },
description: "Provide at least one pack or some loose pills.", description: "Provide at least one pack or some quantity.",
example: { example: {
packsAdded: 1, packsAdded: 1,
loosePillsAdded: 4, loosePillsAdded: 4,
quantityAdded: 4,
usePrescription: true, usePrescription: true,
}, },
} as const; } as const;
@@ -49,6 +52,7 @@ const refillResponseSchema = {
id: { type: "number" }, id: { type: "number" },
packsAdded: { type: "integer" }, packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" }, totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" }, refillDate: { type: "string", format: "date-time" },
}, },
@@ -80,6 +84,7 @@ const refillHistoryItemSchema = {
id: { type: "number" }, id: { type: "number" },
packsAdded: { type: "integer" }, packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" }, totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" }, usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" }, refillDate: { type: "string", format: "date-time" },
@@ -136,11 +141,12 @@ export async function refillRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, medId), eq(medications.userId, userId))); .where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found"); if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType); const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle"; const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType); const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle; const isCountBasedAmountPackage = isAmountBased && !isBottle;
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max( const fallbackAmountPerPackage = Math.max(
@@ -153,7 +159,9 @@ export async function refillRoutes(app: FastifyInstance) {
: fallbackAmountPerPackage; : fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded); const requestedPackAdds = Math.max(0, packsAdded);
const requestedAmountAdds = Math.max(0, loosePillsAdded); const requestedLooseAdds = Math.max(0, loosePillsAdded);
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds; let effectivePacksAdded = requestedPackAdds;
@@ -166,6 +174,9 @@ export async function refillRoutes(app: FastifyInstance) {
? effectivePacksAdded * amountPerPackage ? effectivePacksAdded * amountPerPackage
: requestedAmountAdds; : requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) { if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" }); return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
@@ -183,11 +194,31 @@ export async function refillRoutes(app: FastifyInstance) {
} }
} }
// Update medication stock const refillBaselineAt = new Date();
const newPackCount = med.packCount + effectivePacksAdded; const baselineStockBeforeRefill = isAmountBased
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; ? med.looseTablets + (med.stockAdjustment ?? 0)
const previousAmountBase = med.totalPills ?? med.looseTablets; : med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded; const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
// Update medication stock. Refill establishes a new persisted stock baseline and resets
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
let newPackCount = med.packCount + effectivePacksAdded;
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
let newStockAdjustment = med.stockAdjustment ?? 0;
let newTotalAmount = med.totalPills ?? med.looseTablets;
if (isBottle) {
newLooseTablets = targetCurrentStock;
newStockAdjustment = 0;
} else if (isCountBasedAmountPackage) {
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
newLooseTablets = targetCurrentStock;
newTotalAmount = targetCurrentStock;
newStockAdjustment = 0;
} else {
const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
}
let consumedRefills = 0; let consumedRefills = 0;
if (usePrescription) { if (usePrescription) {
@@ -197,10 +228,10 @@ export async function refillRoutes(app: FastifyInstance) {
? Math.max(0, remainingPrescriptionRefills - consumedRefills) ? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null); : (med.prescriptionRemainingRefills ?? null);
const refillBaselineAt = new Date();
const updatePayload: { const updatePayload: {
packCount: number; packCount: number;
looseTablets: number; looseTablets: number;
stockAdjustment: number;
totalPills?: number; totalPills?: number;
packageAmountValue?: number; packageAmountValue?: number;
prescriptionRemainingRefills: number | null; prescriptionRemainingRefills: number | null;
@@ -209,6 +240,7 @@ export async function refillRoutes(app: FastifyInstance) {
} = { } = {
packCount: newPackCount, packCount: newPackCount,
looseTablets: newLooseTablets, looseTablets: newLooseTablets,
stockAdjustment: newStockAdjustment,
prescriptionRemainingRefills: newRemainingRefills, prescriptionRemainingRefills: newRemainingRefills,
lastStockCorrectionAt: refillBaselineAt, lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt, updatedAt: refillBaselineAt,
@@ -236,31 +268,20 @@ export async function refillRoutes(app: FastifyInstance) {
}) })
.returning(); .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 { return {
success: true, success: true,
refill: { refill: {
id: refill.id, id: refill.id,
packsAdded: effectivePacksAdded, packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded, loosePillsAdded: effectiveLoosePillsAdded,
quantityAdded: totalPillsAdded,
totalPillsAdded, totalPillsAdded,
refillDate: refill.refillDate, refillDate: refill.refillDate,
}, },
newStock: { newStock: {
packCount: newPackCount, packCount: newPackCount,
looseTablets: newLooseTablets, looseTablets: newLooseTablets,
totalPills: newTotalPills, totalPills: targetCurrentStock,
}, },
prescription: { prescription: {
used: usePrescription, used: usePrescription,
@@ -316,6 +337,7 @@ export async function refillRoutes(app: FastifyInstance) {
id: r.id, id: r.id,
packsAdded: r.packsAdded, packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded, loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded, totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false, usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate, refillDate: r.refillDate,
+47 -7
View File
@@ -14,6 +14,7 @@ import {
const reportDataSchema = z.object({ const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100), medicationIds: z.array(z.number().int().positive()).min(1).max(100),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
}); });
const reportDataBodyOpenApiSchema = { const reportDataBodyOpenApiSchema = {
@@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100, maxItems: 100,
items: { type: "integer", minimum: 1 }, items: { type: "integer", minimum: 1 },
}, },
takenByFilter: {
type: "array",
maxItems: 50,
items: { type: "string", minLength: 1, maxLength: 100 },
},
}, },
example: { example: {
medicationIds: [1, 3, 5], medicationIds: [1, 3, 5],
takenByFilter: ["Daniel"],
}, },
} as const; } as const;
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
if (!takenByFilter) return true;
const parts = doseId.split("-");
if (parts.length < 4) return false;
const takenBy = parts.at(-1)?.trim();
if (!takenBy) return false;
return takenByFilter.has(takenBy);
}
const reportDataResponseSchema = { const reportDataResponseSchema = {
type: "object", type: "object",
additionalProperties: { additionalProperties: {
@@ -39,7 +55,7 @@ const reportDataResponseSchema = {
properties: { properties: {
dosesTaken: { type: "integer" }, dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" }, automaticDosesTaken: { type: "integer" },
dosesDismissed: { type: "integer" }, dosesSkipped: { type: "integer" },
firstDoseAt: { type: "string" }, firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" }, lastDoseAt: { type: "string" },
refills: { refills: {
@@ -49,6 +65,7 @@ const reportDataResponseSchema = {
properties: { properties: {
packsAdded: { type: "integer" }, packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" }, usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" }, refillDate: { type: "string", format: "date-time" },
}, },
@@ -93,10 +110,22 @@ export async function reportRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format()); if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply); const userId = await getUserId(req, reply);
const { medicationIds } = parsed.data; const { medicationIds, takenByFilter } = parsed.data;
const normalizedTakenByFilter = takenByFilter?.length
? new Set(takenByFilter.map((value) => value.trim()))
: null;
// Verify all medications belong to this user // Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)); const userMeds = await db
.select({
id: medications.id,
packageType: medications.packageType,
blistersPerPack: medications.blistersPerPack,
pillsPerBlister: medications.pillsPerBlister,
})
.from(medications)
.where(eq(medications.userId, userId));
const medMap = new Map(userMeds.map((med) => [med.id, med]));
const userMedIds = new Set(userMeds.map((m) => m.id)); const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) { for (const id of medicationIds) {
@@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) {
for (const dose of allDoses) { for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10); const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue; if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []); if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({ dosesByMed.get(medId)!.push({
takenAt: dose.takenAt, takenAt: dose.takenAt,
@@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) {
{ {
dosesTaken: number; dosesTaken: number;
automaticDosesTaken: number; automaticDosesTaken: number;
dosesDismissed: number; dosesSkipped: number;
firstDoseAt: string | null; firstDoseAt: string | null;
lastDoseAt: string | null; lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; refills: {
packsAdded: number;
loosePillsAdded: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
} }
> = {}; > = {};
@@ -147,9 +183,12 @@ export async function reportRoutes(app: FastifyInstance) {
const doses = dosesByMed.get(medId) ?? []; const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed); const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic"); const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed); const skippedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
const medication = medMap.get(medId);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user. // Get refills for this medication scoped to the authenticated user.
const refills = await db const refills = await db
@@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) {
result[medId] = { result[medId] = {
dosesTaken: takenDoses.length, dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length, automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length, dosesSkipped: skippedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null, firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null, lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({ refills: refills.map((r) => ({
packsAdded: r.packsAdded, packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded, loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false, usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})), })),
+87 -42
View File
@@ -2,15 +2,26 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js"; import { userSettings } from "../db/schema.js";
import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import {
createTestNotificationActionContext,
storeNotificationActionGroupNtfyMessageId,
} from "../services/notification-actions-service.js";
import {
type PushNotificationOptions,
renderNotificationActionPayload,
} from "../services/notifications/action-renderer.js";
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js"; import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
import { import {
classifyTestEmailFailure, classifyTestEmailFailure,
getAllUserSettingsFromDb, getAllUserSettingsFromDb,
getAvailableTimezones,
getDefaultSettings, getDefaultSettings,
getNotificationProvider, getNotificationProvider,
loadUserSettingsFromDb, loadUserSettingsFromDb,
normalizeSettingsTimezone,
sanitizeNotificationUrl, sanitizeNotificationUrl,
type UserSettings, type UserSettings,
validateNotificationHostname, validateNotificationHostname,
@@ -20,6 +31,7 @@ import type { AuthUser } from "../types/fastify.js";
export type { UserSettings } from "../services/settings-service.js"; export type { UserSettings } from "../services/settings-service.js";
type SettingsBody = { type SettingsBody = {
timezone: string;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string; notificationEmail: string;
reminderDaysBefore: number; reminderDaysBefore: number;
@@ -67,36 +79,6 @@ const settingsErrorSchema = {
}, },
}; };
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 envInt(key: string, defaultVal: number): number { function envInt(key: string, defaultVal: number): number {
const val = process.env[key]; const val = process.env[key];
if (val === undefined) return defaultVal; if (val === undefined) return defaultVal;
@@ -104,6 +86,24 @@ function envInt(key: string, defaultVal: number): number {
return Number.isNaN(parsed) ? defaultVal : parsed; return Number.isNaN(parsed) ? defaultVal : parsed;
} }
function getLanguage(language: string | null | undefined): Language {
return language === "de" ? "de" : "en";
}
function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } {
const tr = getTranslations(language);
const reminderAt = new Date(Date.now() + 60 * 1000);
const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), {
hour: "2-digit",
minute: "2-digit",
}).format(reminderAt);
return {
title: t(tr.push.intakeTitle, { minutes: 1 }),
message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
};
}
async function getOrCreateUserSettings(userId: number) { async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
@@ -174,6 +174,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15); const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
return reply.send({ return reply.send({
timezone: settings.timezone ?? "",
availableTimezones: getAvailableTimezones(),
serverTimezone: process.env.TZ || "UTC",
// User notification settings (from DB) // User notification settings (from DB)
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "", notificationEmail: settings.notificationEmail ?? "",
@@ -241,6 +244,7 @@ export async function settingsRoutes(app: FastifyInstance) {
type: "object", type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"], required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: { properties: {
timezone: { type: "string" },
emailEnabled: { type: "boolean" }, emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" }, notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" }, reminderDaysBefore: { type: "number" },
@@ -293,6 +297,7 @@ export async function settingsRoutes(app: FastifyInstance) {
upcomingTodayOnly: false, upcomingTodayOnly: false,
shareScheduleTodayOnly: false, shareScheduleTodayOnly: false,
swapDashboardMainSections: false, swapDashboardMainSections: false,
timezone: "",
}, },
}, },
response: { response: {
@@ -318,6 +323,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = { const settingsData = {
timezone: normalizeSettingsTimezone(body.timezone),
emailEnabled: body.emailEnabled, emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null, notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true, emailStockReminders: body.emailStockReminders ?? true,
@@ -543,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
} }
try { try {
const userId = await getUserId(request, reply);
const settings = await getOrCreateUserSettings(userId);
const language = getLanguage(settings.language);
const { title, message } = buildInteractiveTestPushNotification(language);
const actionContext = await createTestNotificationActionContext({
userId,
title,
message,
publicAppUrl: env.PUBLIC_APP_URL,
language,
});
const provider = getNotificationProvider(url); const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification( const result = await sendShoutrrrNotification(url, title, message, {
url, actions: actionContext?.actions,
"MedAssist-ng Test", respondUrl: actionContext?.respondUrl,
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!" viewUrl: actionContext?.viewUrl,
); clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
sequenceId: actionContext?.sequenceId,
tags: ["pill"],
priority: 3,
});
if (result.success) { if (result.success) {
if (actionContext?.groupId && result.providerMessageId) {
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
}
request.log.info({ provider }, "[Settings] Test push notification sent"); request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" }); return reply.send({ success: true, message: "Test notification sent successfully" });
} else { } else {
@@ -573,8 +598,9 @@ export async function settingsRoutes(app: FastifyInstance) {
export async function sendShoutrrrNotification( export async function sendShoutrrrNotification(
urlStr: string, urlStr: string,
title: string, title: string,
message: string message: string,
): Promise<{ success: boolean; error?: string }> { options: PushNotificationOptions = {}
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
try { try {
if (urlStr.startsWith("pushover://")) { if (urlStr.startsWith("pushover://")) {
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? ""; const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
@@ -727,12 +753,13 @@ export async function sendShoutrrrNotification(
} }
// Use ONLY the reconstructed URL from validation - never the original urlStr // Use ONLY the reconstructed URL from validation - never the original urlStr
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation; const { url: sanitizedUrl, isNtfy, auth } = validation;
let targetUrl: string; let targetUrl: string;
const method = "POST"; const method = "POST";
let headers: Record<string, string> = {}; let headers: Record<string, string> = {};
let body: string | undefined; let body: string | undefined;
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
// Remove emojis from title for header compatibility // Remove emojis from title for header compatibility
const cleanTitle = title const cleanTitle = title
@@ -777,19 +804,27 @@ export async function sendShoutrrrNotification(
// characters (umlauts, accents, etc.) through HTTP headers // characters (umlauts, accents, etc.) through HTTP headers
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`; const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
headers = { Title: encodedTitle, Tags: "pill" }; headers = { Title: encodedTitle, Tags: "pill" };
body = message; body = renderedPayload.message;
// Add auth if present (extracted during sanitization) // Add auth if present (extracted during sanitization)
if (auth) { if (auth) {
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
} }
if (isNtfy) {
headers = { ...headers, ...renderedPayload.headers };
}
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) { } else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
targetUrl = sanitizedUrl; targetUrl = sanitizedUrl;
headers = { "Content-Type": "application/json" }; headers = { "Content-Type": "application/json" };
if (isDiscordWebhook) { if (isDiscordWebhook) {
body = JSON.stringify({ content: `${title}\n\n${message}` }); body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
} else { } else {
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` }); body = JSON.stringify({
title,
message: renderedPayload.message,
text: `${title}\n\n${renderedPayload.message}`,
});
} }
} else { } else {
return { return {
@@ -814,7 +849,17 @@ export async function sendShoutrrrNotification(
}); });
if (response.ok) { if (response.ok) {
return { success: true }; let providerMessageId: string | undefined;
if (isNtfy) {
try {
const payload = (await response.json()) as { id?: unknown };
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
} catch {
providerMessageId = undefined;
}
}
return { success: true, providerMessageId };
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
return { success: false, error: `HTTP ${response.status}: ${errorText}` }; return { success: false, error: `HTTP ${response.status}: ${errorText}` };
+1 -1
View File
@@ -385,7 +385,7 @@ export async function shareRoutes(app: FastifyInstance) {
const parsed = createShareSchema.safeParse(request.body); const parsed = createShareSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input", error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
}); });
} }
+16 -2
View File
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId); const match = doseIdPattern.exec(dose.doseId);
if (!match) continue; if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10); const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10); const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) { if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue; continue;
} }
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId); const match = doseIdPattern.exec(dose.doseId);
if (!match) continue; if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10); const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10); const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) { if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue; continue;
} }
@@ -0,0 +1,280 @@
import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, medications, userSettings } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import { computeMedicationCurrentStock } from "./current-stock.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
export type DoseTrackingSource = "manual" | "automatic" | "notification";
export type MarkDoseTakenResult =
| {
success: true;
status: "marked" | "already_taken";
}
| {
success: false;
code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
message: string;
};
export type DismissDosesResult = {
success: true;
dismissedCount: number;
alreadyTakenCount: number;
};
export type SkipDosesResult = {
success: true;
skippedCount: number;
alreadySkippedCount: number;
switchedFromTakenCount: number;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) {
return null;
}
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
function hasRealTakenTimestamp(takenAt: Date | null): boolean {
return takenAt instanceof Date && takenAt.getTime() > 0;
}
async function isDoseOutOfStock(options: { userId: number; doseId: string }): Promise<boolean> {
const parsedDose = parseDoseId(options.doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
if (!medication) {
return false;
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
const intake = intakes[parsedDose.intakeIndex];
const scheduledOccurrenceMs = intake
? (() => {
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
).getTime();
})()
: parsedDose.timestampMs;
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
return (
computeMedicationCurrentStock({
medication,
doses,
stockCalculationMode,
nowMs: stockBeforeDoseMs,
}) <= 0
);
}
export async function markDoseTakenForUser(input: {
userId: number;
doseId: string;
source: DoseTrackingSource;
markedBy?: string | null;
}): Promise<MarkDoseTakenResult> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return {
success: false,
code: "INVALID_DOSE",
message: "Invalid dose ID",
};
}
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (existing && !existing.dismissed) {
return { success: true, status: "already_taken" };
}
if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
return { success: true, status: "already_taken" };
}
if (existing?.dismissed) {
return {
success: false,
code: "ALREADY_SKIPPED",
message: "Dose is already skipped",
};
}
const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
if (outOfStock) {
return {
success: false,
code: "OUT_OF_STOCK",
message: "Medication is out of stock",
};
}
await db.insert(doseTracking).values({
userId: input.userId,
doseId: input.doseId,
takenAt: new Date(),
markedBy: input.markedBy ?? null,
takenSource: input.source,
dismissed: false,
});
return { success: true, status: "marked" };
}
export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise<SkipDosesResult> {
let skippedCount = 0;
let alreadySkippedCount = 0;
let switchedFromTakenCount = 0;
for (const doseId of input.doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
if (!existing) {
await db.insert(doseTracking).values({
userId: input.userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
skippedCount++;
continue;
}
if (existing.dismissed) {
alreadySkippedCount++;
continue;
}
if (hasRealTakenTimestamp(existing.takenAt)) {
switchedFromTakenCount++;
}
await db
.update(doseTracking)
.set({
dismissed: true,
takenAt: new Date(0),
takenSource: "manual",
markedBy: null,
})
.where(eq(doseTracking.id, existing.id));
skippedCount++;
}
return {
success: true,
skippedCount,
alreadySkippedCount,
switchedFromTakenCount,
};
}
export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise<DismissDosesResult> {
let dismissedCount = 0;
let alreadyTakenCount = 0;
for (const doseId of input.doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
if (!existing) {
await db.insert(doseTracking).values({
userId: input.userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
continue;
}
if (existing.dismissed) {
continue;
}
if (hasRealTakenTimestamp(existing.takenAt)) {
alreadyTakenCount++;
continue;
}
await db
.update(doseTracking)
.set({
dismissed: true,
takenAt: new Date(0),
takenSource: "manual",
markedBy: null,
})
.where(eq(doseTracking.id, existing.id));
dismissedCount++;
}
return {
success: true,
dismissedCount,
alreadyTakenCount,
};
}
@@ -12,13 +12,15 @@ import {
type Language, type Language,
t, t,
} from "../i18n/translations.js"; } from "../i18n/translations.js";
import { env } from "../plugins/env.js";
import { getAllUserSettings, type UserSettings } from "../routes/settings.js"; import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js"; import type { ServiceLogger } from "../utils/logger.js";
// Import shared utilities // Import shared utilities
import { import {
cleanOldIntakeReminders, cleanOldIntakeReminders,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
getTimezone, getEffectiveTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type IntakeReminderState, type IntakeReminderState,
@@ -29,6 +31,10 @@ import {
type UpcomingIntake, type UpcomingIntake,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
import { computeMedicationCurrentStock } from "./current-stock.js"; import { computeMedicationCurrentStock } from "./current-stock.js";
import {
createNotificationActionContext,
storeNotificationActionGroupNtfyMessageId,
} from "./notification-actions-service.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
@@ -83,6 +89,41 @@ function formatIntakeLog(intake: {
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`; return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
} }
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = med.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = med.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${med.id}`;
}
function getPushProviderLabel(url: string): string {
const normalizedUrl = url.trim().toLowerCase();
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
if (normalizedUrl.startsWith("discord://")) return "discord";
if (normalizedUrl.startsWith("pushover://")) return "pushover";
if (normalizedUrl.startsWith("gotify://")) return "gotify";
if (normalizedUrl.startsWith("telegram://")) return "telegram";
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
} catch {
return "unknown";
}
}
function formatActionContextLog(options: {
actionMode: "full" | "view-only";
doseCount: number;
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
}): string {
const { actionMode, doseCount, actionContext } = options;
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
}
async function autoMarkDueIntakesAsTaken( async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number }, settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[], rows: (typeof medications.$inferSelect)[],
@@ -137,7 +178,7 @@ async function autoMarkDueIntakesAsTaken(
} }
const medicationTakenBy = parseTakenByJson(med.takenByJson); const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || ""; const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
let remainingStock = computeMedicationCurrentStock({ let remainingStock = computeMedicationCurrentStock({
medication: med, medication: med,
doses: trackedDoses, doses: trackedDoses,
@@ -425,7 +466,7 @@ export async function checkAndSendIntakeRemindersForUser(
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id); const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language); const locale = getDateLocale(language);
const tz = getTimezone(); const tz = getEffectiveTimezone(settings.timezone ?? null);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger); const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) { if (autoMarkedCount > 0) {
@@ -473,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
return; // No medications have reminders enabled for this user return; // No medications have reminders enabled for this user
} }
const now = new Date();
const state = loadIntakeReminderState(logger); const state = loadIntakeReminderState(logger);
const trackedDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
...entry,
currentStock: computeMedicationCurrentStock({
medication: entry.med,
doses: trackedDoses,
stockCalculationMode: settings.stockCalculationMode,
nowMs: now.getTime(),
}),
}));
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
if (suppressedEmptyStockEntries.length > 0) {
logger.info(
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
.map((entry) =>
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
)
.join(", ")}`
);
}
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
if (reminderEntriesEligible.length === 0) {
logger.info(
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
);
return;
}
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
let scheduledIntakesTodayCount = 0; let scheduledIntakesTodayCount = 0;
// Get start and end of today in user's timezone (for filtering today's doses only) // 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 })); const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0); todayStart.setHours(0, 0, 0, 0);
@@ -485,10 +557,10 @@ export async function checkAndSendIntakeRemindersForUser(
todayEnd.setHours(23, 59, 59, 999); todayEnd.setHours(23, 59, 59, 999);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders // Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const { med, intakes, intakesWithReminders } of reminderEntries) { for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) {
// Medication-level takenBy (for fallback/display purposes) // Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson); const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || ""; const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
// Process each intake separately to track blisterIndex // Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => { intakesWithReminders.forEach((intake, _blisterIndex) => {
@@ -791,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
.join("\n") + .join("\n") +
repeatNote + repeatNote +
`\n\n---\n${getFooterPlain(language)}`; `\n\n---\n${getFooterPlain(language)}`;
const actionMode = remindersToSend.length === 1 ? "full" : "view-only";
const actionDoseIds = remindersToSend.map((intake) =>
buildDoseIdForIntake({
...intake,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
})
);
let actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null = null;
let actionContextFailed = false;
try {
actionContext = await createNotificationActionContext({
userId: settings.userId,
title,
message,
doseIds: actionDoseIds,
scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(),
publicAppUrl: env.PUBLIC_APP_URL,
language,
actionMode,
});
} catch (error) {
actionContextFailed = true;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(
`[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
settings.shoutrrrUrl!
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}`
);
}
if (!actionContext) {
if (actionContextFailed) {
logger.warn(
`[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
settings.shoutrrrUrl!
)})`
);
} else {
logger.warn(
`[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})`
);
}
} else {
logger.info(
`[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
settings.shoutrrrUrl!
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
);
}
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message); const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!);
logger.info(
`[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
);
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, {
actions: actionContext?.actions,
respondUrl: actionContext?.respondUrl,
viewUrl: actionContext?.viewUrl,
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
sequenceId: actionContext?.sequenceId,
tags: ["pill"],
priority: hasNaggingReminder ? 4 : 3,
});
shoutrrrSuccess = result.success; shoutrrrSuccess = result.success;
if (!result.success) { if (!result.success) {
logger.error( logger.error(
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}` `[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}`
); );
} else { } else {
if (actionContext?.groupId && result.providerMessageId) {
try {
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
logger.info(
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
);
}
} else if (actionContext?.groupId && pushProvider === "ntfy") {
logger.warn(
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
);
}
logger.info( logger.info(
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})` `[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
); );
} }
} }
@@ -0,0 +1,350 @@
import { createHash, randomBytes } from "node:crypto";
import { and, eq, gt, isNull } from "drizzle-orm";
import { db } from "../db/client.js";
import { notificationActionGroups, notificationActionTokens } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
import { env } from "../plugins/env.js";
import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js";
export type NotificationActionKind = "taken" | "skip" | "respond" | "view";
type TokenKind = Exclude<NotificationActionKind, "view">;
type ActiveTokenKind = "taken" | "skip" | "respond";
export type NotificationActionContext = {
groupId?: number;
sequenceId?: string;
respondUrl?: string;
viewUrl: string;
actions: PushNotificationAction[];
};
type NotificationActionMode = "full" | "view-only";
export type NotificationActionTokenRecord = {
token: typeof notificationActionTokens.$inferSelect;
group: typeof notificationActionGroups.$inferSelect;
doseIds: string[];
viewUrl: string | null;
};
const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
function normalizePublicAppUrl(publicAppUrl: string): string {
return publicAppUrl.replace(/\/+$/, "");
}
function parseConfiguredUrl(value: string | null | undefined): URL | null {
const trimmedValue = value?.trim();
if (!trimmedValue) {
return null;
}
try {
return new URL(trimmedValue);
} catch {
return null;
}
}
function isLoopbackHostname(hostname: string): boolean {
const normalizedHostname = hostname.toLowerCase();
return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1";
}
function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null {
const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL);
if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) {
return normalizePublicAppUrl(configuredUrl.toString());
}
const corsOrigins = env.CORS_ORIGINS.split(",")
.map((origin) => parseConfiguredUrl(origin))
.filter((origin): origin is URL => origin !== null);
const reachableCorsOrigin =
corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null;
if (reachableCorsOrigin) {
return normalizePublicAppUrl(reachableCorsOrigin.toString());
}
return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null;
}
function getScheduledKey(scheduledFor: Date): string {
return String(Math.floor(scheduledFor.getTime() / 60000));
}
function formatDateParam(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string {
const params = new URLSearchParams();
const primaryDoseId = doseIds[0];
if (scheduledFor) {
params.set("day", formatDateParam(scheduledFor));
}
if (primaryDoseId) {
params.set("dose", primaryDoseId);
}
const queryString = params.toString();
return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`;
}
function parseDoseIdsJson(value: string): string[] {
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
} catch {
return [];
}
}
function createSequenceId(groupKey: string): string {
return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`;
}
export function createActionToken(): string {
return randomBytes(32).toString("hex");
}
export function hashActionToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}
async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> {
const token = createActionToken();
await db.insert(notificationActionTokens).values({
groupId,
tokenHash: hashActionToken(token),
kind,
});
return { kind, token };
}
async function createActionTokens(groupId: number): Promise<Record<ActiveTokenKind, string>> {
const createdTokens = await Promise.all([
createTokenRow(groupId, "taken"),
createTokenRow(groupId, "skip"),
createTokenRow(groupId, "respond"),
]);
return createdTokens.reduce(
(accumulator, entry) => {
accumulator[entry.kind] = entry.token;
return accumulator;
},
{ taken: "", skip: "", respond: "" } as Record<ActiveTokenKind, string>
);
}
export async function createNotificationActionContext(input: {
userId: number;
title: string;
message: string;
doseIds: string[];
scheduledFor: Date;
publicAppUrl?: string | null;
language: Language;
actionMode?: NotificationActionMode;
}): Promise<NotificationActionContext | null> {
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
if (!publicAppUrl) {
return null;
}
const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort();
if (uniqueDoseIds.length === 0) {
return null;
}
const baseUrl = publicAppUrl;
const actionMode = input.actionMode ?? "full";
const labels = getNotificationActionLabels(input.language);
const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds);
if (actionMode === "view-only") {
return {
viewUrl,
actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }],
};
}
const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`;
const sequenceId = createSequenceId(groupKey);
const now = new Date();
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
let [group] = await db
.select()
.from(notificationActionGroups)
.where(
and(
eq(notificationActionGroups.groupKey, groupKey),
isNull(notificationActionGroups.resolvedAction),
gt(notificationActionGroups.expiresAt, now)
)
);
if (!group) {
[group] = await db
.insert(notificationActionGroups)
.values({
userId: input.userId,
groupKey,
sequenceId,
doseIdsJson: JSON.stringify(uniqueDoseIds),
title: input.title,
message: input.message,
language: input.language,
scheduledFor: input.scheduledFor,
expiresAt,
updatedAt: now,
})
.returning();
}
const tokens = await createActionTokens(group.id);
const groupLanguage = (group.language as Language | null) ?? input.language;
const groupLabels = getNotificationActionLabels(groupLanguage);
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds);
return {
groupId: group.id,
sequenceId: group.sequenceId,
respondUrl,
viewUrl: resolvedViewUrl,
actions: [
{
kind: "taken",
label: groupLabels.taken,
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
method: "POST",
},
{
kind: "skip",
label: groupLabels.skip,
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
method: "POST",
},
{ kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" },
],
};
}
export async function createTestNotificationActionContext(input: {
userId: number;
title: string;
message: string;
publicAppUrl?: string | null;
language: Language;
}): Promise<NotificationActionContext | null> {
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
if (!publicAppUrl) {
return null;
}
const baseUrl = publicAppUrl;
const now = new Date();
const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`;
const sequenceId = createSequenceId(groupKey);
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
const viewUrl = buildViewUrl(baseUrl, null, []);
const [group] = await db
.insert(notificationActionGroups)
.values({
userId: input.userId,
groupKey,
sequenceId,
doseIdsJson: "[]",
title: input.title,
message: input.message,
language: input.language,
scheduledFor: now,
expiresAt,
updatedAt: now,
})
.returning();
const tokens = await createActionTokens(group.id);
const groupLanguage = (group.language as Language | null) ?? input.language;
const groupLabels = getNotificationActionLabels(groupLanguage);
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
return {
groupId: group.id,
sequenceId: group.sequenceId,
respondUrl,
viewUrl,
actions: [
{
kind: "taken",
label: groupLabels.taken,
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
method: "POST",
},
{
kind: "skip",
label: groupLabels.skip,
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
method: "POST",
},
{ kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" },
],
};
}
export async function getNotificationActionTokenRecord(
rawToken: string
): Promise<NotificationActionTokenRecord | null> {
const tokenHash = hashActionToken(rawToken);
const rows = await db
.select({ token: notificationActionTokens, group: notificationActionGroups })
.from(notificationActionTokens)
.innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id))
.where(eq(notificationActionTokens.tokenHash, tokenHash));
const record = rows[0];
if (!record) {
return null;
}
const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL);
return {
token: record.token,
group: record.group,
doseIds: parseDoseIdsJson(record.group.doseIdsJson),
viewUrl: baseUrl
? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson))
: null,
};
}
export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean {
return record.group.expiresAt.getTime() <= Date.now();
}
export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise<void> {
const normalizedMessageId = ntfyMessageId.trim();
if (normalizedMessageId.length === 0) {
return;
}
await db
.update(notificationActionGroups)
.set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() })
.where(eq(notificationActionGroups.id, groupId));
}
@@ -0,0 +1,175 @@
import type { Language } from "../../i18n/translations.js";
export type PushNotificationAction =
| {
kind: "taken";
label: string;
url: string;
method: "POST";
}
| {
kind: "skip";
label: string;
url: string;
method: "POST";
}
| {
kind: "view";
label: string;
url: string;
method: "GET";
};
export type PushNotificationOptions = {
actions?: PushNotificationAction[];
respondUrl?: string;
viewUrl?: string;
clickUrl?: string;
tags?: string[];
priority?: number;
sequenceId?: string;
};
type NtfyActionPayload = {
action: "http" | "view";
label: string;
url: string;
method?: "POST";
clear: boolean;
};
function encodeHeaderValue(value: string): string {
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
return value;
}
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
}
export function isNtfyNotificationUrl(urlStr: string): boolean {
if (urlStr.startsWith("ntfy://")) {
return true;
}
try {
const parsed = new URL(urlStr);
if (!["http:", "https:"].includes(parsed.protocol)) {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
} catch {
return false;
}
}
export function getNotificationProvider(urlStr: string): string {
if (isNtfyNotificationUrl(urlStr)) {
return "ntfy";
}
try {
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
} catch {
return "unknown";
}
}
export function getNotificationActionLabels(language: Language): {
taken: string;
skip: string;
respond: string;
view: string;
} {
if (language === "de") {
return {
taken: "Einnehmen",
skip: "Überspringen",
respond: "Antworten",
view: "Öffnen",
};
}
return {
taken: "Take",
skip: "Skip",
respond: "Respond",
view: "View",
};
}
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
const actions = options.actions ?? [];
return actions.map((action) => {
if (action.kind === "view") {
return {
action: "view",
label: action.label,
url: action.url,
clear: false,
};
}
return {
action: "http",
label: action.label,
url: action.url,
method: "POST",
// Clear the original actionable ntfy notification locally after a successful mutation.
clear: true,
};
});
}
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
if (!options.respondUrl && !options.viewUrl) {
return message;
}
const lines = [message.trimEnd()];
if (options.respondUrl) {
lines.push("", "Respond:", options.respondUrl);
}
if (options.viewUrl) {
lines.push("", "View:", options.viewUrl);
}
return lines.join("\n");
}
export function renderNotificationActionPayload(
urlStr: string,
message: string,
options: PushNotificationOptions
): { message: string; headers: Record<string, string> } {
if (!isNtfyNotificationUrl(urlStr)) {
return {
message: appendFallbackActionLinks(message, options),
headers: {},
};
}
const headers: Record<string, string> = {};
const ntfyActions = buildNtfyActions(options);
if (ntfyActions.length > 0) {
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
}
if (options.clickUrl && ntfyActions.length === 0) {
headers.Click = options.clickUrl;
}
if (options.tags && options.tags.length > 0) {
headers.Tags = options.tags.join(",");
}
if (typeof options.priority === "number") {
headers.Priority = String(options.priority);
}
if (options.sequenceId) {
headers["X-Sequence-ID"] = options.sequenceId;
}
return { message, headers };
}
@@ -1,5 +1,6 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { sendShoutrrrNotification } from "../../routes/settings.js"; import { sendShoutrrrNotification } from "../../routes/settings.js";
import type { PushNotificationOptions } from "./action-renderer.js";
type MailDeliveryInfo = { type MailDeliveryInfo = {
accepted?: unknown; accepted?: unknown;
@@ -122,14 +123,15 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
export async function sendPushNotification( export async function sendPushNotification(
url: string, url: string,
title: string, title: string,
message: string message: string,
): Promise<{ success: boolean; error?: string }> { options: PushNotificationOptions = {}
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
try { try {
const result = await sendShoutrrrNotification(url, title, message); const result = await sendShoutrrrNotification(url, title, message, options);
if (!result.success) { if (!result.success) {
return { success: false, error: result.error }; return { success: false, error: result.error };
} }
return { success: true }; return { success: true, providerMessageId: result.providerMessageId };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
+15 -3
View File
@@ -21,6 +21,7 @@ import {
formatInTimezone, formatInTimezone,
getCurrentHourInTimezone, getCurrentHourInTimezone,
getDateOnlyTimestamp, getDateOnlyTimestamp,
getEffectiveTimezone,
getMsUntilNextCheck, getMsUntilNextCheck,
getNextScheduledOccurrenceTime, getNextScheduledOccurrenceTime,
getNextScheduledTime, getNextScheduledTime,
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
expiryDate: string | null; expiryDate: string | null;
}; };
function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = row.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = row.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${row.id}`;
}
async function getMedicationsNeedingReminder( async function getMedicationsNeedingReminder(
userId: number, userId: number,
reminderDaysBefore: number, reminderDaysBefore: number,
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) { if (isCritical || isLow) {
lowStock.push({ lowStock.push({
name: row.name, name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills, medsLeft: currentPills,
daysLeft, daysLeft,
depletionDate, depletionDate,
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1) (row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
) )
.map((row) => ({ .map((row) => ({
name: row.name, name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
remainingRefills: row.prescriptionRemainingRefills ?? 0, remainingRefills: row.prescriptionRemainingRefills ?? 0,
lowThreshold: row.prescriptionLowRefillThreshold ?? 1, lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
expiryDate: row.prescriptionExpiryDate ?? null, expiryDate: row.prescriptionExpiryDate ?? null,
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
} }
const state = loadReminderState(); const state = loadReminderState();
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone const userTimezone = getEffectiveTimezone(settings.timezone ?? null);
const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone
const userStateKey = `user_${settings.userId}`; const userStateKey = `user_${settings.userId}`;
const userStockNotifiedKey = `${userStateKey}_${today}_stock`; const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`; const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
+34 -2
View File
@@ -2,9 +2,11 @@ import { eq } from "drizzle-orm";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js"; import { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js"; import type { Language } from "../i18n/translations.js";
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
export type UserSettings = { export type UserSettings = {
userId: number; userId: number;
timezone?: string | null;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string | null; notificationEmail: string | null;
emailStockReminders: boolean; emailStockReminders: boolean;
@@ -80,7 +82,7 @@ export function getNotificationProvider(url: string): string {
if (url.startsWith("telegram://")) return "telegram"; if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify"; if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover"; if (url.startsWith("pushover://")) return "pushover";
if (url.startsWith("ntfy://")) return "ntfy"; if (isNtfyNotificationUrl(url)) return "ntfy";
try { try {
const parsed = new URL(url); const parsed = new URL(url);
@@ -105,6 +107,7 @@ function envInt(key: string, defaultVal: number): number {
export function getDefaultSettings() { export function getDefaultSettings() {
return { return {
timezone: "",
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
@@ -144,6 +147,33 @@ export function getDefaultSettings() {
}; };
} }
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
let cachedTimezones: Set<string> | null = null;
function getTimezoneSet(): Set<string> {
if (cachedTimezones) return cachedTimezones;
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
return cachedTimezones;
}
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
return cachedTimezones;
}
export function getAvailableTimezones(): string[] {
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
}
export function normalizeSettingsTimezone(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
return getTimezoneSet().has(trimmed) ? trimmed : "";
}
export function validateNotificationHostname(hostnameRaw: string): string | null { export function validateNotificationHostname(hostnameRaw: string): string | null {
const hostname = hostnameRaw.toLowerCase(); const hostname = hostnameRaw.toLowerCase();
@@ -202,7 +232,7 @@ export function sanitizeNotificationUrl(
return { url: discordWebhookUrl, isNtfy: false }; return { url: discordWebhookUrl, isNtfy: false };
} }
const isNtfy = urlStr.startsWith("ntfy://"); const isNtfy = isNtfyNotificationUrl(urlStr);
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr; const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl); const parsed = new URL(normalizedUrl);
@@ -245,6 +275,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
const settings = await getOrCreateUserSettings(userId); const settings = await getOrCreateUserSettings(userId);
return { return {
userId: settings.userId, userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail, notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders, emailStockReminders: settings.emailStockReminders,
@@ -288,6 +319,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings); const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({ return allSettings.map((settings) => ({
userId: settings.userId, userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail, notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders, emailStockReminders: settings.emailStockReminders,
@@ -0,0 +1,226 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb } = 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,
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
const { dismissDosesForUser, markDoseTakenForUser } = await import("../services/dose-tracking-service.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 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],
});
return Number(result.rows[0].id);
}
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
const 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, ?, ?, ?, ?, 0)`,
args: [
options.id,
options.userId,
options.packCount ?? 1,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }]),
],
});
}
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 insertDose(options: {
userId: number;
doseId: string;
dismissed?: boolean;
takenAt?: number;
takenSource?: "manual" | "automatic" | "notification";
markedBy?: string | null;
}) {
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed, taken_at, taken_source, marked_by)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
options.doseId,
options.dismissed ? 1 : 0,
options.takenAt ?? Math.floor(Date.now() / 1000),
options.takenSource ?? "manual",
options.markedBy ?? null,
],
});
}
describe("dose-tracking-service", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
});
afterAll(() => {
testClient.close();
});
beforeEach(async () => {
await clearTables();
});
it("inserts a taken row for a valid in-stock dose", async () => {
const userId = await createUser("dose-service-user");
await insertMedication({ id: 5, userId, packCount: 1 });
await insertUserSettings(userId, "automatic");
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: null,
});
expect(result).toEqual({ success: true, status: "marked" });
const rows = await testClient.execute({
sql: "SELECT dismissed, taken_source, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dismissed: 0, taken_source: "notification", marked_by: null }),
]);
});
it("is idempotent when the dose is already taken", async () => {
const userId = await createUser("dose-service-existing");
await insertDose({ userId, doseId: "5-0-1736064000000", dismissed: false });
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "manual",
markedBy: null,
});
expect(result).toEqual({ success: true, status: "already_taken" });
const count = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(Number(count.rows[0].count)).toBe(1);
});
it("rejects taking a dose that is already skipped", async () => {
const userId = await createUser("dose-service-dismissed");
await insertMedication({ id: 5, userId, packCount: 1 });
await insertUserSettings(userId, "automatic");
await insertDose({
userId,
doseId: "5-0-1736064000000",
dismissed: true,
takenAt: 0,
takenSource: "manual",
markedBy: null,
});
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: "reminder",
});
expect(result).toEqual({ success: false, code: "ALREADY_SKIPPED", message: "Dose is already skipped" });
const rows = await testClient.execute({
sql: "SELECT dismissed, taken_source, marked_by, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(rows.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_source: "manual", marked_by: null })]);
expect(Number(rows.rows[0].taken_at)).toBe(0);
});
it("returns OUT_OF_STOCK without mutating dose tracking", async () => {
const userId = await createUser("dose-service-stock");
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const result = await markDoseTakenForUser({
userId,
doseId: "5-0-1736064000000",
source: "notification",
markedBy: null,
});
expect(result).toEqual({ success: false, code: "OUT_OF_STOCK", message: "Medication is out of stock" });
const count = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ?",
args: [userId],
});
expect(Number(count.rows[0].count)).toBe(0);
});
it("dismisses new doses, stays idempotent for dismissed rows, and preserves real taken rows", async () => {
const userId = await createUser("dose-service-dismiss");
await insertDose({ userId, doseId: "5-1-1736064000000", dismissed: true, takenAt: 0 });
await insertDose({ userId, doseId: "5-2-1736064000000", dismissed: false });
const result = await dismissDosesForUser({
userId,
doseIds: ["5-0-1736064000000", "5-1-1736064000000", "5-2-1736064000000"],
});
expect(result).toEqual({ success: true, dismissedCount: 1, alreadyTakenCount: 1 });
const rows = await testClient.execute({
sql: "SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
args: [userId],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dose_id: "5-0-1736064000000", dismissed: 1, taken_at: 0 }),
expect.objectContaining({ dose_id: "5-1-1736064000000", dismissed: 1, taken_at: 0 }),
expect.objectContaining({ dose_id: "5-2-1736064000000", dismissed: 0 }),
]);
});
});
+402 -7
View File
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
@@ -307,10 +308,10 @@ describe("E2E Tests with Real Routes", () => {
expect(response.json().error).toBe("Access denied to medication"); expect(response.json().error).toBe("Access denied to medication");
}); });
it("should aggregate taken/dismissed doses and refill history", async () => { it("should aggregate taken/skipped doses and refill history", async () => {
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]); const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
// One taken dose and one dismissed dose for the same medication // One taken dose and one skipped dose for the same medication
await testClient.execute({ await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`, VALUES (?, ?, ?, 0)`,
@@ -337,13 +338,14 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const data = response.json(); const data = response.json();
expect(data[medId].dosesTaken).toBe(1); expect(data[medId].dosesTaken).toBe(1);
expect(data[medId].dosesDismissed).toBe(1); expect(data[medId].dosesSkipped).toBe(1);
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString()); expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
expect(data[medId].refills).toHaveLength(1); expect(data[medId].refills).toHaveLength(1);
expect(data[medId].refills[0]).toMatchObject({ expect(data[medId].refills[0]).toMatchObject({
packsAdded: 2, packsAdded: 2,
loosePillsAdded: 5, loosePillsAdded: 5,
quantityAdded: 7,
usedPrescription: true, usedPrescription: true,
}); });
}); });
@@ -375,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({ expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1, packsAdded: 1,
loosePillsAdded: 0, loosePillsAdded: 0,
quantityAdded: 1,
usedPrescription: false, usedPrescription: false,
}); });
}); });
@@ -2442,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
expect(med.stockAdjustment).toBe(0); expect(med.stockAdjustment).toBe(0);
}); });
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Liquid Stale Client Stock Correction",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 7,
packageAmountValue: 150,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 1050,
looseTablets: 1050,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: 0,
packCount: 1,
totalPills: 150,
},
});
expect(correctionResponse.statusCode).toBe(200);
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterCorrectionResponse.statusCode).toBe(200);
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(correctedMed).toBeTruthy();
expect(correctedMed.packCount).toBe(1);
expect(correctedMed.totalPills).toBe(150);
expect(correctedMed.looseTablets).toBe(150);
expect(correctedMed.stockAdjustment).toBe(0);
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.quantityAdded).toBe(150);
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(300);
expect(refillData.newStock.totalPills).toBe(300);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0].quantityAdded).toBe(150);
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterRefillResponse.statusCode).toBe(200);
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(refilledMed).toBeTruthy();
expect(refilledMed.packCount).toBe(2);
expect(refilledMed.totalPills).toBe(300);
expect(refilledMed.looseTablets).toBe(300);
});
it("should persist stockAdjustment in GET /medications", async () => { it("should persist stockAdjustment in GET /medications", async () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
@@ -3047,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
}; };
async function expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded,
expectedAmountPerPackage,
}: {
medId: number;
refillData: {
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
newStock: { packCount: number; totalPills: number; looseTablets: number };
};
visibleStockBeforeRefill: number;
expectedQuantityAdded: number;
expectedPacksAdded: number;
expectedAmountPerPackage?: number;
}) {
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
quantityAdded: expectedQuantityAdded,
totalPillsAdded: expectedQuantityAdded,
});
if (expectedAmountPerPackage) {
expect(refillData.newStock.packCount).toBe(
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
);
}
}
it("should create and return bottle type medication", async () => { it("should create and return bottle type medication", async () => {
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -3240,6 +3359,196 @@ describe("E2E Tests with Real Routes", () => {
}); });
}); });
it.each([
{
name: "bottle",
payload: {
...bottleMedication,
totalPills: 100,
looseTablets: 10,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
expectedVisibleStockBeforeRefill: 10,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 0,
expectedPackCount: 0,
expectedLooseTablets: 110,
expectedTotalPills: 110,
expectedPersistedTotalPills: 100,
expectedStockAdjustment: 0,
},
{
name: "blister",
payload: {
...blisterMedication,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 10,
expectedQuantityAdded: 10,
expectedResponsePacksAdded: 1,
expectedPackCount: 2,
expectedLooseTablets: 0,
expectedTotalPills: 20,
expectedPersistedTotalPills: null,
expectedStockAdjustment: 0,
},
{
name: "liquid_container",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 100,
packageAmountUnit: "ml",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 10,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 1,
expectedAmountPerPackage: 100,
expectedPackCount: 2,
expectedLooseTablets: 110,
expectedTotalPills: 110,
expectedPersistedTotalPills: 110,
expectedStockAdjustment: 0,
},
])("should refill from the persisted stock baseline after prior consumption for $name", async ({
payload,
refillPayload,
expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedResponsePacksAdded,
expectedAmountPerPackage,
expectedPackCount,
expectedLooseTablets,
expectedTotalPills,
expectedPersistedTotalPills,
expectedStockAdjustment,
}) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
for (let day = 1; day <= 6; day += 1) {
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
});
}
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: refillPayload,
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded: expectedResponsePacksAdded,
expectedAmountPerPackage,
});
expect(refillData.newStock.packCount).toBe(expectedPackCount);
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(expectedPackCount);
expect(med.looseTablets).toBe(expectedLooseTablets);
expect(med.totalPills).toBe(expectedPersistedTotalPills);
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
});
it("should refill tube stock from the corrected visible baseline", async () => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...tubeMedication,
packCount: 1,
packageAmountValue: 80,
packageAmountUnit: "g",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: -6,
looseTablets: 10,
totalPills: 10,
packageAmountValue: 80,
packCount: 1,
},
});
expect(correctionResponse.statusCode).toBe(200);
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();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 4,
expectedQuantityAdded: 80,
expectedPacksAdded: 1,
expectedAmountPerPackage: 80,
});
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(84);
expect(refillData.newStock.totalPills).toBe(84);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(2);
expect(med.looseTablets).toBe(84);
expect(med.totalPills).toBe(84);
expect(med.stockAdjustment).toBe(0);
});
it("should calculate correct refill totalPillsAdded for blister type", async () => { it("should calculate correct refill totalPillsAdded for blister type", async () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
@@ -3271,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
}); });
it("should keep liquid_container refill additive and preserve amount baseline", async () => { it("should keep liquid_container refill additive and preserve amount baseline", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
url: "/medications", url: "/medications",
@@ -3293,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1); await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 180,
expectedQuantityAdded: 180,
expectedPacksAdded: 1,
expectedAmountPerPackage: 180,
});
expect(refillData.refill.loosePillsAdded).toBe(180); expect(refillData.refill.loosePillsAdded).toBe(180);
expect(refillData.refill.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360); expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" }); const medsResponse = await app.inject({ method: "GET", url: "/medications" });
@@ -3306,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
expect(med.looseTablets).toBe(360); expect(med.looseTablets).toBe(360);
}); });
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packCount: 0,
packageAmountValue: 150,
totalPills: 300,
looseTablets: 300,
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 5, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 300,
expectedQuantityAdded: 750,
expectedPacksAdded: 5,
expectedAmountPerPackage: 150,
});
expect(refillData.newStock.packCount).toBe(7);
expect(refillData.newStock.totalPills).toBe(1050);
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.packCount).toBe(7);
expect(med.totalPills).toBe(1050);
expect(med.looseTablets).toBe(1050);
});
it.each([ it.each([
{ {
name: "liquid_container", name: "liquid_container",
@@ -3322,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1, prescriptionLowRefillThreshold: 1,
}, },
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true }, refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1, expectedPacksAdded: 1,
expectedLooseAdded: 180, expectedLooseAdded: 180,
expectedRemainingRefills: 1, expectedRemainingRefills: 1,
expectedTotalPills: 360, expectedTotalPills: 360,
expectedAmountPerPackage: 180,
}, },
{ {
name: "tube", name: "tube",
@@ -3337,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1, prescriptionLowRefillThreshold: 1,
}, },
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true }, refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
expectedVisibleStockBeforeRefill: 80,
expectedPacksAdded: 2, expectedPacksAdded: 2,
expectedLooseAdded: 80, expectedLooseAdded: 80,
expectedRemainingRefills: 1, expectedRemainingRefills: 1,
expectedTotalPills: 160, expectedTotalPills: 160,
expectedAmountPerPackage: 40,
}, },
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({ ])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
payload, payload,
refillPayload, refillPayload,
expectedVisibleStockBeforeRefill,
expectedPacksAdded, expectedPacksAdded,
expectedLooseAdded, expectedLooseAdded,
expectedRemainingRefills, expectedRemainingRefills,
expectedTotalPills, expectedTotalPills,
expectedAmountPerPackage,
}) => { }) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
url: "/medications", url: "/medications",
@@ -3366,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded: expectedLooseAdded,
expectedPacksAdded,
expectedAmountPerPackage,
});
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded); expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded); expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded); expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
expect(refillData.prescription.used).toBe(true); expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills); expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
@@ -3381,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
expect(historyResponse.json()[0]).toMatchObject({ expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded, packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded, loosePillsAdded: expectedLooseAdded,
quantityAdded: expectedLooseAdded,
usedPrescription: true, usedPrescription: true,
}); });
}); });
@@ -3402,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1); await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 80,
expectedQuantityAdded: 40,
expectedPacksAdded: 1,
expectedAmountPerPackage: 40,
});
expect(refillData.refill.loosePillsAdded).toBe(40); expect(refillData.refill.loosePillsAdded).toBe(40);
expect(refillData.refill.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120); expect(refillData.newStock.totalPills).toBe(120);
const medsResponse = await app.inject({ method: "GET", url: "/medications" }); const medsResponse = await app.inject({ method: "GET", url: "/medications" });
+25 -14
View File
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"), NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z PORT: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("3000")
.default("3000"), .transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"), LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
AUTH_ENABLED: z AUTH_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
REGISTRATION_ENABLED: z REGISTRATION_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
JWT_SECRET: z.string().min(10).optional(), JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(), REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(), COOKIE_SECRET: z.string().min(10).optional(),
ACCESS_TOKEN_TTL_MINUTES: z ACCESS_TOKEN_TTL_MINUTES: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("15")
.default("15"), .transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z REFRESH_TOKEN_TTL_DAYS: z
.string() .string()
.transform((v) => parseInt(v, 10)) .default("7")
.default("7"), .transform((v) => parseInt(v, 10)),
OIDC_ENABLED: z OIDC_ENABLED: z
.string() .string()
.transform((v) => v === "true") .default("false")
.default("false"), .transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(), OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(),
@@ -44,8 +45,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"), OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z OIDC_AUTO_CREATE_USERS: z
.string() .string()
.transform((v) => v === "true") .default("true")
.default("true"), .transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
OIDC_PROVIDER_NAME: z.string().default("SSO"), OIDC_PROVIDER_NAME: z.string().default("SSO"),
}); });
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
expect(result.PORT).toBe(3000); expect(result.PORT).toBe(3000);
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173"); expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
expect(result.LOG_LEVEL).toBe("info"); expect(result.LOG_LEVEL).toBe("info");
expect(result.PUBLIC_APP_URL).toBeUndefined();
expect(result.AUTH_ENABLED).toBe(false); expect(result.AUTH_ENABLED).toBe(false);
expect(result.REGISTRATION_ENABLED).toBe(false); expect(result.REGISTRATION_ENABLED).toBe(false);
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15); expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
}); });
describe("OIDC URL validation", () => { describe("OIDC URL validation", () => {
it("should accept valid PUBLIC_APP_URL", () => {
const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" });
expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com");
});
it("should reject invalid PUBLIC_APP_URL", () => {
expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow();
});
it("should accept valid OIDC_ISSUER_URL", () => { it("should accept valid OIDC_ISSUER_URL", () => {
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" }); const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com"); expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
+40
View File
@@ -411,6 +411,7 @@ describe("Export/Import API", () => {
expect(data.settings.notificationEmail).toBe("test@example.com"); expect(data.settings.notificationEmail).toBe("test@example.com");
expect(data.settings.language).toBe("de"); expect(data.settings.language).toBe("de");
expect(data.settings.lowStockDays).toBe(14); expect(data.settings.lowStockDays).toBe(14);
expect(data.settings.shareStockStatus).toBeUndefined();
}); });
it("should exclude sensitive data by default", async () => { it("should exclude sensitive data by default", async () => {
@@ -557,6 +558,45 @@ describe("Export/Import API", () => {
expect(result.rows[0].loose_tablets).toBe(5); expect(result.rows[0].loose_tablets).toBe(5);
}); });
it("accepts legacy shareStockStatus in imported settings but does not export or use it", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [],
doseHistory: [],
refillHistory: [],
settings: {
language: "de",
stockCalculationMode: "automatic",
shareStockStatus: false,
},
shareLinks: [],
};
const importResponse = await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(importResponse.statusCode).toBe(200);
const exportResponse = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(exportResponse.statusCode).toBe(200);
expect(exportResponse.json().settings.shareStockStatus).toBeUndefined();
const settingsRow = await ctx.client.execute({
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = ?",
args: [userId],
});
expect(settingsRow.rows[0].share_medication_overview).toBe(0);
expect(settingsRow.rows[0].share_stock_status).toBe(1);
});
it("should replace existing data on import", async () => { it("should replace existing data on import", async () => {
// Create existing medication // Create existing medication
await createTestMedication(ctx.client, { await createTestMedication(ctx.client, {
+87
View File
@@ -0,0 +1,87 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import sharp from "sharp";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getThumbFilename,
MAX_IMAGE_UPLOAD_BYTES,
removeImageFiles,
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload";
describe("image-upload utils", () => {
const MOCK_TIMESTAMP_MS = 1_700_000_000_000;
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("builds thumb filename with and without extension", () => {
expect(getThumbFilename("avatar.png")).toBe("avatar-thumb.webp");
expect(getThumbFilename("avatar")).toBe("avatar-thumb.webp");
});
it("removes original and thumb files when they exist", () => {
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
tempDirs.push(imagesDir);
const imageFilename = "profile.webp";
const imagePath = join(imagesDir, imageFilename);
const thumbPath = join(imagesDir, getThumbFilename(imageFilename));
writeFileSync(imagePath, Buffer.from("image"));
writeFileSync(thumbPath, Buffer.from("thumb"));
removeImageFiles(imagesDir, imageFilename);
expect(() => readFileSync(imagePath)).toThrow();
expect(() => readFileSync(thumbPath)).toThrow();
});
it("buffers stream chunks and rejects payloads above max size", async () => {
const stream = Readable.from([Buffer.from("hello"), Buffer.from("world")]);
await expect(streamToBuffer(stream)).resolves.toEqual(Buffer.from("helloworld"));
const oversized = Readable.from([Buffer.alloc(MAX_IMAGE_UPLOAD_BYTES + 1)]);
await expect(streamToBuffer(oversized)).rejects.toThrow("IMAGE_TOO_LARGE");
});
it("writes optimized full and thumbnail webp variants", async () => {
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
tempDirs.push(imagesDir);
vi.spyOn(Date, "now").mockReturnValue(MOCK_TIMESTAMP_MS);
const uploadBuffer = await sharp({
create: {
width: 64,
height: 48,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const result = await writeOptimizedImageSet(imagesDir, "med-42", uploadBuffer, {
maxEdgePx: 32,
thumbSizePx: 16,
});
expect(result.filename).toBe("med-42-1700000000000.webp");
expect(result.thumbFilename).toBe("med-42-1700000000000-thumb.webp");
const optimizedMeta = await sharp(join(imagesDir, result.filename)).metadata();
const thumbMeta = await sharp(join(imagesDir, result.thumbFilename)).metadata();
expect(optimizedMeta.format).toBe("webp");
expect(thumbMeta.format).toBe("webp");
expect(Math.max(optimizedMeta.width ?? 0, optimizedMeta.height ?? 0)).toBeLessThanOrEqual(32);
expect(thumbMeta.width).toBe(16);
expect(thumbMeta.height).toBe(16);
});
});
@@ -0,0 +1,715 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockedEnv,
createNotificationActionContextMock,
storeNotificationActionGroupNtfyMessageIdMock,
sendPushNotificationMock,
} = vi.hoisted(() => ({
mockedEnv: {
PUBLIC_APP_URL: undefined as string | undefined,
CORS_ORIGINS: "http://localhost:5173" as string,
},
createNotificationActionContextMock: vi.fn(),
storeNotificationActionGroupNtfyMessageIdMock: vi.fn(),
sendPushNotificationMock: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: () => false,
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock("../db/path-utils.js", () => ({
getDataDir: () => "/tmp",
}));
vi.mock("../db/client.js", () => ({
db: {
select: vi.fn(),
insert: vi.fn(),
},
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("../services/notification-actions-service.js", () => ({
createNotificationActionContext: createNotificationActionContextMock,
storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock,
}));
vi.mock("../services/notifications/delivery.js", () => ({
getSmtpConfig: vi.fn(() => null),
sendEmailNotification: vi.fn(),
sendPushNotification: sendPushNotificationMock,
}));
vi.mock("../services/notifications/state.js", () => ({
updateReminderSentTime: vi.fn(),
updateUserReminderSentTime: vi.fn(),
}));
vi.mock("../utils/scheduler-utils.js", async () => {
const actual = await vi.importActual<typeof import("../utils/scheduler-utils.js")>("../utils/scheduler-utils.js");
const candidate = {
medName: "Calcium",
intakeTime: new Date("2026-01-05T11:15:00.000Z"),
intakeTimeStr: "11:15",
usage: 1,
takenBy: null,
pillWeightMg: null,
doseUnit: "mg",
};
return {
...actual,
getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
getDateLocale: () => "en-US",
parseTakenByJson: () => [],
parseIntakesJson: () => [
{
usage: 1,
every: 1,
start: "2026-01-05T10:45:00.000Z",
takenBy: null,
intakeRemindersEnabled: true,
},
],
getTodaysIntakes: () => [candidate],
getUpcomingIntakes: () => [candidate],
};
});
import { db } from "../db/client.js";
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
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("intake reminder scheduler action wiring", () => {
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;
mockedEnv.PUBLIC_APP_URL = undefined;
mockedEnv.CORS_ORIGINS = "http://localhost:5173";
createNotificationActionContextMock.mockReset();
storeNotificationActionGroupNtfyMessageIdMock.mockReset();
sendPushNotificationMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
if (originalTz === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTz;
}
});
it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 11,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 41,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 11,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 11,
publicAppUrl: "https://app.example.com",
language: "en",
actionMode: "full",
doseIds: [expect.stringMatching(/^7-0-/)],
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
clickUrl: "https://app.example.com/api/notification-actions/respond",
sequenceId: "medassist-sequence",
tags: ["pill"],
priority: 3,
})
);
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1");
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("uses view-only actions for grouped intake reminders", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 13,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
{
id: 8,
userId: 13,
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
method: "GET",
},
],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
});
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 13,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 13,
publicAppUrl: "https://app.example.com",
language: "en",
actionMode: "view-only",
doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)],
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
method: "GET",
},
],
respondUrl: undefined,
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
sequenceId: undefined,
tags: ["pill"],
priority: 3,
})
);
});
it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => {
createNotificationActionContextMock.mockResolvedValue(null);
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 12,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 12,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: 12,
publicAppUrl: undefined,
})
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: undefined,
respondUrl: undefined,
viewUrl: undefined,
clickUrl: undefined,
tags: ["pill"],
priority: 3,
})
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions")
);
});
it("falls back to push delivery without actions when action context generation fails", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 15,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed"));
sendPushNotificationMock.mockResolvedValue({ success: true });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 15,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(sendPushNotificationMock).toHaveBeenCalledWith(
"ntfy://ntfy.sh/medassist",
expect.any(String),
expect.any(String),
expect.objectContaining({
actions: undefined,
respondUrl: undefined,
viewUrl: undefined,
clickUrl: undefined,
})
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed"));
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Sending intake reminders without actions after action context failure")
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("logs enriched push delivery failures with action context metadata", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 16,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 52,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" });
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 16,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy"));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full"));
expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled();
});
it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => {
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 17,
name: "Calcium",
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: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
createNotificationActionContextMock.mockResolvedValue({
groupId: 77,
actions: [
{
kind: "taken",
label: "Taken",
url: "https://app.example.com/api/notification-actions/taken",
method: "POST",
},
],
respondUrl: "https://app.example.com/api/notification-actions/respond",
viewUrl: "https://app.example.com/?date=2026-01-05",
sequenceId: "medassist-sequence",
});
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" });
storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed"));
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 17,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77");
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id"));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
});
it("does not send intake reminders for reminder-enabled medications with empty stock", async () => {
const selectMock = vi.mocked(mockedDb.select);
selectMock
.mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }]))
.mockImplementationOnce(() =>
mockSelectWhere([
{
id: 7,
userId: 14,
name: "Calcium",
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: true,
intakesJson: "[]",
usageJson: "[]",
everyJson: "[]",
startJson: "[]",
},
])
)
.mockImplementationOnce(() => mockSelectWhere([]));
const logger = createLogger();
await checkAndSendIntakeRemindersForUser(
{
userId: 14,
language: "en",
stockCalculationMode: "manual",
emailEnabled: false,
notificationEmail: null,
emailIntakeReminders: false,
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
shoutrrrIntakeReminders: true,
repeatRemindersEnabled: false,
} as never,
logger as never
);
expect(createNotificationActionContextMock).not.toHaveBeenCalled();
expect(sendPushNotificationMock).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Skipping reminder-enabled medications with empty stock")
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("No reminder-eligible medications with stock remaining")
);
});
});
+1
View File
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
getNotificationActionLabels,
isNtfyNotificationUrl,
type PushNotificationAction,
renderNotificationActionPayload,
} from "../services/notifications/action-renderer.js";
function decodeRfc2047Base64(value: string): string {
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
if (!match) {
return value;
}
return Buffer.from(match[1], "base64").toString("utf-8");
}
const actions: PushNotificationAction[] = [
{
kind: "taken",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
},
{
kind: "skip",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
];
describe("notification action renderer", () => {
it("builds ntfy native actions without duplicate click headers", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
tags: ["pill"],
priority: 4,
sequenceId: "medassist-sequence",
});
expect(result.message).toBe("Body");
expect(result.headers).toMatchObject({
Tags: "pill",
Priority: "4",
"X-Sequence-ID": "medassist-sequence",
});
expect(result.headers.Click).toBeUndefined();
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
expect(parsedActions).toEqual([
{
action: "http",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
{
action: "http",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "View",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("keeps the ntfy click header when there are no native actions", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
});
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
});
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
expect(result.message).toBe("Body");
expect(result.headers.Actions).toBeTruthy();
expect(result.message).not.toContain("Respond:");
});
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "taken",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
},
],
});
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
{
action: "http",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
]);
});
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "skip",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{
kind: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
method: "GET",
},
],
});
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
{
action: "http",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("uses consistent action-form labels for English and German", () => {
expect(getNotificationActionLabels("en")).toEqual({
taken: "Take",
skip: "Skip",
respond: "Respond",
view: "View",
});
expect(getNotificationActionLabels("de")).toEqual({
taken: "Einnehmen",
skip: "Überspringen",
respond: "Antworten",
view: "Öffnen",
});
});
it("appends respond and view fallback links for non-ntfy providers", () => {
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(result.headers).toEqual({});
expect(result.message).toBe(
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
);
});
});
@@ -0,0 +1,587 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify 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, fetchMock, mockLogger } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
silent: vi.fn(),
level: "info",
child: vi.fn(),
};
logger.child.mockImplementation(() => logger);
return {
testClient: client,
testDb: db,
fetchMock: vi.fn(),
mockLogger: logger,
mockedEnv: {
AUTH_ENABLED: false,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
PUBLIC_APP_URL: "https://app.example.com",
OPENAPI_DOCS_ENABLED: false,
},
};
});
global.fetch = fetchMock;
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { notificationActionRoutes } = await import("../routes/notification-actions.js");
const { createNotificationActionContext } = await import("../services/notification-actions-service.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
function extractToken(url: string): string {
return url.split("/").at(-1) ?? "";
}
async function clearTables() {
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM notification_action_tokens");
await testClient.execute("DELETE FROM notification_action_groups");
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],
});
return Number(result.rows[0].id);
}
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
const start = "2026-01-05T08: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 (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`,
args: [
options.id,
options.userId,
options.packCount ?? 1,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]),
],
});
}
async function insertUserSettings(
userId: number,
stockCalculationMode: "automatic" | "manual" = "automatic",
overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {}
) {
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)",
args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null],
});
}
async function seedContext(options: { userId: number; doseId: string }) {
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId: options.userId,
title: "Reminder",
message: "Take your medication now",
doseIds: [options.doseId],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
return {
respondToken: extractToken(context!.respondUrl!),
takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url),
skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url),
context: context!,
};
}
describe("notification action routes", () => {
let app: Awaited<ReturnType<typeof Fastify>>;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv });
await app.register(notificationActionRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTables();
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
mockedEnv.NODE_ENV = "test";
fetchMock.mockReset();
fetchMock.mockResolvedValue({ ok: true });
mockLogger.info.mockClear();
mockLogger.warn.mockClear();
mockLogger.error.mockClear();
mockLogger.debug.mockClear();
mockLogger.trace.mockClear();
mockLogger.fatal.mockClear();
mockLogger.child.mockClear();
});
it("renders HTML for respond tokens without mutating state", async () => {
const userId = await createUser("notification-route-get");
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const response = await app.inject({
method: "GET",
url: `/notification-actions/${respondToken}`,
headers: { accept: "text/html" },
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body).toContain("Respond to reminder");
expect(response.body).toContain("Take your medication now");
const rows = await testClient.execute({
sql: `SELECT g.resolved_action, t.used_at
FROM notification_action_groups g
INNER JOIN notification_action_tokens t ON t.group_id = g.id
WHERE t.kind = 'respond'`,
});
expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]);
});
it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => {
const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" });
expect(missing.statusCode).toBe(404);
const userId = await createUser("notification-route-errors");
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const nonRespond = await app.inject({
method: "GET",
url: `/notification-actions/${takenToken}`,
headers: { accept: "text/html" },
});
expect(nonRespond.statusCode).toBe(405);
expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" });
await testClient.execute({
sql: "UPDATE notification_action_groups SET expires_at = ?",
args: [new Date(0)],
});
const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` });
expect(expired.statusCode).toBe(410);
expect(expired.json()).toEqual({ error: "Notification action has expired" });
});
it("shows an already-processed HTML state for resolved respond tokens", async () => {
const userId = await createUser("notification-route-resolved");
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
await testClient.execute({
sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?",
args: [new Date()],
});
const response = await app.inject({
method: "GET",
url: `/notification-actions/${respondToken}`,
headers: { accept: "text/html" },
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain("Already processed");
expect(response.body).toContain(
"This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there."
);
});
it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => {
const userId = await createUser("notification-route-skip");
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}`,
payload: { action: "skip" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, action: "skip" });
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
userId,
groupId: expect.any(Number),
tokenKind: "respond",
doseCount: 1,
hasViewUrl: true,
requestedAction: "skip",
}),
"[NotificationActions] Recorded notification action"
);
const dismissedRow = await testClient.execute({
sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, "5-0-1736064000000"],
});
expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]);
const groupRow = await testClient.execute({
sql: "SELECT resolved_action FROM notification_action_groups",
});
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
const conflict = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(conflict.statusCode).toBe(200);
expect(conflict.json()).toEqual({
success: true,
action: "skip",
alreadyProcessed: true,
message: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
});
});
it("keeps legacy dismiss respond actions working as a skip alias", async () => {
const userId = await createUser("notification-route-dismiss-alias");
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}`,
payload: { action: "dismiss" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, action: "skip" });
const groupRow = await testClient.execute({
sql: "SELECT resolved_action FROM notification_action_groups",
});
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
});
it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => {
const userId = await createUser("notification-route-taken-followup");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const firstResponse = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(firstResponse.statusCode).toBe(200);
expect(firstResponse.json()).toEqual({ success: true, action: "taken" });
const followUpHtml = await app.inject({
method: "GET",
url: `/notification-actions/${respondToken}`,
headers: { accept: "text/html" },
});
expect(followUpHtml.statusCode).toBe(200);
expect(followUpHtml.body).toContain(
"This dose is already marked as taken. If you need to change it, open MedAssist and undo it there."
);
const followUpJson = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}`,
payload: { action: "skip" },
});
expect(followUpJson.statusCode).toBe(200);
expect(followUpJson.json()).toEqual({
success: true,
action: "taken",
alreadyProcessed: true,
message: "This dose is already marked as taken. Changes can only be made in MedAssist.",
});
});
it("replaces the original ntfy notification after a successful action with a view-only confirmation", async () => {
const userId = await createUser("notification-route-ntfy-delete");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", {
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
});
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
expect(requestInit).toEqual(
expect.objectContaining({
method: "POST",
body: "Take your medication now\n\n✅ This dose was marked as taken.",
redirect: "error",
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
"X-Sequence-ID": context.sequenceId,
}),
})
);
const actionHeader = String((requestInit as { headers?: Record<string, string> }).headers?.Actions ?? "[]");
expect(JSON.parse(actionHeader)).toEqual([
{
action: "view",
label: "View",
url: context.viewUrl,
clear: false,
},
]);
});
it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => {
const userId = await createUser("notification-route-ntfy-skip-delete");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", {
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
});
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${skipToken}`,
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
expect(requestInit).toEqual(
expect.objectContaining({
method: "POST",
body: "Take your medication now\n\n⏭️ This intake was marked as skipped.",
redirect: "error",
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
"X-Sequence-ID": context.sequenceId,
}),
})
);
});
it("warns when ntfy replacement, delete, and fallback clear all fail", async () => {
const userId = await createUser("notification-route-ntfy-delete-warn");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", {
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
});
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") });
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(app.log.warn).toHaveBeenCalledWith(
expect.objectContaining({ requestedAction: "taken" }),
expect.stringContaining("Failed to replace ntfy notification after resolved action")
);
expect(app.log.warn).toHaveBeenCalledWith(
expect.objectContaining({ requestedAction: "taken" }),
expect.stringContaining("Failed to delete ntfy notification after resolved action")
);
expect(app.log.warn).toHaveBeenCalledWith(
expect.objectContaining({ requestedAction: "taken" }),
expect.stringContaining("Failed to clear ntfy notification after delete fallback")
);
});
it("falls back to clear when ntfy replacement and delete both fail", async () => {
const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", {
shoutrrrEnabled: true,
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
});
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") });
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
const [clearUrl, clearInit] = fetchMock.mock.calls[2] ?? [];
expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
expect(clearInit).toEqual(
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
})
);
});
it("allows browser-origin CORS requests for public notification action tokens", async () => {
const userId = await createUser("notification-route-cors");
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const preflight = await app.inject({
method: "OPTIONS",
url: `/notification-actions/${respondToken}?action=taken`,
headers: {
origin: "https://ntfy.danielvolz.org",
"access-control-request-method": "POST",
"access-control-request-headers": "content-type",
},
});
expect(preflight.statusCode).toBe(204);
expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
expect(preflight.headers["access-control-allow-methods"]).toContain("POST");
expect(preflight.headers["access-control-allow-headers"]).toContain("content-type");
const response = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}`,
headers: {
origin: "https://ntfy.danielvolz.org",
},
payload: { action: "skip" },
});
expect(response.statusCode).toBe(200);
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
});
it("accepts standard HTML form posts on respond pages", async () => {
const userId = await createUser("notification-route-form-post");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}?action=taken`,
headers: {
accept: "text/html",
"content-type": "application/x-www-form-urlencoded",
},
payload: "",
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body).toContain("Action recorded");
expect(response.body).toContain("The dose was marked as taken.");
});
it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => {
const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" });
expect(missing.statusCode).toBe(404);
const expiredUserId = await createUser("notification-route-expired");
const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" });
await testClient.execute({
sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?",
args: [new Date(0), expiredUserId],
});
const expired = await app.inject({
method: "POST",
url: `/notification-actions/${respondToken}`,
payload: { action: "skip" },
});
expect(expired.statusCode).toBe(410);
const userId = await createUser("notification-route-stock");
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
const outOfStock = await app.inject({
method: "POST",
url: `/notification-actions/${takenToken}`,
});
expect(outOfStock.statusCode).toBe(409);
expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({
userId,
groupId: expect.any(Number),
tokenKind: "taken",
doseCount: 1,
hasViewUrl: true,
requestedAction: "taken",
failedDoseIndex: 0,
code: "OUT_OF_STOCK",
}),
"[NotificationActions] Failed to record taken notification action"
);
const state = await testClient.execute({
sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]);
});
});
@@ -0,0 +1,225 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
PUBLIC_APP_URL: "https://app.example.com",
CORS_ORIGINS: "http://localhost:5173,http://localhost:4173",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import(
"../services/notification-actions-service.js"
);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
function extractToken(url: string): string {
return url.split("/").at(-1) ?? "";
}
async function clearTables() {
await testClient.execute("DELETE FROM notification_action_tokens");
await testClient.execute("DELETE FROM notification_action_groups");
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);
}
describe("notification-actions-service", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
});
afterAll(() => {
testClient.close();
});
beforeEach(async () => {
await clearTables();
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173";
});
it("creates a notification action group with hashed tokens and app/view links", async () => {
const userId = await createUser("notify-actions-user");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: expect.stringContaining("/api/notification-actions/"),
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
sequenceId: expect.stringMatching(/^medassist-/),
});
expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]);
const groups = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(Number(groups.rows[0].count)).toBe(1);
const tokenRows = await testClient.execute({
sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC",
});
expect(tokenRows.rows).toHaveLength(3);
const respondToken = extractToken(context!.respondUrl!);
const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond");
expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" }));
expect(respondRow?.token_hash).not.toBe(respondToken);
const record = await getNotificationActionTokenRecord(respondToken);
expect(record).toMatchObject({
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
});
it("creates a view-only context without mutation tokens", async () => {
const userId = await createUser("notify-actions-view-only");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Grouped reminder",
message: "Open the dashboard for details",
doseIds: ["9-0-1736064000000", "10-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
actionMode: "view-only",
});
expect(context).toEqual({
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
method: "GET",
},
],
});
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
expect(Number(groups.rows[0].count)).toBe(0);
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(0);
});
it("reuses an unresolved active group for the same dose set and schedule", async () => {
const userId = await createUser("notify-actions-reuse");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const first = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
const second = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(second?.sequenceId).toBe(first?.sequenceId);
const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups");
expect(groups.rows).toHaveLength(1);
expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId }));
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(6);
});
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
const userId = await createUser("notify-actions-mobile");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
mockedEnv.PUBLIC_APP_URL = "http://localhost:5173";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173";
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`,
viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!));
expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000");
});
it("falls back to the date view when dose ids do not contain a medication id", async () => {
const userId = await createUser("notify-actions-fallback");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["invalid-dose-id"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id");
});
});
+3 -3
View File
@@ -117,7 +117,7 @@ describe("OIDC routes", () => {
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied"); expect(res.headers.location).toBe("http://localhost:5173");
} finally { } finally {
await app.close(); await app.close();
} }
@@ -129,7 +129,7 @@ describe("OIDC routes", () => {
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" }); const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params"); expect(res.headers.location).toBe("http://localhost:5173");
} finally { } finally {
await app.close(); await app.close();
} }
@@ -144,7 +144,7 @@ describe("OIDC routes", () => {
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch"); expect(res.headers.location).toBe("http://localhost:5173");
} finally { } finally {
await app.close(); await app.close();
} }
+1
View File
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
+100 -4
View File
@@ -16,6 +16,8 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
OIDC_ENABLED: false, OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO", OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test", NODE_ENV: "test",
PUBLIC_APP_URL: "https://app.example.com",
CORS_ORIGINS: "https://app.example.com",
}; };
return { return {
testClient: client, testClient: client,
@@ -351,7 +353,7 @@ describe("Real route coverage: settings/export/report", () => {
}); });
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => { it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
fetchMock.mockResolvedValue({ ok: true }); fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" }); expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, requestInit] = fetchMock.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers["X-Sequence-ID"]).toEqual(expect.stringMatching(/^medassist-/));
expect(JSON.parse(headers.Actions ?? "[]")).toEqual([
{
action: "http",
label: "Take",
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
method: "POST",
clear: true,
},
{
action: "http",
label: "Skip",
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
method: "POST",
clear: true,
},
{
action: "view",
label: "View",
url: "https://app.example.com/dashboard",
clear: false,
},
]);
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
expect(Number(groups.rows[0].count)).toBe(1);
const storedGroup = await testClient.execute(
"SELECT ntfy_original_message_id FROM notification_action_groups LIMIT 1"
);
expect(storedGroup.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-test-message-id" })]);
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(3);
}); });
it("sendShoutrrrNotification blocks localhost/private targets", async () => { it("sendShoutrrrNotification blocks localhost/private targets", async () => {
@@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => {
}); });
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => { it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
fetchMock.mockResolvedValue({ ok: true }); fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-message-id" }) });
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message"); const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.providerMessageId).toBe("ntfy-message-id");
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/mytopic", "https://ntfy.sh/mytopic",
expect.objectContaining({ expect.objectContaining({
@@ -589,8 +630,39 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const body = response.json(); const body = response.json();
expect(body[medId].dosesTaken).toBe(1); expect(body[medId].dosesTaken).toBe(1);
expect(body[medId].dosesDismissed).toBe(1); expect(body[medId].dosesSkipped).toBe(1);
expect(body[medId].refills).toHaveLength(1); expect(body[medId].refills).toHaveLength(1);
expect(body[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 2,
usedPrescription: true,
});
});
it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => {
const medId = await seedMedication("Report Filter Med");
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000000000-Alice`, 1700000000, 0],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700001200000-Bob`, 1700001200, 0],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId], takenByFilter: ["Alice"] },
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body[medId].dosesTaken).toBe(1);
expect(body[medId].dosesSkipped).toBe(1);
}); });
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => { it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
@@ -621,7 +693,9 @@ describe("Real route coverage: settings/export/report", () => {
expect(body.medications).toHaveLength(1); expect(body.medications).toHaveLength(1);
expect(body.doseHistory).toHaveLength(1); expect(body.doseHistory).toHaveLength(1);
expect(body.refillHistory).toHaveLength(1); expect(body.refillHistory).toHaveLength(1);
expect(body.refillHistory[0].quantityAdded).toBe(23);
expect(body.settings.language).toBe("de"); expect(body.settings.language).toBe("de");
expect(body.settings.shareStockStatus).toBeUndefined();
expect(body.shareLinks).toHaveLength(1); expect(body.shareLinks).toHaveLength(1);
}); });
@@ -672,7 +746,15 @@ describe("Real route coverage: settings/export/report", () => {
}, },
], ],
doseHistory: [], doseHistory: [],
refillHistory: [], refillHistory: [
{
medicationRef: "med-1",
packsAdded: 0,
quantityAdded: 4,
usedPrescription: false,
refillDate: "2026-01-02T08:00:00.000Z",
},
],
settings: { settings: {
emailEnabled: false, emailEnabled: false,
notificationEmail: null, notificationEmail: null,
@@ -708,10 +790,24 @@ describe("Real route coverage: settings/export/report", () => {
}); });
expect(valid.statusCode).toBe(200); expect(valid.statusCode).toBe(200);
expect(valid.json().imported.medications).toBe(1); expect(valid.json().imported.medications).toBe(1);
expect(valid.json().imported.refillHistory).toBe(1);
const rows = await testClient.execute({ const rows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = 1", sql: "SELECT name FROM medications WHERE user_id = 1",
}); });
expect(rows.rows[0].name).toBe("Imported Med"); expect(rows.rows[0].name).toBe("Imported Med");
const refillRows = await testClient.execute({
sql: "SELECT packs_added, loose_pills_added FROM refill_history WHERE user_id = 1",
});
expect(refillRows.rows).toHaveLength(1);
expect(refillRows.rows[0].packs_added).toBe(0);
expect(refillRows.rows[0].loose_pills_added).toBe(4);
const importedSettings = await testClient.execute({
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = 1",
});
expect(importedSettings.rows[0].share_medication_overview).toBe(0);
expect(importedSettings.rows[0].share_stock_status).toBe(1);
}); });
}); });
+40
View File
@@ -244,6 +244,46 @@ describe("Server Bootstrap", () => {
await app.close(); await app.close();
}); });
it("should allow browser preflight requests on public notification action routes", async () => {
const origins = ["https://medtest.danielvolz.org"];
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cors, {
delegator: (request, callback) => {
if (request.raw.url?.startsWith("/notification-actions/")) {
callback(null, {
origin: true,
credentials: false,
methods: ["GET", "HEAD", "POST", "OPTIONS"],
});
return;
}
callback(null, { origin: origins, credentials: true });
},
});
app.post("/notification-actions/:token", async () => ({ ok: true }));
await app.ready();
const response = await app.inject({
method: "OPTIONS",
url: "/notification-actions/demo-token",
headers: {
origin: "https://ntfy.danielvolz.org",
"access-control-request-method": "POST",
"access-control-request-headers": "content-type",
},
});
expect(response.statusCode).toBe(204);
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
expect(response.headers["access-control-allow-credentials"]).toBeUndefined();
expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS");
await app.close();
});
it("should register cookie plugin", async () => { it("should register cookie plugin", async () => {
const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(cookie, { secret: "test-cookie-secret" });
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
async function createMedication(options: { async function createMedication(options: {
name: string; name: string;
genericName?: string | null;
packCount?: number; packCount?: number;
blistersPerPack?: number; blistersPerPack?: number;
pillsPerBlister?: number; pillsPerBlister?: number;
@@ -80,6 +81,7 @@ async function createMedication(options: {
}) { }) {
const { const {
name, name,
genericName = null,
packCount = 1, packCount = 1,
blistersPerPack = 1, blistersPerPack = 1,
pillsPerBlister = 10, pillsPerBlister = 10,
@@ -106,16 +108,17 @@ async function createMedication(options: {
const result = await testClient.execute({ const result = await testClient.execute({
sql: `INSERT INTO medications ( sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type, user_id, name, generic_name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
stock_adjustment, last_stock_correction_at, stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json, usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`, RETURNING id`,
args: [ args: [
1, 1,
name, name,
genericName,
JSON.stringify(takenBy), JSON.stringify(takenBy),
packCount, packCount,
blistersPerPack, blistersPerPack,
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic"); const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false); expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
}); });
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
await setStockMode("automatic");
await createMedication({
name: "",
genericName: "Acetylsalicylic acid",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
});
}); });
describe("getLiquidReminderThresholds", () => { describe("getLiquidReminderThresholds", () => {
+1
View File
@@ -46,5 +46,6 @@ export const log = {
export type ServiceLogger = { export type ServiceLogger = {
info: (msg: string) => void; info: (msg: string) => void;
debug: (msg: string) => void; debug: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void; error: (msg: string) => void;
}; };
+57 -9
View File
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
} }
function getLocalDateOrdinal(date: Date): number {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
}
function addLocalCalendarDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function getDateOnlyTimestamp(date: Date): number { export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime(); return toDateOnly(date).getTime();
} }
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
const lowerBound = inclusive ? fromMs : fromMs + 1; const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") { if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000; const intervalDays = Math.max(1, schedule.every);
if (startTime >= lowerBound) { if (startTime >= lowerBound) {
return startTime; return startTime;
} }
const intervals = Math.ceil((lowerBound - startTime) / period); const lowerBoundDate = new Date(lowerBound);
return startTime + intervals * period; const startOrdinal = getLocalDateOrdinal(startDate);
const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (candidate.getTime() < lowerBound) {
candidate = addLocalCalendarDays(candidate, intervalDays);
}
return candidate.getTime();
} }
const candidateStart = Math.max(lowerBound, startTime); const candidateStart = Math.max(lowerBound, startTime);
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
} }
if (schedule.scheduleMode !== "weekdays") { if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000; const intervalDays = Math.max(1, schedule.every);
let occurrenceMs = startTime; let occurrence = new Date(startDate);
if (occurrenceMs < rangeStartMs) { if (occurrence.getTime() < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period); const rangeStartDate = new Date(rangeStartMs);
occurrenceMs += intervals * period; const startOrdinal = getLocalDateOrdinal(startDate);
const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (occurrence.getTime() < rangeStartMs) {
occurrence = addLocalCalendarDays(occurrence, intervalDays);
}
} }
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) { for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
if (occurrenceMs >= rangeStartMs) { if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs); callback(occurrenceMs);
} }
occurrence = addLocalCalendarDays(occurrence, intervalDays);
occurrenceMs = occurrence.getTime();
} }
return; return;
} }
@@ -348,6 +379,23 @@ export function getTimezone(): string {
return process.env.TZ || "UTC"; return process.env.TZ || "UTC";
} }
export function isValidTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value });
return true;
} catch {
return false;
}
}
export function getEffectiveTimezone(override?: string | null): string {
const normalized = override?.trim() ?? "";
if (normalized && isValidTimezone(normalized)) {
return normalized;
}
return getTimezone();
}
/** Format a date in the configured timezone */ /** Format a date in the configured timezone */
export function formatInTimezone(date: Date, tz?: string): string { export function formatInTimezone(date: Date, tz?: string): string {
return date.toLocaleString("de-DE", { return date.toLocaleString("de-DE", {
+1 -2
View File
@@ -6,7 +6,6 @@ Scope and behavior:
- These values are applied only when a user's settings are created for the first time. - 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. - 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 ## Email Defaults
@@ -47,6 +46,6 @@ Scope and behavior:
|----------|---------|-------------| |----------|---------|-------------|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). | | `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). | | `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. | | `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. | | `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. | | `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
+306 -292
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.22.2", "version": "1.24.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -27,30 +27,30 @@
"test:e2e:report": "playwright show-report" "test:e2e:report": "playwright show-report"
}, },
"dependencies": { "dependencies": {
"i18next": "^26.0.3", "i18next": "^26.1.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.14.0",
"react": "^19.2.4", "react": "^19.2.6",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.7",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.15.0",
"zod": "^4.3.6" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.15",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.5.2", "@types/node": "^25.6.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.0.1", "jsdom": "^29.1.1",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"vite": "^8.0.5", "vite": "^8.0.12",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }
+1 -1
View File
@@ -506,7 +506,7 @@ function AppContent() {
<AboutModal isOpen={showAbout} onClose={closeAbout} /> <AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes> <Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} /> <Route path="/medications" element={<MedicationsPage />} />
+1 -4
View File
@@ -1105,10 +1105,7 @@ export function MedDetailModal({
</span> </span>
<span className="refill-amount"> <span className="refill-amount">
{(() => { {(() => {
const total = isAmountBasedPackageType(selectedMed.packageType) const total = entry.quantityAdded;
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`; return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
})()} })()}
{entry.usedPrescription && ( {entry.usedPrescription && (
+22 -12
View File
@@ -6,6 +6,7 @@ import type { Medication } from "../types";
import { import {
getMedDisplayName, getMedDisplayName,
getMedTotal, getMedTotal,
getStockDisplayCapacity,
isAmountBasedPackageType, isAmountBasedPackageType,
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
@@ -27,10 +28,16 @@ type ReportData = Record<
{ {
dosesTaken: number; dosesTaken: number;
automaticDosesTaken: number; automaticDosesTaken: number;
dosesDismissed: number; dosesSkipped: number;
firstDoseAt: string | null; firstDoseAt: string | null;
lastDoseAt: string | null; lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; refills: {
packsAdded: number;
loosePillsAdded?: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
} }
>; >;
@@ -121,7 +128,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
const res = await fetch("/api/medications/report-data", { const res = await fetch("/api/medications/report-data", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }), body: JSON.stringify({
medicationIds: Array.from(selectedIds),
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
}),
credentials: "include", credentials: "include",
}); });
if (!res.ok) throw new Error("Failed to fetch report data"); if (!res.ok) throw new Error("Failed to fetch report data");
@@ -374,7 +384,7 @@ function generateTextReport(
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister))); lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets))); if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
} else { } else {
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets))); lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med))));
} }
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -415,12 +425,12 @@ function generateTextReport(
const data = reportData[med.id]; const data = reportData[med.id];
if (data) { if (data) {
lines.push(h3(t("report.docIntakeHistory"))); lines.push(h3(t("report.docIntakeHistory")));
if (data.dosesTaken > 0 || data.dosesDismissed > 0) { if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken))); lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
if (data.automaticDosesTaken > 0) { if (data.automaticDosesTaken > 0) {
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken))); lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
} }
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed))); if (data.dosesSkipped > 0) lines.push(item(t("report.docDosesSkipped"), String(data.dosesSkipped)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt))); if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt))); if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else { } else {
@@ -432,7 +442,7 @@ function generateTextReport(
if (data.refills.length > 0) { if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory"))); lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) { for (const r of data.refills) {
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`; let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`); lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
} }
@@ -572,7 +582,7 @@ function buildPrintHtml(
if (med.looseTablets > 0) if (med.looseTablets > 0)
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
} else { } else {
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`; s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
} }
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -616,14 +626,14 @@ function buildPrintHtml(
// Intake history // Intake history
if (data) { if (data) {
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`; s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
if (data.dosesTaken > 0 || data.dosesDismissed > 0) { if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
s += `<table><tbody>`; s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
if (data.automaticDosesTaken > 0) { if (data.automaticDosesTaken > 0) {
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`; s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
} }
if (data.dosesDismissed > 0) if (data.dosesSkipped > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
if (data.firstDoseAt) if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt) if (data.lastDoseAt)
@@ -638,7 +648,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`; s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`; s += `<ul>`;
for (const r of data.refills) { for (const r of data.refills) {
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`; let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`; if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`; s += `<li>${entry}</li>`;
} }
+234 -101
View File
@@ -39,6 +39,7 @@ export function SharedSchedule() {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set()); const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set()); const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set()); const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const mutationInFlightRef = useRef(0);
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false); const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false);
@@ -183,15 +184,23 @@ export function SharedSchedule() {
// Separates taken and dismissed doses (like main app's useDoses hook) // Separates taken and dismissed doses (like main app's useDoses hook)
const loadTakenDoses = useCallback(async () => { const loadTakenDoses = useCallback(async () => {
if (!token) return; if (!token) return;
if (mutationInFlightRef.current > 0) return;
try { try {
const res = await fetch(`/api/share/${token}/doses`); const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) { if (res.ok) {
if (mutationInFlightRef.current > 0) return;
const data = await res.json(); const data = await res.json();
const taken = new Set<string>(); const taken = new Set<string>();
const automatic = new Set<string>(); const automatic = new Set<string>();
const dismissed = new Set<string>(); const dismissed = new Set<string>();
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) { for (const d of data.doses as Array<{
if (d.dismissed) { doseId: string;
dismissed?: boolean;
skipped?: boolean;
takenSource?: string;
}>) {
if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId); dismissed.add(d.doseId);
} else { } else {
taken.add(d.doseId); taken.add(d.doseId);
@@ -203,15 +212,9 @@ export function SharedSchedule() {
setTakenDoses(taken); setTakenDoses(taken);
setAutomaticTakenDoses(automatic); setAutomaticTakenDoses(automatic);
setDismissedDoses(dismissed); setDismissedDoses(dismissed);
} else {
setTakenDoses(new Set());
setAutomaticTakenDoses(new Set());
setDismissedDoses(new Set());
} }
} catch { } catch {
setTakenDoses(new Set()); // Keep the current optimistic/shared state on transient read errors.
setAutomaticTakenDoses(new Set());
setDismissedDoses(new Set());
} }
}, [token]); }, [token]);
@@ -232,12 +235,22 @@ export function SharedSchedule() {
} }
async function markDoseTaken(doseId: string) { async function markDoseTaken(doseId: string) {
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const wasAutomatic = automaticTakenDoses.has(doseId);
// Optimistic update // Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => { setTakenDoses((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(doseId); next.add(doseId);
return next; return next;
}); });
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setAutomaticTakenDoses((prev) => { setAutomaticTakenDoses((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(doseId); next.delete(doseId);
@@ -266,16 +279,104 @@ export function SharedSchedule() {
// Revert on error // Revert on error
setTakenDoses((prev) => { setTakenDoses((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(doseId); if (wasTaken) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next;
});
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next;
});
setAutomaticTakenDoses((prev) => {
const next = new Set(prev);
if (wasAutomatic) {
next.add(doseId);
}
return next; return next;
}); });
} finally { } finally {
mutationInFlightRef.current--;
loadTakenDoses();
}
}
async function markDoseSkipped(doseId: string) {
if (takenDoses.has(doseId)) {
return;
}
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const wasAutomatic = automaticTakenDoses.has(doseId);
mutationInFlightRef.current++;
setDismissedDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setAutomaticTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
try {
const response = await fetch(`/api/share/${token}/doses/skip`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
throw new Error("Failed to mark shared dose as skipped");
}
} catch {
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next;
});
setTakenDoses((prev) => {
const next = new Set(prev);
if (wasTaken) {
next.add(doseId);
}
return next;
});
setAutomaticTakenDoses((prev) => {
const next = new Set(prev);
if (wasAutomatic) {
next.add(doseId);
}
return next;
});
} finally {
mutationInFlightRef.current--;
loadTakenDoses(); loadTakenDoses();
} }
} }
async function undoDoseTaken(doseId: string) { async function undoDoseTaken(doseId: string) {
const wasAutomatic = automaticTakenDoses.has(doseId);
// Optimistic update // Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => { setTakenDoses((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(doseId); next.delete(doseId);
@@ -299,9 +400,100 @@ export function SharedSchedule() {
next.add(doseId); next.add(doseId);
return next; return next;
}); });
setAutomaticTakenDoses((prev) => {
const next = new Set(prev);
if (wasAutomatic) {
next.add(doseId);
}
return next;
});
} finally {
mutationInFlightRef.current--;
loadTakenDoses();
} }
} }
async function undoDoseSkipped(doseId: string) {
const wasSkipped = dismissedDoses.has(doseId);
mutationInFlightRef.current++;
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
try {
await fetch(`/api/share/${token}/doses/skip/${encodeURIComponent(doseId)}`, {
method: "DELETE",
});
} catch {
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
}
return next;
});
} finally {
mutationInFlightRef.current--;
loadTakenDoses();
}
}
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
isSkipped: boolean;
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
disabled={options.isEmpty}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
</button>
);
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className="dose-btn skip"
onClick={() => markDoseSkipped(options.doseId)}
title={t("dose.markAsSkipped")}
disabled={options.isTaken}
>
<span className="dose-btn-label">{t("dose.skip")}</span>
</button>
);
return (
<>
{takeButton}
{skipButton}
</>
);
};
const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId); const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
useEffect(() => { useEffect(() => {
@@ -934,6 +1126,7 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id); const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isSkipped = dismissedDoses.has(dose.id);
const doseClasses = ["dose-item", "past"]; const doseClasses = ["dose-item", "past"];
if (isTaken) doseClasses.push("all-taken"); if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty"); if (isEmpty) doseClasses.push("med-empty");
@@ -948,37 +1141,17 @@ export function SharedSchedule() {
)} )}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}> <div
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>} {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? ( {renderDoseActionButtons({
<button doseId: dose.id,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(dose.id)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(dose.id)}
disabled={isEmpty}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1149,7 +1322,8 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id); const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isOverdue = dose.when < Date.now() && !isTaken; const isSkipped = dismissedDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken && !isSkipped && !isEmpty;
const doseClasses = ["dose-item"]; const doseClasses = ["dose-item"];
if (isOverdue) doseClasses.push("overdue"); if (isOverdue) doseClasses.push("overdue");
if (isTaken) doseClasses.push("all-taken"); if (isTaken) doseClasses.push("all-taken");
@@ -1166,38 +1340,16 @@ export function SharedSchedule() {
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div <div
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`} className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""} ${isOverdue ? "overdue" : ""}`}
> >
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>} {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? ( {renderDoseActionButtons({
<button doseId: dose.id,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(dose.id)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(dose.id)}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1351,6 +1503,7 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id); const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isSkipped = dismissedDoses.has(dose.id);
const doseClasses = ["dose-item", "future"]; const doseClasses = ["dose-item", "future"];
if (isTaken) doseClasses.push("all-taken"); if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty"); if (isEmpty) doseClasses.push("med-empty");
@@ -1365,37 +1518,17 @@ export function SharedSchedule() {
)} )}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}> <div
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>} {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? ( {renderDoseActionButtons({
<button doseId: dose.id,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(dose.id)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty: true,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(dose.id)}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
disabled={true}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -50,7 +50,6 @@ export function MedicationListSection({
const renderImageAvatar = (med: Medication) => ( const renderImageAvatar = (med: Medication) => (
<span <span
className={med.imageUrl ? "med-avatar-clickable" : undefined} className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() => med.imageUrl && onImagePreview(med)}
onKeyDown={(e) => { onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) { if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
onImagePreview(med); onImagePreview(med);
@@ -146,8 +145,7 @@ export function MedicationListSection({
</> </>
) : ( ) : (
<span> <span>
{t("medications.details.totalCapacity")}:{" "} {t("medications.details.totalCapacity")}: <strong>{stockDisplayCapacity}</strong>
<strong>{med.totalPills ?? med.looseTablets}</strong>
</span> </span>
)} )}
</div> </div>
+11 -1
View File
@@ -11,7 +11,7 @@ import {
type ScheduleEvent, type ScheduleEvent,
type StockThresholds, type StockThresholds,
} from "../types"; } from "../types";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule"; import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
import { ShareContextProvider } from "./ShareContext"; import { ShareContextProvider } from "./ShareContext";
@@ -77,12 +77,15 @@ export interface AppContextValue {
// From useDoses // From useDoses
takenDoses: Set<string>; takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>; setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
skippedDoses: Set<string>;
dismissedDoses: Set<string>; dismissedDoses: Set<string>;
getDoseId: (baseDoseId: string, person: string | null) => string; getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean; isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>; markDoseTaken: (doseId: string) => Promise<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>; undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useCollapsedDays // From useCollapsedDays
manuallyCollapsedDays: Set<string>; manuallyCollapsedDays: Set<string>;
@@ -299,6 +302,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
shares: number; shares: number;
} | null>(null); } | null>(null);
useEffect(() => {
setDefaultFormattingTimezone(settingsHook.settings.timezone || settingsHook.settings.serverTimezone || null);
}, [settingsHook.settings.timezone, settingsHook.settings.serverTimezone]);
// Load user-specific scheduleDays when user changes // Load user-specific scheduleDays when user changes
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && user?.id) { if (typeof window !== "undefined" && user?.id) {
@@ -848,12 +855,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// From useDoses // From useDoses
takenDoses: doses.takenDoses, takenDoses: doses.takenDoses,
setTakenDoses: doses.setTakenDoses, setTakenDoses: doses.setTakenDoses,
skippedDoses: doses.skippedDoses,
dismissedDoses: doses.dismissedDoses, dismissedDoses: doses.dismissedDoses,
getDoseId: doses.getDoseId, getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically, isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses, countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken, markDoseTaken: doses.markDoseTaken,
markDoseSkipped: doses.markDoseSkipped,
undoDoseTaken: doses.undoDoseTaken, undoDoseTaken: doses.undoDoseTaken,
undoDoseSkipped: doses.undoDoseSkipped,
// From useCollapsedDays // From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays, manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
+178 -6
View File
@@ -10,13 +10,16 @@ export interface UseDosesReturn {
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>; setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>; takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">; takenDoseSources: Map<string, "manual" | "automatic">;
skippedDoses: Set<string>;
dismissedDoses: Set<string>; dismissedDoses: Set<string>;
clearDosesState: () => void; clearDosesState: () => void;
getDoseId: (baseDoseId: string, person: string | null) => string; getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean; isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>; markDoseTaken: (doseId: string) => Promise<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>; undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
loadTakenDoses: () => Promise<void>; loadTakenDoses: () => Promise<void>;
} }
@@ -56,7 +59,7 @@ export function useDoses(): UseDosesReturn {
const sources = new Map<string, "manual" | "automatic">(); const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>(); const dismissed = new Set<string>();
for (const d of data.doses) { for (const d of data.doses) {
if (d.dismissed) { if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId); dismissed.add(d.doseId);
} else { } else {
taken.add(d.doseId); taken.add(d.doseId);
@@ -127,6 +130,15 @@ export function useDoses(): UseDosesReturn {
const markDoseTaken = useCallback( const markDoseTaken = useCallback(
async (doseId: string) => { async (doseId: string) => {
if (dismissedDoses.has(doseId)) {
return;
}
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const previousTimestamp = takenDoseTimestamps.get(doseId);
const previousSource = takenDoseSources.get(doseId);
// Optimistic update // Optimistic update
mutationInFlightRef.current++; mutationInFlightRef.current++;
setTakenDoses((prev) => { setTakenDoses((prev) => {
@@ -134,6 +146,11 @@ export function useDoses(): UseDosesReturn {
next.add(doseId); next.add(doseId);
return next; return next;
}); });
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setTakenDoseTimestamps((prev) => { setTakenDoseTimestamps((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(doseId, Date.now()); next.set(doseId, Date.now());
@@ -163,17 +180,38 @@ export function useDoses(): UseDosesReturn {
// Revert on error // Revert on error
setTakenDoses((prev) => { setTakenDoses((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(doseId); if (wasTaken) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next;
});
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next; return next;
}); });
setTakenDoseTimestamps((prev) => { setTakenDoseTimestamps((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(doseId); if (wasTaken && typeof previousTimestamp === "number") {
next.set(doseId, previousTimestamp);
} else {
next.delete(doseId);
}
return next; return next;
}); });
setTakenDoseSources((prev) => { setTakenDoseSources((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(doseId); if (wasTaken && previousSource) {
next.set(doseId, previousSource);
} else {
next.delete(doseId);
}
return next; return next;
}); });
} finally { } finally {
@@ -182,11 +220,96 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses(); loadTakenDoses();
} }
}, },
[getErrorCode, loadTakenDoses, t] [dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
);
const markDoseSkipped = useCallback(
async (doseId: string) => {
if (takenDoses.has(doseId)) {
return;
}
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const previousTimestamp = takenDoseTimestamps.get(doseId);
const previousSource = takenDoseSources.get(doseId);
mutationInFlightRef.current++;
setDismissedDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
try {
const response = await fetch("/api/doses/skip", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
throw new Error("Failed to mark dose as skipped");
}
} catch {
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
} else {
next.delete(doseId);
}
return next;
});
setTakenDoses((prev) => {
const next = new Set(prev);
if (wasTaken) {
next.add(doseId);
}
return next;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
if (wasTaken && typeof previousTimestamp === "number") {
next.set(doseId, previousTimestamp);
}
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
if (wasTaken && previousSource) {
next.set(doseId, previousSource);
}
return next;
});
} finally {
mutationInFlightRef.current--;
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
); );
const undoDoseTaken = useCallback( const undoDoseTaken = useCallback(
async (doseId: string) => { async (doseId: string) => {
const previousTimestamp = takenDoseTimestamps.get(doseId);
const previousSource = takenDoseSources.get(doseId);
// Optimistic update // Optimistic update
mutationInFlightRef.current++; mutationInFlightRef.current++;
setTakenDoses((prev) => { setTakenDoses((prev) => {
@@ -218,13 +341,59 @@ export function useDoses(): UseDosesReturn {
next.add(doseId); next.add(doseId);
return next; return next;
}); });
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
if (typeof previousTimestamp === "number") {
next.set(doseId, previousTimestamp);
}
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
if (previousSource) {
next.set(doseId, previousSource);
}
return next;
});
} finally { } finally {
mutationInFlightRef.current--; mutationInFlightRef.current--;
// Re-sync with server after mutation completes // Re-sync with server after mutation completes
loadTakenDoses(); loadTakenDoses();
} }
}, },
[loadTakenDoses] [loadTakenDoses, takenDoseSources, takenDoseTimestamps]
);
const undoDoseSkipped = useCallback(
async (doseId: string) => {
const wasSkipped = dismissedDoses.has(doseId);
mutationInFlightRef.current++;
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
try {
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
setDismissedDoses((prev) => {
const next = new Set(prev);
if (wasSkipped) {
next.add(doseId);
}
return next;
});
} finally {
mutationInFlightRef.current--;
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses]
); );
return { return {
@@ -232,13 +401,16 @@ export function useDoses(): UseDosesReturn {
setTakenDoses, setTakenDoses,
takenDoseTimestamps, takenDoseTimestamps,
takenDoseSources, takenDoseSources,
skippedDoses: dismissedDoses,
dismissedDoses, dismissedDoses,
clearDosesState, clearDosesState,
getDoseId, getDoseId,
isDoseTakenAutomatically, isDoseTakenAutomatically,
countTakenDoses, countTakenDoses,
markDoseTaken, markDoseTaken,
markDoseSkipped,
undoDoseTaken, undoDoseTaken,
undoDoseSkipped,
loadTakenDoses, loadTakenDoses,
}; };
} }
+7 -1
View File
@@ -121,7 +121,12 @@ export function useRefill(): UseRefillReturn {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }), body: JSON.stringify({
packsAdded: refillPacks,
loosePillsAdded: refillLoose,
quantityAdded: refillLoose,
usePrescription,
}),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -267,6 +272,7 @@ export function useRefill(): UseRefillReturn {
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count. // Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
patchBody.packCount = correctedLiquidBottleCount; patchBody.packCount = correctedLiquidBottleCount;
patchBody.totalPills = liquidStructuralMax; patchBody.totalPills = liquidStructuralMax;
patchBody.looseTablets = liquidStructuralMax;
} else if (!isAmountPackage) { } else if (!isAmountPackage) {
patchBody.looseTablets = finalLoosePills; patchBody.looseTablets = finalLoosePills;
} }
@@ -23,8 +23,11 @@ export function useScheduleController() {
futureDays: ctx.futureDays, futureDays: ctx.futureDays,
takenDoses: ctx.takenDoses, takenDoses: ctx.takenDoses,
dismissedDoses: ctx.dismissedDoses, dismissedDoses: ctx.dismissedDoses,
skippedDoses: ctx.skippedDoses,
markDoseTaken: ctx.markDoseTaken, markDoseTaken: ctx.markDoseTaken,
markDoseSkipped: ctx.markDoseSkipped,
undoDoseTaken: ctx.undoDoseTaken, undoDoseTaken: ctx.undoDoseTaken,
undoDoseSkipped: ctx.undoDoseSkipped,
manuallyCollapsedDays: ctx.manuallyCollapsedDays, manuallyCollapsedDays: ctx.manuallyCollapsedDays,
manuallyExpandedDays: ctx.manuallyExpandedDays, manuallyExpandedDays: ctx.manuallyExpandedDays,
toggleDayCollapse: ctx.toggleDayCollapse, toggleDayCollapse: ctx.toggleDayCollapse,
+7
View File
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
export interface Settings { export interface Settings {
timezone: string;
availableTimezones: string[];
serverTimezone: string;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string; notificationEmail: string;
reminderDaysBefore: number; reminderDaysBefore: number;
@@ -58,6 +61,9 @@ export interface Settings {
export type SettingsLoadError = "auth" | "forbidden" | "request" | null; export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
const defaultSettings: Settings = { const defaultSettings: Settings = {
timezone: "",
availableTimezones: [],
serverTimezone: "UTC",
emailEnabled: false, emailEnabled: false,
notificationEmail: "", notificationEmail: "",
reminderDaysBefore: 7, reminderDaysBefore: 7,
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false; const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return { return {
timezone: settingsToSave.timezone,
emailEnabled: effectiveEmailEnabled, emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail, notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore, reminderDaysBefore: settingsToSave.reminderDaysBefore,
+31 -19
View File
@@ -110,7 +110,7 @@
"fullBlisters": "Volle Blister", "fullBlisters": "Volle Blister",
"openBlister": "Offener Blister", "openBlister": "Offener Blister",
"stock": "Bestand", "stock": "Bestand",
"dailyConsumption": "Taeglicher Verbrauch", "dailyConsumption": "Täglicher Verbrauch",
"stockDetails": "Details", "stockDetails": "Details",
"daysLeft": "Tage übrig", "daysLeft": "Tage übrig",
"status": "Status", "status": "Status",
@@ -133,7 +133,7 @@
"obsoleteTitle": "Obsolet ({{count}})", "obsoleteTitle": "Obsolet ({{count}})",
"obsoleteSince": "Beendet", "obsoleteSince": "Beendet",
"started": "Gestartet", "started": "Gestartet",
"emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu." "emptyState": "Noch keine Medikamente. Füge dein erstes Medikament hinzu."
}, },
"details": { "details": {
"packs": "Packungen", "packs": "Packungen",
@@ -142,7 +142,7 @@
"loose": "Lose", "loose": "Lose",
"total": "Gesamt", "total": "Gesamt",
"stock": "Bestand", "stock": "Bestand",
"capacityPerPackage": "Kapazitaet pro Packung", "capacityPerPackage": "Kapazität pro Packung",
"totalCapacity": "Kapazität", "totalCapacity": "Kapazität",
"type": "Typ" "type": "Typ"
}, },
@@ -174,17 +174,17 @@
"medicationForm": "Medikationsform", "medicationForm": "Medikationsform",
"medicationFormCapsule": "Kapsel", "medicationFormCapsule": "Kapsel",
"medicationFormTablet": "Tablette", "medicationFormTablet": "Tablette",
"medicationFormLiquid": "Fluessigkeit", "medicationFormLiquid": "Flüssigkeit",
"medicationFormTopical": "Topisch", "medicationFormTopical": "Topisch",
"pillForm": "Pillenform", "pillForm": "Pillenform",
"lifecycleCategory": "Lebenszyklus", "lifecycleCategory": "Lebenszyklus",
"lifecycleRefillWhenEmpty": "Nachfuellen wenn leer", "lifecycleRefillWhenEmpty": "Nachfüllen wenn leer",
"lifecycleTreatmentPeriod": "Behandlungszeitraum", "lifecycleTreatmentPeriod": "Behandlungszeitraum",
"packageType": "Verpackungsart", "packageType": "Verpackungsart",
"packageTypeBlister": "Blisterpackung", "packageTypeBlister": "Blisterpackung",
"packageTypeBottle": "Pillendose", "packageTypeBottle": "Pillendose",
"packageTypeTube": "Tube", "packageTypeTube": "Tube",
"packageTypeLiquidContainer": "Fluessigbehaeltnis", "packageTypeLiquidContainer": "Flüssigbehältnis",
"packs": "Packungen", "packs": "Packungen",
"bottles": "Flaschen", "bottles": "Flaschen",
"tubes": "Tuben", "tubes": "Tuben",
@@ -317,17 +317,17 @@
"usageApplication": "Dosis (Anwendungen)", "usageApplication": "Dosis (Anwendungen)",
"intakeUnit": "Einnahmeeinheit", "intakeUnit": "Einnahmeeinheit",
"intakeUnitMl": "Milliliter (ml)", "intakeUnitMl": "Milliliter (ml)",
"intakeUnitTsp": "Teeloeffel (5 ml)", "intakeUnitTsp": "Teelöffel (5 ml)",
"intakeUnitTbsp": "Essloeffel (15 ml)", "intakeUnitTbsp": "Esslöffel (15 ml)",
"intakes": "Einnahmen", "intakes": "Einnahmen",
"intakes_one": "Einnahme", "intakes_one": "Einnahme",
"intakes_other": "Einnahmen", "intakes_other": "Einnahmen",
"teaspoons": "Teeloeffel", "teaspoons": "Teelöffel",
"teaspoons_one": "Teeloeffel", "teaspoons_one": "Teelöffel",
"teaspoons_other": "Teeloeffel", "teaspoons_other": "Teelöffel",
"tablespoons": "Essloeffel", "tablespoons": "Esslöffel",
"tablespoons_one": "Essloeffel", "tablespoons_one": "Esslöffel",
"tablespoons_other": "Essloeffel", "tablespoons_other": "Esslöffel",
"applications": "Anwendungen", "applications": "Anwendungen",
"applications_one": "Anwendung", "applications_one": "Anwendung",
"applications_other": "Anwendungen", "applications_other": "Anwendungen",
@@ -337,7 +337,7 @@
"everyDays": "Alle (Tage)", "everyDays": "Alle (Tage)",
"every": "alle", "every": "alle",
"weekdays": "Wochentage", "weekdays": "Wochentage",
"weekdaysRequired": "Waehle mindestens einen Wochentag aus", "weekdaysRequired": "Wähle mindestens einen Wochentag aus",
"weekdaysShort": { "weekdaysShort": {
"mon": "Mo", "mon": "Mo",
"tue": "Di", "tue": "Di",
@@ -389,6 +389,14 @@
"title": "Sprache", "title": "Sprache",
"select": "Sprache auswählen" "select": "Sprache auswählen"
}, },
"timezone": {
"select": "Zeitzone",
"hint": "IANA-Zeitzone wählen. Wenn gesetzt, überschreibt sie die Server-TZ für deine Reminder-Zeitpunkte.",
"useServerDefault": "Server-Standard nutzen",
"currentServerTz": "Server-Standardzeitzone: {{timezone}}",
"saving": "Zeitzone wird gespeichert...",
"saved": "Zeitzone gespeichert"
},
"apiKey": { "apiKey": {
"title": "API-Zugriff", "title": "API-Zugriff",
"generateTitle": "API-Key erzeugen", "generateTitle": "API-Key erzeugen",
@@ -540,7 +548,11 @@
"dose": { "dose": {
"takenBy": "eingenommen von", "takenBy": "eingenommen von",
"markAsTaken": "Als eingenommen markieren", "markAsTaken": "Als eingenommen markieren",
"take": "Nehmen" "take": "Nehmen",
"skip": "Überspringen",
"markAsSkipped": "Als übersprungen markieren",
"undoTake": "Nehmen rückgängig",
"undoSkip": "Überspringen rückgängig"
}, },
"auth": { "auth": {
"login": "Anmelden", "login": "Anmelden",
@@ -776,11 +788,11 @@
"loosePills": "Lose Tabletten", "loosePills": "Lose Tabletten",
"pillsPerBlister": "(je {{count}} Tabletten)", "pillsPerBlister": "(je {{count}} Tabletten)",
"packageSize": "Packungsgröße: {{count}} Tabletten", "packageSize": "Packungsgröße: {{count}} Tabletten",
"packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}", "packageSizeAmount": "Packungsgröße: {{count}} {{unit}}",
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten", "packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten", "currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.", "maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
"maxExceededAmount": "Die maximale Packungsgroesse betraegt {{count}} {{unit}}. Werte wurden begrenzt.", "maxExceededAmount": "Die maximale Packungsgröße beträgt {{count}} {{unit}}. Werte wurden begrenzt.",
"decreaseValue": "Wert verringern", "decreaseValue": "Wert verringern",
"increaseValue": "Wert erhöhen", "increaseValue": "Wert erhöhen",
"currentTotal": "Aktueller Bestand", "currentTotal": "Aktueller Bestand",
@@ -862,7 +874,7 @@
"docIntakeHistory": "Einnahme-Verlauf", "docIntakeHistory": "Einnahme-Verlauf",
"docDosesTaken": "Eingenommene Dosen", "docDosesTaken": "Eingenommene Dosen",
"docDosesTakenAutomatic": "Automatisch eingenommen", "docDosesTakenAutomatic": "Automatisch eingenommen",
"docDosesDismissed": "Verworfene Dosen", "docDosesSkipped": "Übersprungene Dosen",
"docFirstDose": "Erste Dosis", "docFirstDose": "Erste Dosis",
"docLastDose": "Letzte Dosis", "docLastDose": "Letzte Dosis",
"docRefillHistory": "Nachfüll-Verlauf", "docRefillHistory": "Nachfüll-Verlauf",
+14 -2
View File
@@ -389,6 +389,14 @@
"title": "Language", "title": "Language",
"select": "Select language" "select": "Select language"
}, },
"timezone": {
"select": "Timezone",
"hint": "Select an IANA timezone. When set, this overrides server TZ for your reminder timing.",
"useServerDefault": "Use server default",
"currentServerTz": "Server default timezone: {{timezone}}",
"saving": "Saving timezone...",
"saved": "Timezone saved"
},
"apiKey": { "apiKey": {
"title": "API Access", "title": "API Access",
"generateTitle": "Generate API key", "generateTitle": "Generate API key",
@@ -540,7 +548,11 @@
"dose": { "dose": {
"takenBy": "taken by", "takenBy": "taken by",
"markAsTaken": "Mark as taken", "markAsTaken": "Mark as taken",
"take": "Take" "take": "Take",
"skip": "Skip",
"markAsSkipped": "Mark as skipped",
"undoTake": "Undo take",
"undoSkip": "Undo skip"
}, },
"auth": { "auth": {
"login": "Login", "login": "Login",
@@ -862,7 +874,7 @@
"docIntakeHistory": "Intake History", "docIntakeHistory": "Intake History",
"docDosesTaken": "Doses taken", "docDosesTaken": "Doses taken",
"docDosesTakenAutomatic": "Automatically taken", "docDosesTakenAutomatic": "Automatically taken",
"docDosesDismissed": "Doses dismissed", "docDosesSkipped": "Doses skipped",
"docFirstDose": "First dose", "docFirstDose": "First dose",
"docLastDose": "Last dose", "docLastDose": "Last dose",
"docRefillHistory": "Refill History", "docRefillHistory": "Refill History",
+344 -105
View File
@@ -1,7 +1,8 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */ /* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"; import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
import { useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ConfirmModal, MedicationAvatar } from "../components"; import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection"; import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
@@ -28,9 +29,54 @@ import {
userStorageKey, userStorageKey,
} from "./dashboard-helpers"; } from "./dashboard-helpers";
function getRouteDateKey(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getMedicationIdFromNotificationDoseId(doseId: string | null): string | null {
if (!doseId) {
return null;
}
const [rawMedicationId] = doseId.split("-");
return rawMedicationId?.trim() ? rawMedicationId : null;
}
function findFocusTargetElement(doseId: string | null, medId: string | null): HTMLElement | null {
if (typeof document === "undefined") {
return null;
}
if (doseId) {
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-dose-id]"));
const doseElement = elements.find((element) => element.dataset.doseId === doseId);
if (doseElement) {
return doseElement.closest<HTMLElement>("[data-med-id]") ?? doseElement;
}
}
if (medId) {
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-med-id]"));
return elements.find((element) => element.dataset.medId === medId) ?? null;
}
return null;
}
function getDosePeople(takenBy: unknown): Array<string | null> {
const takenByArray = Array.isArray(takenBy) ? takenBy : [];
return takenByArray.length > 0 ? takenByArray : [null];
}
const EMPTY_DOSE_SET = new Set<string>();
export function DashboardPage() { export function DashboardPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation();
const { const {
meds, meds,
loading, loading,
@@ -49,9 +95,12 @@ export function DashboardPage() {
todayDay, todayDay,
futureDays, futureDays,
takenDoses, takenDoses,
skippedDoses,
dismissedDoses, dismissedDoses,
markDoseTaken, markDoseTaken,
markDoseSkipped,
undoDoseTaken, undoDoseTaken,
undoDoseSkipped,
manuallyCollapsedDays, manuallyCollapsedDays,
manuallyExpandedDays, manuallyExpandedDays,
toggleDayCollapse, toggleDayCollapse,
@@ -71,8 +120,158 @@ export function DashboardPage() {
const [clearingMissed, setClearingMissed] = useState(false); const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false); const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null); const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const notificationFocusAppliedRef = useRef<string | null>(null);
const effectiveSkippedDoses =
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId); const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
const notificationTarget = useMemo(() => {
const params = new URLSearchParams(location.search);
const date = params.get("day")?.trim() ?? params.get("date")?.trim() ?? "";
const doseId = params.get("dose")?.trim() ?? params.get("doseId")?.trim() ?? "";
const medId =
params.get("med")?.trim() ?? params.get("medId")?.trim() ?? getMedicationIdFromNotificationDoseId(doseId) ?? "";
if (!date && !doseId && !medId) {
return null;
}
return {
date: date || null,
doseId: doseId || null,
medId: medId || null,
key: `${date}|${doseId}|${medId}`,
};
}, [location.search]);
const targetDayState = useMemo(() => {
if (!notificationTarget?.date) {
return null;
}
const todayDateKey = todayDay ? getRouteDateKey(todayDay.date) : null;
if (todayDay && todayDateKey === notificationTarget.date) {
const allDoseIds = todayDay.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
const isAutoCollapsed = allDayTaken;
const isManuallyExpanded = manuallyExpandedDays.has(todayDay.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(todayDay.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return { day: todayDay, isAutoCollapsed, isCollapsed, section: "today" as const };
}
const pastDay = pastDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
if (pastDay) {
const isAutoCollapsed = true;
const isCollapsed = !manuallyExpandedDays.has(pastDay.dateStr);
return { day: pastDay, isAutoCollapsed, isCollapsed, section: "past" as const };
}
const futureDay = futureDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
if (futureDay) {
const isAutoCollapsed = true;
const isCollapsed = !manuallyExpandedDays.has(futureDay.dateStr);
return { day: futureDay, isAutoCollapsed, isCollapsed, section: "future" as const };
}
return null;
}, [
notificationTarget,
todayDay,
pastDays,
futureDays,
manuallyExpandedDays,
manuallyCollapsedDays,
isDoseTakenForDisplay,
]);
useEffect(() => {
if (!notificationTarget || !targetDayState) {
return;
}
try {
if (targetDayState.section === "past" && !showPastDays) {
setShowPastDays(true);
}
if (targetDayState.section === "future" && !showFutureDays) {
setShowFutureDays(true);
}
if (targetDayState.isCollapsed) {
toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed);
}
} catch {
notificationFocusAppliedRef.current = null;
}
}, [
notificationTarget,
targetDayState,
setShowPastDays,
setShowFutureDays,
showPastDays,
showFutureDays,
toggleDayCollapse,
]);
useEffect(() => {
if (!notificationTarget) {
notificationFocusAppliedRef.current = null;
return;
}
if (loading || settingsLoading) {
return;
}
if (!targetDayState) {
return;
}
if (notificationFocusAppliedRef.current === notificationTarget.key) {
return;
}
let correctionTimerId: number | null = null;
const scrollTargetIntoView = () => {
try {
const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId);
if (!targetElement || typeof targetElement.scrollIntoView !== "function") {
return false;
}
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
return true;
} catch {
return false;
}
};
const frameId = requestAnimationFrame(() => {
if (!scrollTargetIntoView()) {
return;
}
correctionTimerId = window.setTimeout(() => {
if (!scrollTargetIntoView()) {
return;
}
notificationFocusAppliedRef.current = notificationTarget.key;
}, 220);
});
return () => {
cancelAnimationFrame(frameId);
if (correctionTimerId !== null) {
window.clearTimeout(correctionTimerId);
}
};
}, [notificationTarget, targetDayState, loading, settingsLoading]);
// Get structured reminder data // Get structured reminder data
const reminderData = getReminderStatusData( const reminderData = getReminderStatusData(
@@ -153,6 +352,63 @@ export function DashboardPage() {
} }
}; };
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
isSkipped: boolean;
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
disabled={options.isEmpty || options.isSkipped}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
</button>
);
if (!canManageSkippedDoses) {
return takeButton;
}
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className="dose-btn skip"
onClick={() => markDoseSkipped(options.doseId)}
title={t("dose.markAsSkipped")}
disabled={options.isTaken}
>
<span className="dose-btn-label">{t("dose.skip")}</span>
</button>
);
return (
<>
{takeButton}
{skipButton}
</>
);
};
const requestMarkObsolete = (med: { id: number; name: string }) => { const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med); setObsoleteCandidate(med);
setShowObsoleteConfirm(true); setShowObsoleteConfirm(true);
@@ -708,6 +964,7 @@ export function DashboardPage() {
return ( return (
<div <div
key={day.dateStr} key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
> >
<div <div
@@ -753,8 +1010,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"]; const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty"); if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low"); else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}> <div
key={`${day.dateStr}-${item.medName}`}
className={rowClasses.join(" ")}
data-med-id={med?.id != null ? String(med.id) : undefined}
>
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<div <div
@@ -797,7 +1061,7 @@ export function DashboardPage() {
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person // If no takenBy, show single checkbox; otherwise show one per person
const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; const people = getDosePeople(dose.takenBy);
const allTaken = people.every((person) => const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person)) isDoseTakenForDisplay(getDoseId(dose.id, person))
); );
@@ -828,10 +1092,20 @@ export function DashboardPage() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId); const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
if (notificationTarget?.doseId === doseId)
personClasses.push("notification-focus-target");
return ( return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}> <div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && ( {person && (
<span <span
className="person-name clickable" className="person-name clickable"
@@ -843,38 +1117,13 @@ export function DashboardPage() {
{person} {person}
</span> </span>
)} )}
{isTaken ? ( {renderDoseActionButtons({
<button doseId,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(doseId)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
title={
isEmpty
? t("common.outOfStockTakeBlocked")
: t("dose.markAsTaken")
}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
); );
})} })}
@@ -1023,6 +1272,7 @@ export function DashboardPage() {
return ( return (
<div <div
key={day.dateStr} key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
> >
<div <div
@@ -1067,8 +1317,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"]; const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty"); if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low"); else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}> <div
key={`${day.dateStr}-${item.medName}`}
className={rowClasses.join(" ")}
data-med-id={med?.id != null ? String(med.id) : undefined}
>
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<div <div
@@ -1126,8 +1383,8 @@ export function DashboardPage() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const isOverdue = dose.when < Date.now(); const isOverdue = dose.when < Date.now() && !isEmpty;
const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; const people = getDosePeople(dose.takenBy);
const allTaken = people.every((person) => const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person)) isDoseTakenForDisplay(getDoseId(dose.id, person))
); );
@@ -1159,10 +1416,20 @@ export function DashboardPage() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId); const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
if (notificationTarget?.doseId === doseId)
personClasses.push("notification-focus-target");
return ( return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}> <div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && ( {person && (
<span <span
className="person-name clickable" className="person-name clickable"
@@ -1174,38 +1441,13 @@ export function DashboardPage() {
{person} {person}
</span> </span>
)} )}
{isTaken ? ( {renderDoseActionButtons({
<button doseId,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(doseId)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
title={
isEmpty
? t("common.outOfStockTakeBlocked")
: t("dose.markAsTaken")
}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
); );
})} })}
@@ -1227,7 +1469,7 @@ export function DashboardPage() {
const totalFutureDoses = futureDays.flatMap((d) => const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => d.meds.flatMap((m) =>
m.doses.flatMap((dose) => m.doses.flatMap((dose) =>
dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id] getDosePeople(dose.takenBy).map((person) => (person ? `${dose.id}-${person}` : dose.id))
) )
) )
); );
@@ -1296,6 +1538,7 @@ export function DashboardPage() {
return ( return (
<div <div
key={day.dateStr} key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
> >
<div <div
@@ -1340,8 +1583,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"]; const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty"); if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low"); else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}> <div
key={`${day.dateStr}-${item.medName}`}
className={rowClasses.join(" ")}
data-med-id={med?.id != null ? String(med.id) : undefined}
>
<div className="time-main"> <div className="time-main">
<div className="med-name"> <div className="med-name">
<div <div
@@ -1399,7 +1649,7 @@ export function DashboardPage() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; const people = getDosePeople(dose.takenBy);
const allTaken = people.every((person) => const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person)) isDoseTakenForDisplay(getDoseId(dose.id, person))
); );
@@ -1430,10 +1680,20 @@ export function DashboardPage() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId); const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
if (notificationTarget?.doseId === doseId)
personClasses.push("notification-focus-target");
return ( return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}> <div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && ( {person && (
<span <span
className="person-name clickable" className="person-name clickable"
@@ -1445,34 +1705,13 @@ export function DashboardPage() {
{person} {person}
</span> </span>
)} )}
{isTaken ? ( {renderDoseActionButtons({
<button doseId,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(doseId)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty: true,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take out-of-stock`}
onClick={() => markDoseTaken(doseId)}
title={t("common.outOfStockTakeBlocked")}
disabled={true}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div> </div>
); );
})} })}
+52 -23
View File
@@ -257,8 +257,10 @@ export function MedicationsPage() {
useUnsavedChangesWarning(formChanged); useUnsavedChangesWarning(formChanged);
// View mode: grid (default) or form (edit/new) // View mode: grid (default) or form (edit/new)
// If navigating in with editMedId, suppress rendering until the edit form is ready // If navigating in with a medication deep-link, suppress rendering until the target form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId")); const [pendingEditTransition, setPendingEditTransition] = useState(
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
);
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid"); const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null); const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
@@ -269,9 +271,23 @@ export function MedicationsPage() {
useEffect(() => { useEffect(() => {
showEditModalRef.current = showEditModal; showEditModalRef.current = showEditModal;
}, [showEditModal]); }, [showEditModal]);
const processedEditMedIdRef = useRef<string | null>(null); const processedMedicationLinkRef = useRef<string | null>(null);
const hasDesktopFormHistoryState = useRef(false); const hasDesktopFormHistoryState = useRef(false);
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
const viewMedId = params.get("viewMedId");
if (viewMedId) {
return { mode: "view" as const, linkedMedId: viewMedId };
}
const editMedId = params.get("editMedId");
if (editMedId) {
return { mode: "edit" as const, linkedMedId: editMedId };
}
return { mode: null, linkedMedId: null };
}, []);
// Sync formChanged state to the global context for navigation blocking // Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges(); const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => { useEffect(() => {
@@ -819,12 +835,13 @@ export function MedicationsPage() {
[t] [t]
); );
const clearEditMedIdParam = useCallback(() => { const clearMedicationLinkParams = useCallback(() => {
setSearchParams( setSearchParams(
(prevParams) => { (prevParams) => {
if (!prevParams.has("editMedId")) return prevParams; if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams); const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId"); nextParams.delete("editMedId");
nextParams.delete("viewMedId");
return nextParams; return nextParams;
}, },
{ replace: true } { replace: true }
@@ -848,7 +865,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
clearEditMedIdParam(); clearMedicationLinkParams();
// Mark as confirmed to avoid double confirmation in popstate handler // Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true; closeConfirmedRef.current = true;
window.history.back(); window.history.back();
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
if (shouldCloseMobileModal) { if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards. // Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true; closeConfirmedRef.current = true;
clearEditMedIdParam(); clearMedicationLinkParams();
setShowEditModal(false); setShowEditModal(false);
setReadOnlyView(false); setReadOnlyView(false);
setActiveTab("general"); setActiveTab("general");
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes // Handle browser back button for modals and unsaved changes
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId"); const currentParams = new URLSearchParams(window.location.search);
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
// Obsolete confirmation is open — dismiss it and stay where we are // Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) { if (showObsoleteConfirm) {
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation // If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) { if (closeConfirmedRef.current) {
closeConfirmedRef.current = false; closeConfirmedRef.current = false;
if (currentEditMedId) { if (currentMedicationLinkId && currentLinkMode) {
// Prevent URL popstate from immediately reopening mobile edit for the same id. // Prevent URL popstate from immediately reopening mobile edit for the same id.
processedEditMedIdRef.current = currentEditMedId; processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
clearEditMedIdParam(); clearMedicationLinkParams();
} }
if (showEditModal) { if (showEditModal) {
setShowEditModal(false); setShowEditModal(false);
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
if (currentEditMedId) { if (currentMedicationLinkId && currentLinkMode) {
// Mark as handled before URL cleanup to avoid same-tick re-open races. // Mark as handled before URL cleanup to avoid same-tick re-open races.
processedEditMedIdRef.current = currentEditMedId; processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
} }
clearEditMedIdParam(); clearMedicationLinkParams();
setShowEditModal(false); setShowEditModal(false);
resetForm(); resetForm();
resetMedicationEnrichment(); resetMedicationEnrichment();
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
}; };
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState);
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]); }, [
showObsoleteConfirm,
showDeleteConfirm,
showEditModal,
viewMode,
formChanged,
resetForm,
clearMedicationLinkParams,
getMedicationLinkState,
]);
// Close modal on Escape key // Close modal on Escape key
useEffect(() => { useEffect(() => {
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
}, [activeMeds, editingId]); }, [activeMeds, editingId]);
useEffect(() => { useEffect(() => {
const editMedId = searchParams.get("editMedId"); const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
if (!editMedId) { if (!linkedMedId || !linkMode) {
processedEditMedIdRef.current = null; processedMedicationLinkRef.current = null;
return; return;
} }
if (processedEditMedIdRef.current === editMedId) return; const linkKey = `${linkMode}:${linkedMedId}`;
const parsedMedId = Number.parseInt(editMedId, 10); if (processedMedicationLinkRef.current === linkKey) return;
const parsedMedId = Number.parseInt(linkedMedId, 10);
if (Number.isNaN(parsedMedId)) return; if (Number.isNaN(parsedMedId)) return;
const medicationToEdit = const medicationToEdit =
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId); meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return; if (!medicationToEdit) return;
processedEditMedIdRef.current = editMedId; processedMedicationLinkRef.current = linkKey;
setShowNameValidation(false); setShowNameValidation(false);
setReadOnlyView(false); setReadOnlyView(linkMode === "view");
setActiveTab("general"); setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || ""); resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal); startEdit(medicationToEdit, openEditModal);
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
const nextParams = new URLSearchParams(searchParams); const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId"); nextParams.delete("editMedId");
nextParams.delete("viewMedId");
setSearchParams(nextParams, { replace: true }); setSearchParams(nextParams, { replace: true });
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]); }, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => { const selectedMedication = useMemo(() => {
if (!editingId) return null; if (!editingId) return null;
+84 -61
View File
@@ -24,8 +24,8 @@ function getStockStatus(
packageType?: string packageType?: string
) { ) {
if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" }; if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" };
// Out of stock or completely depleted = danger (red) // Only a real zero-or-below stock count is out of stock.
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" }; if (medsLeft <= 0) return { className: "danger", label: "status.outOfStock" };
// No schedule, but has stock = normal // No schedule, but has stock = normal
if (daysLeft === null) return { className: "success", label: "status.noSchedule" }; if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
if (isLiquidContainerPackageType(packageType)) { if (isLiquidContainerPackageType(packageType)) {
@@ -85,7 +85,10 @@ export function SchedulePage() {
isDoseTakenAutomatically, isDoseTakenAutomatically,
dismissedDoses, dismissedDoses,
markDoseTaken, markDoseTaken,
skippedDoses,
markDoseSkipped,
undoDoseTaken, undoDoseTaken,
undoDoseSkipped,
coverageByMed, coverageByMed,
depletionByMed, depletionByMed,
manuallyExpandedDays, manuallyExpandedDays,
@@ -172,6 +175,59 @@ export function SchedulePage() {
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }> doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => formatScheduleTotalUsageLabel(med, total, t, doses); ) => formatScheduleTotalUsageLabel(med, total, t, doses);
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
isSkipped: boolean;
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
disabled={options.isEmpty || options.isSkipped}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
</button>
);
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
<button
className="dose-btn skip"
onClick={() => markDoseSkipped(options.doseId)}
title={t("dose.markAsSkipped")}
disabled={options.isTaken}
>
<span className="dose-btn-label">{t("dose.skip")}</span>
</button>
);
return (
<>
{takeButton}
{skipButton}
</>
);
};
return ( return (
<section className="grid"> <section className="grid">
<article className="card schedule-full"> <article className="card schedule-full">
@@ -320,10 +376,14 @@ export function SchedulePage() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId); const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = skippedDoses.has(doseId);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
return ( return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}> <div key={doseId} className={personClasses.join(" ")}>
{person && ( {person && (
<span <span
className="person-name clickable" className="person-name clickable"
@@ -335,35 +395,13 @@ export function SchedulePage() {
{person} {person}
</span> </span>
)} )}
{isTaken ? ( {renderDoseActionButtons({
<button doseId,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(doseId)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
); );
})} })}
@@ -549,14 +587,16 @@ export function SchedulePage() {
{people.map((person) => { {people.map((person) => {
const doseId = getDoseId(dose.id, person); const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId); const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = skippedDoses.has(doseId);
const isAutomaticallyTaken = const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now; isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
const isOverdue = !isTaken && dose.when < now && !isPastDay; const isOverdue = !isTaken && !isSkipped && !isEmpty && dose.when < now && !isPastDay;
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
if (isOverdue) personClasses.push("overdue");
return ( return (
<div <div key={doseId} className={personClasses.join(" ")}>
key={doseId}
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{person && ( {person && (
<span <span
className="person-name clickable" className="person-name clickable"
@@ -568,30 +608,13 @@ export function SchedulePage() {
{person} {person}
</span> </span>
)} )}
{isTaken ? ( {renderDoseActionButtons({
<button doseId,
className="dose-btn undo" isTaken,
onClick={() => undoDoseTaken(doseId)} isSkipped,
title={t("common.undo")} isAutomaticallyTaken,
> isEmpty,
{isAutomaticallyTaken && ( })}
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
</button>
) : (
<button
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div> </div>
); );
})} })}
+137 -30
View File
@@ -1,9 +1,9 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */ /* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components"; import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
export function SettingsPage() { export function SettingsPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -13,8 +13,11 @@ export function SettingsPage() {
const [apiKeyError, setApiKeyError] = useState<string | null>(null); const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const { const {
settings, settings,
savedSettings,
setSettings, setSettings,
settingsLoading, settingsLoading,
settingsSaving,
settingsSaved,
settingsLoadError, settingsLoadError,
// Email testing // Email testing
testEmail, testEmail,
@@ -39,6 +42,8 @@ export function SettingsPage() {
setImportResult, setImportResult,
meds, meds,
} = useAppContext(); } = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0; const hasExistingData = meds.length > 0;
let emailUnavailableReason: string | null = null; let emailUnavailableReason: string | null = null;
@@ -117,6 +122,49 @@ export function SettingsPage() {
const automaticStockCalculationId = "settings-stock-calculation-automatic"; const automaticStockCalculationId = "settings-stock-calculation-automatic";
const manualStockCalculationId = "settings-stock-calculation-manual"; const manualStockCalculationId = "settings-stock-calculation-manual";
useEffect(() => {
setTimezoneDraft(settings.timezone);
}, [settings.timezone]);
const commitTimezoneDraft = () => {
if (timezoneDraft === settings.timezone) {
return;
}
setTimezoneTouched(true);
setSettings((prev) => ({ ...prev, timezone: timezoneDraft }));
};
const savedTimezone = savedSettings?.timezone ?? settings.timezone;
const timezoneChanged = settings.timezone !== savedTimezone;
const showTimezoneSaving = timezoneTouched && timezoneChanged && settingsSaving;
const showTimezoneSaved = timezoneTouched && !timezoneChanged && settingsSaved;
let timezoneStatusText = "";
if (showTimezoneSaving) {
timezoneStatusText = t("settings.timezone.saving");
} else if (showTimezoneSaved) {
timezoneStatusText = t("settings.timezone.saved");
}
const timezoneStatusClassName = showTimezoneSaved ? "timezone-status timezone-status-saved" : "timezone-status";
const availableTimezones = Array.isArray(settings.availableTimezones) ? settings.availableTimezones : [];
const timezoneSuggestions =
availableTimezones.length > 0
? availableTimezones
: (() => {
try {
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
return intlWithSupportedValues.supportedValuesOf("timeZone");
}
} catch {
// fall through
}
return [settings.serverTimezone || "UTC", "UTC"];
})();
return ( return (
<section className="grid"> <section className="grid">
{settingsLoading ? ( {settingsLoading ? (
@@ -160,6 +208,53 @@ export function SettingsPage() {
<option value="de">🇩🇪 Deutsch</option> <option value="de">🇩🇪 Deutsch</option>
</select> </select>
</label> </label>
<div className="setting-row language-row" style={{ marginTop: "12px" }}>
<div className="setting-label">
<span>{t("settings.timezone.select")}</span>
<span className="info-tooltip small tooltip-align-left" data-tooltip={t("settings.timezone.hint")}>
</span>
</div>
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px", width: "auto" }}>
<input
type="text"
className="select-field language-select"
value={timezoneDraft}
onChange={(e) => {
setTimezoneDraft(e.target.value);
}}
onBlur={commitTimezoneDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
list="settings-timezone-suggestions"
placeholder={settings.serverTimezone || "UTC"}
/>
<datalist id="settings-timezone-suggestions">
{timezoneSuggestions.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
<button
type="button"
className="ghost"
onClick={() => {
setTimezoneTouched(true);
setTimezoneDraft("");
setSettings((prev) => ({ ...prev, timezone: "" }));
}}
>
{t("settings.timezone.useServerDefault")}
</button>
</div>
</div>
<p className={timezoneStatusClassName}>{timezoneStatusText || " "}</p>
<p className="hint-text" style={{ marginTop: "8px" }}>
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
</p>
</article> </article>
<article className="card" data-testid="settings-notification-card"> <article className="card" data-testid="settings-notification-card">
@@ -642,13 +737,16 @@ export function SettingsPage() {
<div className="schedule-row"> <div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span> <span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
<span className="schedule-value"> <span className="schedule-value">
{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), { {new Date(settings.nextScheduledCheck).toLocaleString(
day: "2-digit", getSystemLocale(i18n.language),
month: "2-digit", withFormattingTimezone({
year: "numeric", day: "2-digit",
hour: "2-digit", month: "2-digit",
minute: "2-digit", year: "numeric",
})} hour: "2-digit",
minute: "2-digit",
})
)}
</span> </span>
</div> </div>
)} )}
@@ -656,13 +754,16 @@ export function SettingsPage() {
<div className="schedule-row"> <div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span> <span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value"> <span className="schedule-value">
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), { {new Date(settings.lastStockReminderSent).toLocaleString(
day: "2-digit", getSystemLocale(i18n.language),
month: "2-digit", withFormattingTimezone({
year: "numeric", day: "2-digit",
hour: "2-digit", month: "2-digit",
minute: "2-digit", year: "numeric",
})} hour: "2-digit",
minute: "2-digit",
})
)}
</span> </span>
</div> </div>
)} )}
@@ -670,13 +771,16 @@ export function SettingsPage() {
<div className="schedule-row"> <div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span> <span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
<span className="schedule-value"> <span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), { {new Date(settings.lastAutoEmailSent).toLocaleString(
day: "2-digit", getSystemLocale(i18n.language),
month: "2-digit", withFormattingTimezone({
year: "numeric", day: "2-digit",
hour: "2-digit", month: "2-digit",
minute: "2-digit", year: "numeric",
})} hour: "2-digit",
minute: "2-digit",
})
)}
</span> </span>
</div> </div>
)} )}
@@ -684,13 +788,16 @@ export function SettingsPage() {
<div className="schedule-row"> <div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span> <span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
<span className="schedule-value"> <span className="schedule-value">
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), { {new Date(settings.lastPrescriptionReminderSent).toLocaleString(
day: "2-digit", getSystemLocale(i18n.language),
month: "2-digit", withFormattingTimezone({
year: "numeric", day: "2-digit",
hour: "2-digit", month: "2-digit",
minute: "2-digit", year: "numeric",
})} hour: "2-digit",
minute: "2-digit",
})
)}
</span> </span>
</div> </div>
)} )}
+19 -12
View File
@@ -1,5 +1,6 @@
import type { Coverage, Medication, PackageType } from "../types"; import type { Coverage, Medication, PackageType } from "../types";
import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types"; import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { withFormattingTimezone } from "../utils/formatters";
import { splitCurrentBlisterStock } from "../utils/stock"; import { splitCurrentBlisterStock } from "../utils/stock";
export function userStorageKey(userId: number | undefined, key: string): string { export function userStorageKey(userId: number | undefined, key: string): string {
@@ -132,12 +133,15 @@ export function getReminderStatusData(
let lastStockSent: { date: string; medNames: string | null } | null = null; let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) { if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent); const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, { const formattedDate = sentDate.toLocaleDateString(
day: "2-digit", locale,
month: "short", withFormattingTimezone({
hour: "2-digit", day: "2-digit",
minute: "2-digit", month: "short",
}); hour: "2-digit",
minute: "2-digit",
})
);
lastStockSent = { lastStockSent = {
date: formattedDate, date: formattedDate,
medNames: lastStockReminderMedNames, medNames: lastStockReminderMedNames,
@@ -147,12 +151,15 @@ export function getReminderStatusData(
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null; let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) { if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent); const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, { const formattedDate = sentDate.toLocaleDateString(
day: "2-digit", locale,
month: "short", withFormattingTimezone({
hour: "2-digit", day: "2-digit",
minute: "2-digit", month: "short",
}); hour: "2-digit",
minute: "2-digit",
})
);
lastIntakeSent = { lastIntakeSent = {
date: formattedDate, date: formattedDate,
medName: lastReminderMedName, medName: lastReminderMedName,
+63 -3
View File
@@ -2112,6 +2112,20 @@ button.has-validation-error {
border-color: color-mix(in srgb, var(--danger) 42%, transparent); border-color: color-mix(in srgb, var(--danger) 42%, transparent);
color: color-mix(in srgb, var(--danger) 82%, white 18%); color: color-mix(in srgb, var(--danger) 82%, white 18%);
} }
.time-row.notification-focus-target-row {
scroll-margin-top: 0.75rem;
}
.time-row.notification-focus-target-row .med-name-text {
color: color-mix(in srgb, var(--primary) 88%, white 12%);
}
.time-row.notification-focus-target-row .tag.subtle {
background: color-mix(in srgb, var(--primary) 12%, transparent);
border-color: color-mix(in srgb, var(--primary) 28%, transparent);
}
.time-main { .time-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2209,12 +2223,12 @@ button.has-validation-error {
color: var(--warning); color: var(--warning);
} }
.dose-item.overdue .dose-btn.take { .dose-item.overdue .dose-btn.take:not(.undo):not(:disabled) {
box-shadow: 0 0 0 2px var(--warning); box-shadow: 0 0 0 2px var(--warning);
animation: overduePulse 1.5s ease-in-out infinite; animation: overduePulse 1.5s ease-in-out infinite;
} }
.dose-item.overdue .dose-btn.take:hover { .dose-item.overdue .dose-btn.take:not(.undo):hover {
filter: brightness(0.87); filter: brightness(0.87);
} }
@@ -2332,7 +2346,7 @@ button.has-validation-error {
} }
.dose-btn .dose-btn-label { .dose-btn .dose-btn-label {
display: none; display: inline;
} }
.dose-btn.take { .dose-btn.take {
@@ -2379,6 +2393,16 @@ button.has-validation-error {
filter: none; filter: none;
} }
.dose-btn.skip {
background: color-mix(in srgb, var(--warning) 18%, var(--bg-tertiary));
border: 1px solid color-mix(in srgb, var(--warning) 52%, var(--border-primary));
color: var(--text-primary);
}
.dose-btn.skip:hover {
filter: brightness(0.94);
}
.dose-btn.take.out-of-stock, .dose-btn.take.out-of-stock,
.dose-btn.take.out-of-stock:disabled, .dose-btn.take.out-of-stock:disabled,
.dashboard-schedules-section .dose-btn.take.out-of-stock, .dashboard-schedules-section .dose-btn.take.out-of-stock,
@@ -2409,6 +2433,18 @@ button.has-validation-error {
filter: brightness(0.9); filter: brightness(0.9);
} }
.dose-btn.undo.take {
background: color-mix(in srgb, var(--success) 82%, var(--accent-bg));
border-color: color-mix(in srgb, var(--success) 88%, white 12%);
color: #eafff6;
}
.dose-btn.undo.skip {
background: color-mix(in srgb, var(--warning) 50%, var(--bg-tertiary));
border-color: color-mix(in srgb, var(--warning) 68%, var(--border-primary));
color: var(--text-primary);
}
/* Per-person dose tracking */ /* Per-person dose tracking */
.dose-checks { .dose-checks {
display: flex; display: flex;
@@ -2426,10 +2462,20 @@ button.has-validation-error {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.dose-person.notification-focus-target {
background: color-mix(in srgb, var(--primary) 16%, var(--accent-bg));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent);
animation: notification-focus-pulse 1.5s ease 2;
}
.dose-person.taken { .dose-person.taken {
background: var(--success-bg); background: var(--success-bg);
} }
.dose-person.skipped {
background: color-mix(in srgb, var(--warning) 20%, var(--accent-bg));
}
.dose-person.overdue { .dose-person.overdue {
background: var(--warning-bg); background: var(--warning-bg);
} }
@@ -2457,6 +2503,10 @@ button.has-validation-error {
color: var(--success); color: var(--success);
} }
.dose-person.skipped .person-name {
color: color-mix(in srgb, var(--warning) 82%, var(--text-primary));
}
.dose-person .dose-btn { .dose-person .dose-btn {
margin-left: 0; margin-left: 0;
height: 24px; height: 24px;
@@ -2465,6 +2515,16 @@ button.has-validation-error {
font-size: 0.75rem; font-size: 0.75rem;
} }
@keyframes notification-focus-pulse {
0%,
100% {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in srgb, var(--primary) 14%, transparent);
}
}
@media (min-width: 769px) { @media (min-width: 769px) {
.time-row { .time-row {
grid-template-columns: minmax(170px, 230px) 1fr; grid-template-columns: minmax(170px, 230px) 1fr;
+1 -1
View File
@@ -613,7 +613,7 @@ body.modal-open {
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
margin-bottom: 1rem; margin-bottom: 1rem;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: visible;
} }
.card { .card {
+17 -3
View File
@@ -10,7 +10,7 @@
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: visible;
} }
.setting-row { .setting-row {
@@ -311,7 +311,7 @@
transition: transition:
opacity 0.15s, opacity 0.15s,
visibility 0.15s; visibility 0.15s;
z-index: 1100; z-index: 12000;
pointer-events: none; pointer-events: none;
} }
@@ -329,7 +329,7 @@
transition: transition:
opacity 0.15s, opacity 0.15s,
visibility 0.15s; visibility 0.15s;
z-index: 1101; z-index: 12001;
} }
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */ /* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
@@ -507,6 +507,20 @@
border-radius: 6px; border-radius: 6px;
} }
.timezone-status {
min-height: 1.25rem;
margin-top: 8px;
margin-bottom: 0;
padding: 0;
font-size: 0.85rem;
color: transparent;
background: transparent;
}
.timezone-status-saved {
color: var(--success);
}
/* Notification Matrix Mobile */ /* Notification Matrix Mobile */
@media (max-width: 480px) { @media (max-width: 480px) {
.notification-matrix { .notification-matrix {
+23 -2
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App"; import App from "../App";
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
}); });
vi.mock("../pages", () => ({ vi.mock("../pages", () => ({
DashboardPage: () => <div>dashboard-page</div>, DashboardPage: () => {
const location = useLocation();
return (
<div>
<span>dashboard-page</span>
<span data-testid="dashboard-location-search">{location.search}</span>
</div>
);
},
MedicationsPage: () => <div>medications-page</div>, MedicationsPage: () => <div>medications-page</div>,
PlannerPage: () => <div>planner-page</div>, PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>, SchedulePage: () => <div>schedule-page</div>,
@@ -265,6 +273,19 @@ describe("App", () => {
expect(screen.getByText("dashboard-page")).toBeInTheDocument(); expect(screen.getByText("dashboard-page")).toBeInTheDocument();
}); });
it("preserves notification query params when redirecting root to dashboard", () => {
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
render(
<MemoryRouter initialEntries={[`/${search}`]}>
<App />
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
});
it("renders initializing state when auth state is missing", () => { it("renders initializing state when auth state is missing", () => {
authMock = { authMock = {
user: null, user: null,
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
oidcProviderName: "", oidcProviderName: "",
}; };
afterEach(() => {
window.history.replaceState({}, "", "/");
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>) (global.fetch as ReturnType<typeof vi.fn>)
@@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => {
it("shows refill history when expanded", () => { it("shows refill history when expanded", () => {
const refillHistory: RefillEntry[] = [ const refillHistory: RefillEntry[] = [
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 }, { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
]; ];
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />); render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
@@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => {
it("calls onRefillHistoryExpandedChange when toggle clicked", () => { it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
const onRefillHistoryExpandedChange = vi.fn(); const onRefillHistoryExpandedChange = vi.fn();
const refillHistory: RefillEntry[] = [ const refillHistory: RefillEntry[] = [
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 }, { id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
]; ];
render( render(
@@ -42,7 +42,7 @@ describe("ReportModal", () => {
json: async () => ({ json: async () => ({
1: { 1: {
dosesTaken: 2, dosesTaken: 2,
dosesDismissed: 0, dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z", firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z", lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [], refills: [],
@@ -74,7 +74,7 @@ describe("ReportModal", () => {
1: { 1: {
dosesTaken: 1, dosesTaken: 1,
automaticDosesTaken: 0, automaticDosesTaken: 0,
dosesDismissed: 0, dosesSkipped: 0,
firstDoseAt: "2026-02-03T12:00:00.000Z", firstDoseAt: "2026-02-03T12:00:00.000Z",
lastDoseAt: null, lastDoseAt: null,
refills: [], refills: [],
@@ -121,7 +121,7 @@ describe("ReportModal", () => {
1: { 1: {
dosesTaken: 0, dosesTaken: 0,
automaticDosesTaken: 0, automaticDosesTaken: 0,
dosesDismissed: 0, dosesSkipped: 0,
firstDoseAt: null, firstDoseAt: null,
lastDoseAt: null, lastDoseAt: null,
refills: [], refills: [],
@@ -183,13 +183,14 @@ describe("ReportModal", () => {
1: { 1: {
dosesTaken: 1, dosesTaken: 1,
automaticDosesTaken: 0, automaticDosesTaken: 0,
dosesDismissed: 0, dosesSkipped: 0,
firstDoseAt: "2026-03-03T12:00:00.000Z", firstDoseAt: "2026-03-03T12:00:00.000Z",
lastDoseAt: null, lastDoseAt: null,
refills: [ refills: [
{ {
packsAdded: 1, packsAdded: 1,
loosePillsAdded: 0, loosePillsAdded: 0,
quantityAdded: 20,
usedPrescription: false, usedPrescription: false,
refillDate: "2026-03-04", refillDate: "2026-03-04",
}, },
@@ -251,6 +252,81 @@ describe("ReportModal", () => {
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled(); expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
}); });
it("sends the selected person filter with the report request and clears it for all people", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
automaticDosesTaken: 0,
dosesSkipped: 1,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
2: {
dosesTaken: 1,
automaticDosesTaken: 0,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
const firstRender = render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
})
);
});
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
firstRender.unmount();
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
})
);
});
});
it("generates markdown report and keeps modal open on fetch error", async () => { it("generates markdown report and keeps modal open on fetch error", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false }); (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SharedSchedule } from "../../components/SharedSchedule"; import { SharedSchedule } from "../../components/SharedSchedule";
@@ -168,10 +168,58 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
}; };
} }
function createSharedDoseFetchMock(options: {
token?: string;
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
}) {
const token = options.token ?? "token-123";
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const body =
typeof init?.body === "string" && init.body.length > 0
? (JSON.parse(init.body) as { doseId: string })
: undefined;
requests.push({ url, method, body });
if (url === `/api/share/${token}` && method === "GET") {
return { ok: true, json: async () => options.sharedData };
}
if (url === `/api/share/${token}/doses` && method === "GET") {
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
}
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
doseState.set(body.doseId, { doseId: body.doseId, skipped: true });
return { ok: true, json: async () => ({}) };
}
if (url === `/api/share/${token}/doses` && method === "POST" && body?.doseId) {
doseState.set(body.doseId, { doseId: body.doseId, takenSource: "manual" });
return { ok: true, json: async () => ({}) };
}
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
doseState.delete(doseId);
return { ok: true, json: async () => ({}) };
}
return Promise.reject(new Error(`Unexpected request: ${method} ${url}`));
});
return { fetchMock, requests, getDoses: () => Array.from(doseState.values()) };
}
describe("SharedSchedule", () => { describe("SharedSchedule", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.localStorage.clear(); window.localStorage.clear();
globalThis.fetch = vi.fn() as unknown as typeof fetch;
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>); vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
}); });
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
it("renders shared schedule shell for valid token", async () => { it("renders shared schedule shell for valid token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => { (globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
} }
if (url === "/api/share/token-123") { if (url === "/api/share/token-123") {
@@ -247,7 +295,7 @@ describe("SharedSchedule", () => {
it("renders generic error when loading share data fails", async () => { it("renders generic error when loading share data fails", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => { (globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
} }
if (url === "/api/share/token-123") { if (url === "/api/share/token-123") {
@@ -270,7 +318,7 @@ describe("SharedSchedule", () => {
const sharedData = createSharedDataWithTodayDose(referenceNow); const sharedData = createSharedDataWithTodayDose(referenceNow);
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => { (globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
json: () => json: () =>
@@ -296,7 +344,7 @@ describe("SharedSchedule", () => {
const sharedData = createSharedDataWithEmbeddedOverview(); const sharedData = createSharedDataWithEmbeddedOverview();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => { (globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
} }
if (url === "/api/share/token-123") { if (url === "/api/share/token-123") {
@@ -318,4 +366,90 @@ describe("SharedSchedule", () => {
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0); expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
expect(screen.getByText("share.noSchedule")).toBeInTheDocument(); expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
}); });
it("skips a neutral shared dose via the skip endpoint", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = createSharedDataWithTodayDose(referenceNow);
const { fetchMock, requests } = createSharedDoseFetchMock({ sharedData });
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("dose.skip"));
await waitFor(() => {
expect(requests).toContainEqual({
url: "/api/share/token-123/doses/skip",
method: "POST",
body: { doseId: sharedData.automaticDoseId },
});
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
});
});
it("undoes a skipped shared dose via the delete skip endpoint", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = createSharedDataWithTodayDose(referenceNow);
const { fetchMock, requests } = createSharedDoseFetchMock({
sharedData,
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("dose.undoSkip"));
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/doses/skip/${sharedData.automaticDoseId}`,
method: "DELETE",
});
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
});
});
it("takes a skipped shared dose again via the take endpoint", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = createSharedDataWithTodayDose(referenceNow);
const { fetchMock, requests, getDoses } = createSharedDoseFetchMock({
sharedData,
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("dose.take"));
await waitFor(() => {
expect(requests).toContainEqual({
url: "/api/share/token-123/doses",
method: "POST",
body: { doseId: sharedData.automaticDoseId },
});
expect(getDoses()).toEqual([
expect.objectContaining({ doseId: sharedData.automaticDoseId, takenSource: "manual" }),
]);
expect(document.querySelector(".day-block.today")).toHaveClass("all-taken");
});
});
}); });
@@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => {
const sharedData = createSharedData(); const sharedData = createSharedData();
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => { (globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
} }
if (url === "/api/share/token-123") { if (url === "/api/share/token-123") {
@@ -37,6 +37,7 @@ vi.mock("../../hooks", () => ({
vi.mock("../../utils/formatters", () => ({ vi.mock("../../utils/formatters", () => ({
getSystemLocale: () => "en-US", getSystemLocale: () => "en-US",
setDefaultFormattingTimezone: vi.fn(),
})); }));
vi.mock("../../utils/schedule", async () => { vi.mock("../../utils/schedule", async () => {
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../../../features/schedule/formatters";
const t = (key: string, options?: Record<string, unknown>): string => {
switch (key) {
case "form.packageAmountUnitMl":
return "ml";
case "form.blisters.teaspoons":
return Number(options?.count) === 1 ? "teaspoon" : "teaspoons";
case "form.blisters.tablespoons":
return Number(options?.count) === 1 ? "tablespoon" : "tablespoons";
case "form.blisters.applications":
return Number(options?.count) === 1 ? "application" : "applications";
case "common.pill":
return "pill";
case "common.pills":
return "pills";
case "common.pillsTotal":
return `${options?.count ?? 0} pills total`;
default:
return key;
}
};
describe("schedule formatters", () => {
it("formats liquid dose labels in base and converted units", () => {
expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 0, t, "ml")).toBe("0 ml");
expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 2, t, "tsp")).toBe("2 teaspoons 10 ml");
});
it("formats tube doses as applications by default and ml for liquid forms", () => {
expect(formatScheduleDoseUsageLabel({ packageType: "tube" }, 1, t)).toBe("1 application");
expect(formatScheduleDoseUsageLabel({ packageType: "tube", medicationForm: "liquid" }, 3, t)).toBe("3 ml");
});
it("formats liquid totals from dose units and mixed-unit conversion", () => {
expect(
formatScheduleTotalUsageLabel(
{ packageType: "liquid_container" },
0,
t,
[
{ usage: 1, intakeUnit: "tsp" },
{ usage: 2, intakeUnit: "tsp" },
],
"ml"
)
).toBe("3 teaspoons 15 ml");
expect(
formatScheduleTotalUsageLabel(
{ packageType: "liquid_container" },
0,
t,
[
{ usage: 1, intakeUnit: "tsp" },
{ usage: 1, intakeUnit: "tbsp" },
],
"ml"
)
).toBe("20 ml");
});
it("falls back to total and non-liquid totals when dose list is not usable", () => {
expect(
formatScheduleTotalUsageLabel(
{ packageType: "liquid_container" },
4,
t,
[{ usage: -1, intakeUnit: "ml" }],
"tbsp"
)
).toBe("4 tablespoons 60 ml");
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills total");
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
areAllDoseIdsTaken,
countTakenDoseIds,
resolveCollapsedState,
toggleDateInSet,
} from "../../../features/schedule/interactions";
describe("schedule interactions", () => {
it("toggles dates without mutating the original set", () => {
const previous = new Set(["2026-01-01"]);
const added = toggleDateInSet(previous, "2026-01-02");
const removed = toggleDateInSet(added, "2026-01-01");
expect(previous).toEqual(new Set(["2026-01-01"]));
expect(added).toEqual(new Set(["2026-01-01", "2026-01-02"]));
expect(removed).toEqual(new Set(["2026-01-02"]));
});
it("resolves auto and manual collapsed states", () => {
expect(resolveCollapsedState(true, "2026-01-01", new Set(), new Set())).toBe(true);
expect(resolveCollapsedState(true, "2026-01-01", new Set(["2026-01-01"]), new Set())).toBe(false);
expect(resolveCollapsedState(false, "2026-01-01", new Set(), new Set(["2026-01-01"]))).toBe(true);
});
it("counts and checks taken dose ids", () => {
const taken = new Set(["a", "c"]);
const isDoseTaken = (doseId: string) => taken.has(doseId);
expect(countTakenDoseIds(["a", "b", "c"], isDoseTaken)).toBe(2);
expect(areAllDoseIdsTaken(["a", "c"], isDoseTaken)).toBe(true);
expect(areAllDoseIdsTaken(["a", "b"], isDoseTaken)).toBe(false);
expect(areAllDoseIdsTaken([], isDoseTaken)).toBe(false);
});
});
+52 -3
View File
@@ -31,7 +31,9 @@ describe("useRefill", () => {
}); });
it("loads refill history", async () => { it("loads refill history", async () => {
const mockHistory = [{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: "2024-03-15T10:00:00Z" }]; const mockHistory = [
{ id: 1, packsAdded: 2, loosePillsAdded: 0, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" },
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, ok: true,
@@ -49,7 +51,7 @@ describe("useRefill", () => {
it("handles refill history with refills wrapper", async () => { it("handles refill history with refills wrapper", async () => {
const mockHistory = { const mockHistory = {
refills: [{ id: 1, packsAdded: 2, createdAt: "2024-03-15T10:00:00Z" }], refills: [{ id: 1, packsAdded: 2, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" }],
}; };
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
@@ -162,7 +164,7 @@ describe("useRefill", () => {
"/api/medications/1/refill", "/api/medications/1/refill",
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }), body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }),
}) })
); );
expect(fetch).toHaveBeenNthCalledWith( expect(fetch).toHaveBeenNthCalledWith(
@@ -505,6 +507,53 @@ describe("useRefill", () => {
}); });
}); });
it("keeps liquid stock correction base fields aligned", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const liquidMed: Medication = {
id: 12,
name: "Aligned Liquid",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 1,
packageAmountValue: 180,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 180,
looseTablets: 180,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(liquidMed, {
all: [{ name: liquidMed.name, medsLeft: 180, daysLeft: 36 }] as Coverage[],
});
result.current.setEditStockFullBlisters(2);
result.current.setEditStockPartialBlisterPills(300);
});
await act(async () => {
await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: -60,
packCount: 2,
totalPills: 360,
looseTablets: 360,
});
});
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => { it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true }); (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
@@ -130,6 +130,13 @@ const mockTodayDay = {
], ],
}; };
function getRouteDateKey(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// Default mock factory // Default mock factory
const createMockAppContext = (overrides = {}) => ({ const createMockAppContext = (overrides = {}) => ({
meds: [], meds: [],
@@ -158,6 +165,7 @@ const createMockAppContext = (overrides = {}) => ({
todayDay: null, todayDay: null,
futureDays: [], futureDays: [],
takenDoses: new Set(), takenDoses: new Set(),
skippedDoses: new Set(),
dismissedDoses: new Set(), dismissedDoses: new Set(),
markDoseTaken: vi.fn(), markDoseTaken: vi.fn(),
undoDoseTaken: vi.fn(), undoDoseTaken: vi.fn(),
@@ -321,6 +329,7 @@ describe("DashboardPage", () => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
mockContextValue = createMockAppContext(); mockContextValue = createMockAppContext();
HTMLElement.prototype.scrollIntoView = vi.fn();
}); });
it("renders dashboard page", () => { it("renders dashboard page", () => {
@@ -377,6 +386,41 @@ describe("DashboardPage", () => {
expect(cards.length).toBeGreaterThan(0); expect(cards.length).toBeGreaterThan(0);
}); });
it("renders today doses even when schedule data omits takenBy arrays", () => {
mockContextValue = createMockAppContext({
todayDay: {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [
{
id: "dose-without-taken-by",
timeStr: "09:00",
when: Date.now() + 60_000,
usage: 1,
takenBy: undefined as unknown as string[],
},
],
lastWhen: Date.now() + 60_000,
},
],
},
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
expect(screen.getByText("09:00")).toBeInTheDocument();
});
it("renders schedule days selector", () => { it("renders schedule days selector", () => {
render( render(
<MemoryRouter> <MemoryRouter>
@@ -505,6 +549,7 @@ describe("DashboardPage interactions", () => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
mockContextValue = createMockAppContext(); mockContextValue = createMockAppContext();
HTMLElement.prototype.scrollIntoView = vi.fn();
}); });
it("has schedule days options", () => { it("has schedule days options", () => {
@@ -539,6 +584,138 @@ describe("DashboardPage interactions", () => {
expect(setScheduleDays).toHaveBeenCalledWith(90); expect(setScheduleDays).toHaveBeenCalledWith(90);
}); });
it("renders today doses when skip state is missing from an older app context shape", () => {
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
todayDay: mockTodayDay,
skippedDoses: undefined,
markDoseSkipped: undefined,
undoDoseSkipped: undefined,
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
expect(screen.getByText("Today")).toBeInTheDocument();
expect(document.querySelector(".day-block.today .dose-btn.take")).toBeInTheDocument();
expect(document.querySelector(".day-block.today .dose-btn.skip")).not.toBeInTheDocument();
});
it("keeps the dashboard rendered when notification focus scrolling fails", async () => {
const doseId = String(mockTodayDay.meds[0].doses[0].id);
HTMLElement.prototype.scrollIntoView = vi.fn(() => {
throw new Error("scroll failed");
});
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
todayDay: mockTodayDay,
});
render(
<MemoryRouter
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
>
<DashboardPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Today")).toBeInTheDocument();
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
expect(targetDose).toHaveClass("notification-focus-target");
});
});
it("highlights and scrolls to the notification-linked dashboard dose", async () => {
const doseId = String(mockTodayDay.meds[0].doses[0].id);
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
todayDay: mockTodayDay,
});
render(
<MemoryRouter
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
>
<DashboardPage />
</MemoryRouter>
);
await waitFor(() => {
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
const targetRow = document.querySelector('[data-med-id="1"]');
expect(targetDose).toHaveClass("notification-focus-target");
expect(targetRow).toHaveClass("notification-focus-target-row");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
});
});
it("supports the shorter dashboard notification query params", async () => {
const doseId = String(mockTodayDay.meds[0].doses[0].id);
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
todayDay: mockTodayDay,
});
render(
<MemoryRouter
initialEntries={[`/dashboard?day=${getRouteDateKey(mockTodayDay.date)}&dose=${encodeURIComponent(doseId)}`]}
>
<DashboardPage />
</MemoryRouter>
);
await waitFor(() => {
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
const targetRow = document.querySelector('[data-med-id="1"]');
expect(targetDose).toHaveClass("notification-focus-target");
expect(targetRow).toHaveClass("notification-focus-target-row");
});
});
it("scrolls to the notification-linked dashboard dose after schedule data loads", async () => {
const doseId = String(mockTodayDay.meds[0].doses[0].id);
mockContextValue = createMockAppContext();
const { rerender } = render(
<MemoryRouter
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
>
<DashboardPage />
</MemoryRouter>
);
expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull();
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
todayDay: mockTodayDay,
});
rerender(
<MemoryRouter
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
>
<DashboardPage />
</MemoryRouter>
);
await waitFor(() => {
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
expect(targetDose).toHaveClass("notification-focus-target");
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
});
});
it("hides past and future sections when upcomingTodayOnly is enabled", () => { it("hides past and future sections when upcomingTodayOnly is enabled", () => {
mockContextValue = createMockAppContext({ mockContextValue = createMockAppContext({
settings: { settings: {
@@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => {
}); });
}); });
it("opens read-only view from viewMedId query parameter", async () => {
const startEdit = vi.fn();
mockFormHookValue = createMockFormHook({ startEdit });
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
renderPage("/medications?viewMedId=1");
await waitFor(() => {
expect(startEdit).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("common.close")).toBeInTheDocument();
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
});
it("opens unsaved confirm and continues edit after confirmation", async () => { it("opens unsaved confirm and continues edit after confirmation", async () => {
const startEdit = vi.fn(); const startEdit = vi.fn();
const resetForm = vi.fn(); const resetForm = vi.fn();
@@ -103,9 +103,12 @@ const createMockContext = (overrides = {}) => ({
pastDays: [], pastDays: [],
futureDays: [], futureDays: [],
takenDoses: new Set(), takenDoses: new Set(),
skippedDoses: new Set(),
dismissedDoses: new Set(), dismissedDoses: new Set(),
markDoseTaken: vi.fn(), markDoseTaken: vi.fn(),
markDoseSkipped: vi.fn(),
undoDoseTaken: vi.fn(), undoDoseTaken: vi.fn(),
undoDoseSkipped: vi.fn(),
coverageByMed: {}, coverageByMed: {},
depletionByMed: {}, depletionByMed: {},
manuallyExpandedDays: new Set(), manuallyExpandedDays: new Set(),
@@ -674,6 +677,144 @@ describe("SchedulePage with taken doses", () => {
}); });
}); });
describe("SchedulePage skip behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
});
});
it("shows a skip action alongside take for neutral doses", () => {
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
});
it("calls markDoseSkipped when clicking skip", () => {
const markDoseSkipped = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
markDoseSkipped,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
const skipButton = document.querySelector(".dose-btn.skip");
expect(skipButton).toBeInTheDocument();
if (skipButton) {
fireEvent.click(skipButton);
}
expect(markDoseSkipped).toHaveBeenCalledWith(`1-0-${FIXED_TIMESTAMP}-John`);
});
it("renders undo skip state for skipped doses", () => {
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
skippedDoses: new Set([skippedDoseId]),
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
expect(screen.getByText("John").closest(".dose-person")).toHaveClass("skipped");
});
it("calls undoDoseSkipped when clicking undo skip", () => {
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
const undoDoseSkipped = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
skippedDoses: new Set([skippedDoseId]),
undoDoseSkipped,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
const undoSkipButton = document.querySelector(".dose-btn.undo.skip");
expect(undoSkipButton).toBeInTheDocument();
if (undoSkipButton) {
fireEvent.click(undoSkipButton);
}
expect(undoDoseSkipped).toHaveBeenCalledWith(skippedDoseId);
});
it("does not mark skipped due doses as overdue", () => {
vi.useFakeTimers();
const now = new Date("2026-01-22T12:00:00.000Z");
vi.setSystemTime(now);
const when = new Date("2026-01-22T09:00:00.000Z").getTime();
const baseDoseId = `1-0-${when}`;
const skippedDoseId = `${baseDoseId}-John`;
const dueDay = [
{
dateStr: "Wed, Jan 22",
date: new Date(now),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [{ id: baseDoseId, timeStr: "09:00", when, usage: 1, takenBy: ["John"] }],
lastWhen: when,
},
],
},
];
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: dueDay,
coverageByMed: mockCoverageByMed,
skippedDoses: new Set([skippedDoseId]),
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
const personRow = screen.getByText("John").closest(".dose-person");
expect(personRow).toHaveClass("skipped");
expect(personRow).not.toHaveClass("overdue");
vi.useRealTimers();
});
});
describe("SchedulePage with low stock", () => { describe("SchedulePage with low stock", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
+28 -1
View File
@@ -134,6 +134,20 @@ describe("getMedTotal", () => {
expect(getMedTotal(tube)).toBe(604); expect(getMedTotal(tube)).toBe(604);
expect(getMedTotal(liquid)).toBe(450); expect(getMedTotal(liquid)).toBe(450);
}); });
it("prefers canonical amount-base stock over compatibility mirror fields", () => {
const liquid = {
packageType: "liquid_container" as const,
packCount: 2,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 300,
looseTablets: 150,
stockAdjustment: 0,
};
expect(getMedTotal(liquid)).toBe(150);
});
}); });
describe("getPackageSize", () => { describe("getPackageSize", () => {
@@ -200,7 +214,7 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(80); expect(getPackageSize(med)).toBe(80);
}); });
it("returns totalPills for tube/liquid container package size", () => { it("returns canonical amount-base stock for tube/liquid container package size", () => {
const tube = { const tube = {
packageType: "tube" as const, packageType: "tube" as const,
packCount: 4, packCount: 4,
@@ -221,6 +235,19 @@ describe("getPackageSize", () => {
expect(getPackageSize(tube)).toBe(600); expect(getPackageSize(tube)).toBe(600);
expect(getPackageSize(liquid)).toBe(450); expect(getPackageSize(liquid)).toBe(450);
}); });
it("prefers canonical amount-base stock for package size when compatibility mirror drifts", () => {
const tube = {
packageType: "tube" as const,
packCount: 2,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 300,
looseTablets: 150,
};
expect(getPackageSize(tube)).toBe(150);
});
}); });
describe("getStockDisplayCapacity", () => { describe("getStockDisplayCapacity", () => {

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