Compare commits

..

79 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
Copilot d5b3c5c21f fix: remove upgrade-insecure-requests from CSP — blank homepage on HTTP deployments (#525)
* Initial plan

* fix: remove upgrade-insecure-requests from CSP to fix blank homepage over HTTP

The upgrade-insecure-requests CSP directive instructs browsers to
upgrade same-host HTTP requests to HTTPS (preserving port). In the
default plain-HTTP Docker deployment (port 4174), the browser upgrades
every asset URL to https://host:4174/... and sends a TLS Client Hello
to the HTTP nginx port. nginx cannot parse TLS bytes as HTTP and returns
400 with no method/URI (the observed "400 - -" log pattern). All
JS/CSS bundles fail to load, React never mounts, page stays blank.

Fix: remove "; upgrade-insecure-requests" from the CSP string.
This directive is intended for HTTPS-only sites and is harmful on
plain-HTTP servers. Removing it does not weaken security for HTTP
deployments.

Agent-Logs-Url: https://github.com/DanielVolz/medassist-ng/sessions/9c4db7bd-1272-49ca-abf3-73c2ad5a5354

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-04-10 12:28:38 +02:00
Daniel Volz 002f16c505 fix(security): centralize SMTP transport creation
Centralize SMTP transport creation to reduce the duplicated CodeQL SMTP request-forgery path.
2026-04-08 20:00:57 +02:00
dependabot[bot] aa050f7dc5 build(deps): bump nodemailer from 8.0.4 to 8.0.5 in /backend
Bump nodemailer from 8.0.4 to 8.0.5 in /backend
2026-04-08 19:39:51 +02:00
Daniel Volz 0795bfe589 chore(release): 1.22.2
Release v1.22.2
2026-04-08 19:34:24 +02:00
Daniel Volz 25483c12f0 fix(security): mitigate backend drizzle-kit audit chain 2026-04-08 19:21:05 +02:00
dependabot[bot] 2a340855fb build(deps): bump vite from 8.0.3 to 8.0.5 in /backend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [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.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  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-04-07 07:01:11 +02:00
dependabot[bot] 52fec1a4e5 build(deps-dev): bump vite from 8.0.3 to 8.0.5 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [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.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 06:56:40 +02:00
dependabot[bot] 1cb4a44cef build(deps-dev): bump @types/nodemailer from 7.0.11 to 8.0.0 in /backend
Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 7.0.11 to 8.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-06 11:00:55 +02:00
dependabot[bot] 51b09dc563 build(deps): bump the minor-and-patch group in /backend with 3 updates
Bumps the minor-and-patch group in /backend with 3 updates: [dotenv](https://github.com/motdotla/dotenv), [@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 `dotenv` from 17.3.1 to 17.4.1
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.3.1...v17.4.1)

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

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

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-06 10:54:02 +02:00
dependabot[bot] dbbd9d5ed8 build(deps-dev): bump @biomejs/biome from 2.4.9 to 2.4.10 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.9 to 2.4.10
- [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.10/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  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-06 10:50:19 +02:00
dependabot[bot] 15f1e33aa4 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.1` | `26.0.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.1` | `17.0.2` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.2` | `7.14.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.58.2` | `1.59.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.0` | `25.5.2` |


Updates `i18next` from 26.0.1 to 26.0.3
- [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.1...v26.0.3)

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

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

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

Updates `@playwright/test` from 1.58.2 to 1.59.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.58.2...v1.59.1)

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.5.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-04-06 10:49:32 +02:00
111 changed files with 10135 additions and 2626 deletions
+9 -2
View File
@@ -13,6 +13,12 @@ PORT=3000
CORS_ORIGINS=http://localhost:4174
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
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
@@ -37,7 +43,8 @@ LOG_LEVEL=warn
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# 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
# =============================================================================
@@ -148,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+7 -1
View File
@@ -1,11 +1,17 @@
# 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.
- 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.
- If `doku/report.md` is missing, create it immediately.
- 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.
## Required Startup Steps
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v1.0.2
- uses: actions/add-to-project@v2.0.0
with:
project-url: ${{ vars.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -196,7 +196,7 @@ jobs:
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.current_tag.outputs.value }}
target_commitish: ${{ github.sha }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps:
- name: Move project item to Done
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Sync fields
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Build weekly summary
id: summary
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -59,7 +59,7 @@ jobs:
core.setOutput('body', body);
- name: Publish report issue
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+26 -2
View File
@@ -83,18 +83,42 @@ Thumbs.db
AGENTS.md
docs/TECH_STACK.md
doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md
doku/report.md
plan/
.copilot-tracking/
.playwright-cli/
.agents/
skills-lock.json
# ===================
# Local Spec Kit artifacts
# Local Spec Kit workspace state
# ===================
.specify/
specs/
docs/SPEC_KIT.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/prompts/speckit.*.prompt.md
.github/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 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/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<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-911%2F911-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 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 |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
Recommended values for API docs by environment:
@@ -305,6 +305,8 @@ API reference:
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `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)
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)
- 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:
```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,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
}
]
}
+398 -1427
View File
File diff suppressed because it is too large Load Diff
+22 -16
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.22.1",
"version": "1.24.0",
"private": true,
"type": "module",
"scripts": {
@@ -19,36 +19,42 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/formbody": "^8.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@libsql/client": "^0.17.2",
"@fastify/swagger-ui": "^5.2.6",
"@libsql/client": "^0.17.3",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.4",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.2",
"nodemailer": "^8.0.4",
"openid-client": "^6.8.2",
"jose": "^6.2.3",
"nodemailer": "^8.0.7",
"openid-client": "^6.8.4",
"sharp": "^0.34.5",
"zod": "^3.23.8"
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.6.2",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/coverage-v8": "^4.1.5",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^6.0.2",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
},
"overrides": {
"@esbuild-kit/esm-loader": "2.6.5",
"@esbuild-kit/core-utils": "3.3.2",
"esbuild": "0.25.4"
}
}
+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 reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
@@ -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_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
@@ -95,6 +97,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0,
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 = [
`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 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)`,
];
+1
View File
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
timezone text NOT NULL DEFAULT '',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
+43 -4
View File
@@ -105,10 +105,12 @@ export const userSettings = sqliteTable("user_settings", {
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
// Current MedAssist versions no longer read or expose this setting in product flows.
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences
@@ -182,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
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
// =============================================================================
@@ -193,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
+6
View File
@@ -109,6 +109,8 @@ type TranslationKeys = {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
intakeTakenConfirmation: string;
intakeSkippedConfirmation: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
@@ -234,6 +236,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
intakeTakenConfirmation: "✅ This dose was marked as taken.",
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
@@ -355,6 +359,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
+59 -4
View File
@@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
import { notificationActionRoutes } from "./routes/notification-actions.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js";
@@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) {
return base;
}
function buildHelmetOptions(_isProduction: boolean) {
return {};
}
function isPublicNotificationActionPath(url: string | undefined): boolean {
if (!url) {
return false;
}
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
@@ -166,6 +180,7 @@ export async function createApp(options?: {
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
@@ -182,8 +197,26 @@ export async function createApp(options?: {
// Register plugins
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
await app.register(helmet, buildHelmetOptions(opts.isProduction));
await app.register(cors, {
hook: "preHandler",
delegator: (request, callback) => {
if (isPublicNotificationActionPath(request.raw.url)) {
callback(null, {
origin: true,
credentials: false,
methods: ["GET", "HEAD", "POST", "OPTIONS"],
preflightContinue: true,
});
return;
}
callback(null, {
origin: opts.corsOrigins,
credentials: true,
});
},
});
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
@@ -212,6 +245,7 @@ export async function createApp(options?: {
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
@@ -266,8 +300,26 @@ app.decorate("config", {
});
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: origins, credentials: true });
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
await app.register(cors, {
hook: "preHandler",
delegator: (request, callback) => {
if (isPublicNotificationActionPath(request.raw.url)) {
callback(null, {
origin: true,
credentials: false,
methods: ["GET", "HEAD", "POST", "OPTIONS"],
preflightContinue: true,
});
return;
}
callback(null, {
origin: origins,
credentials: true,
});
},
});
await app.register(rateLimit, {
max: Number(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: "1 minute",
@@ -294,6 +346,7 @@ await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
@@ -309,6 +362,7 @@ const start = async () => {
startReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg),
});
@@ -323,6 +377,7 @@ const start = async () => {
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
+17 -16
View File
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default("3000"),
.default("3000")
.transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
OPENAPI_DOCS_ENABLED: z
.string()
.transform((v) => v === "true")
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
// Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z
.string()
.transform((v) => v === "true")
.default("true"),
.default("true")
.transform((v) => v === "true"),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default("15"),
.default("15")
.transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default("7"),
.default("7")
.transform((v) => parseInt(v, 10)),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default("true"),
.default("true")
.transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
+2 -2
View File
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
@@ -616,7 +616,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = updateProfileSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
+27 -31
View File
@@ -6,6 +6,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.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 {
applyOpenApiRouteStandards,
@@ -61,6 +62,15 @@ const doseReadResponseSchema = {
},
} 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
// Returns anonymous user ID when auth is disabled
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);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
error: getValidationErrorMessage(parsed.error),
});
}
const { doseId } = parsed.data;
// Check if already marked
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
const result = await markDoseTakenForUser({
userId,
doseId,
source: "manual",
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" };
}
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 };
}
);
@@ -423,23 +421,22 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
error: getValidationErrorMessage(parsed.error),
});
}
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;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) {
await db
.update(doseTracking)
@@ -448,7 +445,6 @@ export async function doseRoutes(app: FastifyInstance) {
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
@@ -590,7 +586,7 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
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)
// =============================================================================
const EXPORT_VERSION = "1.4";
const EXPORT_VERSION = "1.5";
// =============================================================================
// Zod Schemas for Import Validation
@@ -96,7 +96,8 @@ const doseHistorySchema = z.object({
const refillHistoryExportSchema = z.object({
medicationRef: z.string(), // References _exportId
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).optional(),
quantityAdded: z.number().int().min(0).optional(),
usedPrescription: z.boolean().default(false),
refillDate: z.string(), // ISO datetime
});
@@ -108,37 +109,44 @@ const shareLinkSchema = z.object({
regenerateToken: z.boolean().default(true),
});
const settingsExportSchema = z
.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
expiryWarningDays: z.number().int().default(90),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareStockStatus: z.boolean().default(true),
shareMedicationOverview: z.boolean().default(false),
const settingsSchemaBase = z.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
expiryWarningDays: z.number().int().default(90),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
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();
@@ -149,7 +157,7 @@ const importDataSchema = z.object({
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
refillHistory: z.array(refillHistoryExportSchema).default([]),
settings: settingsExportSchema,
settings: importSettingsSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
@@ -210,7 +218,7 @@ const importBodyOpenApiSchema = {
},
],
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" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
@@ -370,6 +378,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 1. Load all medications
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
const medIdToExportId = new Map<number, string>();
@@ -509,7 +518,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
@@ -548,6 +556,13 @@ export async function exportRoutes(app: FastifyInstance) {
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
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
let refillDateIso: string;
@@ -568,6 +583,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
quantityAdded,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
@@ -778,6 +794,8 @@ export async function exportRoutes(app: FastifyInstance) {
// 5. Import 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({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
@@ -802,7 +820,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
@@ -830,7 +847,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
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 allowsBottleCapacityUpdate = packageType === "bottle";
if (allowsAmountBaseUpdate) {
if (totalPills !== undefined) updateFields.totalPills = totalPills;
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
const normalizedAmountBase = looseTablets ?? totalPills;
if (normalizedAmountBase !== undefined) {
updateFields.totalPills = normalizedAmountBase;
updateFields.looseTablets = normalizedAmountBase;
}
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
}
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills;
}
if (packCount !== undefined) updateFields.packCount = packCount;
if (looseTablets !== undefined) {
if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
updateFields.looseTablets = looseTablets;
}
@@ -1654,7 +1657,7 @@ export async function medicationRoutes(app: FastifyInstance) {
async (req, reply) => {
const parsed = dismissUntilSchema.safeParse(req.body);
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);
+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);
} catch (err: unknown) {
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
if (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) {
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
return reply.redirect(getFrontendUrl());
}
// Verify state
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
if (!storedState.valid || storedState.value !== state) {
request.log.warn("[OIDC] State mismatch during callback validation");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
return reply.redirect(getFrontendUrl());
}
// Get code verifier
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
if (!storedVerifier.valid || !storedVerifier.value) {
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
return reply.redirect(getFrontendUrl());
}
try {
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const sub = tokens.claims()?.sub;
if (!sub) {
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);
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
"[OIDC] Missing required user info"
);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
return reply.redirect(getFrontendUrl());
}
// Clean cookies
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
if (!user) {
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
return reply.redirect(getFrontendUrl());
}
// Update last login
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Callback processing failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
return reply.redirect(getFrontendUrl());
}
}
);
+4 -16
View File
@@ -1,6 +1,5 @@
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import {
@@ -20,7 +19,7 @@ import {
type StockReminderItem as SharedStockReminderItem,
} from "../services/notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -428,19 +427,9 @@ ${getFooterPlain(language)}`;
`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
const mailResult = await transporter.sendMail({
const mailResult = await sendEmailNotification({
from: smtpFrom,
to: email,
subject: t(dc.subject, { from: fromDate, until: untilDate }),
@@ -448,9 +437,8 @@ ${getFooterPlain(language)}`;
html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Failed to send demand email");
}
request.log.info(
+46 -24
View File
@@ -18,10 +18,11 @@ const refillSchema = z
.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
quantityAdded: z.number().int().min(0).default(0),
usePrescription: z.boolean().default(false),
})
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
message: "Must add at least one pack or some loose pills",
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
message: "Must add at least one pack or some quantity",
});
const refillBodyOpenApiSchema = {
@@ -29,12 +30,14 @@ const refillBodyOpenApiSchema = {
properties: {
packsAdded: { type: "integer", minimum: 0, default: 0 },
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
quantityAdded: { type: "integer", minimum: 0, default: 0 },
usePrescription: { type: "boolean", default: false },
},
description: "Provide at least one pack or some loose pills.",
description: "Provide at least one pack or some quantity.",
example: {
packsAdded: 1,
loosePillsAdded: 4,
quantityAdded: 4,
usePrescription: true,
},
} as const;
@@ -49,6 +52,7 @@ const refillResponseSchema = {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" },
},
@@ -80,6 +84,7 @@ const refillHistoryItemSchema = {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" },
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)));
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 isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
@@ -153,7 +159,9 @@ export async function refillRoutes(app: FastifyInstance) {
: fallbackAmountPerPackage;
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));
let effectivePacksAdded = requestedPackAdds;
@@ -166,6 +174,9 @@ export async function refillRoutes(app: FastifyInstance) {
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
@@ -183,11 +194,31 @@ export async function refillRoutes(app: FastifyInstance) {
}
}
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
const previousAmountBase = med.totalPills ?? med.looseTablets;
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
const refillBaselineAt = new Date();
const baselineStockBeforeRefill = isAmountBased
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
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;
if (usePrescription) {
@@ -197,10 +228,10 @@ export async function refillRoutes(app: FastifyInstance) {
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
const refillBaselineAt = new Date();
const updatePayload: {
packCount: number;
looseTablets: number;
stockAdjustment: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
@@ -209,6 +240,7 @@ export async function refillRoutes(app: FastifyInstance) {
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
stockAdjustment: newStockAdjustment,
prescriptionRemainingRefills: newRemainingRefills,
lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt,
@@ -236,31 +268,20 @@ export async function refillRoutes(app: FastifyInstance) {
})
.returning();
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
if (isCountBasedAmountPackage) {
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
} else if (isBottle) {
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
}
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
quantityAdded: totalPillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
totalPills: targetCurrentStock,
},
prescription: {
used: usePrescription,
@@ -316,6 +337,7 @@ export async function refillRoutes(app: FastifyInstance) {
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
+47 -7
View File
@@ -14,6 +14,7 @@ import {
const reportDataSchema = z.object({
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 = {
@@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
takenByFilter: {
type: "array",
maxItems: 50,
items: { type: "string", minLength: 1, maxLength: 100 },
},
},
example: {
medicationIds: [1, 3, 5],
takenByFilter: ["Daniel"],
},
} 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 = {
type: "object",
additionalProperties: {
@@ -39,7 +55,7 @@ const reportDataResponseSchema = {
properties: {
dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" },
dosesDismissed: { type: "integer" },
dosesSkipped: { type: "integer" },
firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" },
refills: {
@@ -49,6 +65,7 @@ const reportDataResponseSchema = {
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
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());
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
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));
for (const id of medicationIds) {
@@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) {
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
@@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) {
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
dosesSkipped: number;
firstDoseAt: 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 takenDoses = doses.filter((d) => !d.dismissed);
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 medication = medMap.get(medId);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
const refills = await db
@@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) {
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
dosesSkipped: skippedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
+101 -72
View File
@@ -1,16 +1,27 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.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 { 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 {
classifyTestEmailFailure,
getAllUserSettingsFromDb,
getAvailableTimezones,
getDefaultSettings,
getNotificationProvider,
loadUserSettingsFromDb,
normalizeSettingsTimezone,
sanitizeNotificationUrl,
type UserSettings,
validateNotificationHostname,
@@ -20,6 +31,7 @@ import type { AuthUser } from "../types/fastify.js";
export type { UserSettings } from "../services/settings-service.js";
type SettingsBody = {
timezone: string;
emailEnabled: boolean;
notificationEmail: string;
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 {
const val = process.env[key];
if (val === undefined) return defaultVal;
@@ -104,6 +86,24 @@ function envInt(key: string, defaultVal: number): number {
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) {
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);
return reply.send({
timezone: settings.timezone ?? "",
availableTimezones: getAvailableTimezones(),
serverTimezone: process.env.TZ || "UTC",
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
@@ -241,6 +244,7 @@ export async function settingsRoutes(app: FastifyInstance) {
type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: {
timezone: { type: "string" },
emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" },
@@ -293,6 +297,7 @@ export async function settingsRoutes(app: FastifyInstance) {
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
timezone: "",
},
},
response: {
@@ -318,6 +323,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = {
timezone: normalizeSettingsTimezone(body.timezone),
emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true,
@@ -445,49 +451,34 @@ export async function settingsRoutes(app: FastifyInstance) {
async (request, reply) => {
const { email } = request.body;
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const smtp = getSmtpConfig();
request.log.info(
{
to: email,
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
hasSmtpHost: Boolean(smtp.host),
hasSmtpUser: Boolean(smtp.user),
hasSmtpPass: Boolean(smtp.pass),
hasSmtpFrom: Boolean(smtp.from),
smtpPort: smtp.port,
smtpSecure: smtp.secure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
if (!smtp.host || !smtp.user) {
request.log.warn(
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
{ to: email, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ to: email }, "[Settings] Sending test email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
const mailResult = await sendEmailNotification({
from: smtp.from,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
@@ -502,9 +493,8 @@ export async function settingsRoutes(app: FastifyInstance) {
`,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
if (!mailResult.success) {
throw new Error(mailResult.error ?? "Failed to send test email");
}
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
@@ -559,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
}
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 result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
const result = await sendShoutrrrNotification(url, title, message, {
actions: actionContext?.actions,
respondUrl: actionContext?.respondUrl,
viewUrl: actionContext?.viewUrl,
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
sequenceId: actionContext?.sequenceId,
tags: ["pill"],
priority: 3,
});
if (result.success) {
if (actionContext?.groupId && result.providerMessageId) {
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
}
request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
@@ -589,8 +598,9 @@ export async function settingsRoutes(app: FastifyInstance) {
export async function sendShoutrrrNotification(
urlStr: string,
title: string,
message: string
): Promise<{ success: boolean; error?: string }> {
message: string,
options: PushNotificationOptions = {}
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
try {
if (urlStr.startsWith("pushover://")) {
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
@@ -743,12 +753,13 @@ export async function sendShoutrrrNotification(
}
// 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;
const method = "POST";
let headers: Record<string, string> = {};
let body: string | undefined;
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
// Remove emojis from title for header compatibility
const cleanTitle = title
@@ -793,19 +804,27 @@ export async function sendShoutrrrNotification(
// characters (umlauts, accents, etc.) through HTTP headers
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
headers = { Title: encodedTitle, Tags: "pill" };
body = message;
body = renderedPayload.message;
// Add auth if present (extracted during sanitization)
if (auth) {
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://")) {
targetUrl = sanitizedUrl;
headers = { "Content-Type": "application/json" };
if (isDiscordWebhook) {
body = JSON.stringify({ content: `${title}\n\n${message}` });
body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
} 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 {
return {
@@ -830,7 +849,17 @@ export async function sendShoutrrrNotification(
});
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 {
const errorText = await response.text();
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);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
+16 -2
View File
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue;
}
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
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,
t,
} from "../i18n/translations.js";
import { env } from "../plugins/env.js";
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
// Import shared utilities
import {
cleanOldIntakeReminders,
createDefaultIntakeReminderState,
getTimezone,
getEffectiveTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type IntakeReminderState,
@@ -29,6 +31,10 @@ import {
type UpcomingIntake,
} from "../utils/scheduler-utils.js";
import { computeMedicationCurrentStock } from "./current-stock.js";
import {
createNotificationActionContext,
storeNotificationActionGroupNtfyMessageId,
} from "./notification-actions-service.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
@@ -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})`;
}
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = med.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = med.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${med.id}`;
}
function getPushProviderLabel(url: string): string {
const normalizedUrl = url.trim().toLowerCase();
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
if (normalizedUrl.startsWith("discord://")) return "discord";
if (normalizedUrl.startsWith("pushover://")) return "pushover";
if (normalizedUrl.startsWith("gotify://")) return "gotify";
if (normalizedUrl.startsWith("telegram://")) return "telegram";
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
} catch {
return "unknown";
}
}
function formatActionContextLog(options: {
actionMode: "full" | "view-only";
doseCount: number;
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
}): string {
const { actionMode, doseCount, actionContext } = options;
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
@@ -137,7 +178,7 @@ async function autoMarkDueIntakesAsTaken(
}
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({
medication: med,
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 locale = getDateLocale(language);
const tz = getTimezone();
const tz = getEffectiveTimezone(settings.timezone ?? null);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) {
@@ -473,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
return; // No medications have reminders enabled for this user
}
const now = new Date();
const state = loadIntakeReminderState(logger);
const trackedDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
...entry,
currentStock: computeMedicationCurrentStock({
medication: entry.med,
doses: trackedDoses,
stockCalculationMode: settings.stockCalculationMode,
nowMs: now.getTime(),
}),
}));
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
if (suppressedEmptyStockEntries.length > 0) {
logger.info(
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
.map((entry) =>
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
)
.join(", ")}`
);
}
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
if (reminderEntriesEligible.length === 0) {
logger.info(
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
);
return;
}
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
let scheduledIntakesTodayCount = 0;
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
@@ -485,10 +557,10 @@ export async function checkAndSendIntakeRemindersForUser(
todayEnd.setHours(23, 59, 59, 999);
// 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)
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
intakesWithReminders.forEach((intake, _blisterIndex) => {
@@ -791,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
.join("\n") +
repeatNote +
`\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;
if (!result.success) {
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 {
if (actionContext?.groupId && result.providerMessageId) {
try {
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
logger.info(
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
);
}
} else if (actionContext?.groupId && pushProvider === "ntfy") {
logger.warn(
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
);
}
logger.info(
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, 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 };
}
+29 -13
View File
@@ -1,5 +1,6 @@
import nodemailer from "nodemailer";
import { sendShoutrrrNotification } from "../../routes/settings.js";
import type { PushNotificationOptions } from "./action-renderer.js";
type MailDeliveryInfo = {
accepted?: unknown;
@@ -64,6 +65,25 @@ export function getSmtpConfig(): {
return { host, user, pass, port, secure, from };
}
export function createSmtpTransport(smtp = getSmtpConfig()) {
if (!smtp.host || !smtp.user) {
return null;
}
// The SMTP endpoint is configured by the server operator via environment variables,
// not derived from request-controlled input.
// lgtm [js/request-forgery]
return nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.pass ?? "",
},
});
}
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
const smtp = getSmtpConfig();
if (!smtp.host || !smtp.user) {
@@ -71,15 +91,10 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
}
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: {
user: smtp.user,
pass: smtp.pass ?? "",
},
});
const transporter = createSmtpTransport(smtp);
if (!transporter) {
return { success: false, error: "SMTP not configured" };
}
const mailResult = await transporter.sendMail({
from: input.from ?? smtp.from,
@@ -108,14 +123,15 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
export async function sendPushNotification(
url: string,
title: string,
message: string
): Promise<{ success: boolean; error?: string }> {
message: string,
options: PushNotificationOptions = {}
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
try {
const result = await sendShoutrrrNotification(url, title, message);
const result = await sendShoutrrrNotification(url, title, message, options);
if (!result.success) {
return { success: false, error: result.error };
}
return { success: true };
return { success: true, providerMessageId: result.providerMessageId };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
+15 -3
View File
@@ -21,6 +21,7 @@ import {
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getEffectiveTimezone,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
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(
userId: number,
reminderDaysBefore: number,
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) {
lowStock.push({
name: row.name,
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills,
daysLeft,
depletionDate,
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
)
.map((row) => ({
name: row.name,
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
remainingRefills: row.prescriptionRemainingRefills ?? 0,
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
expiryDate: row.prescriptionExpiryDate ?? null,
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
}
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 userStockNotifiedKey = `${userStateKey}_${today}_stock`;
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 { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
export type UserSettings = {
userId: number;
timezone?: string | null;
emailEnabled: boolean;
notificationEmail: string | null;
emailStockReminders: boolean;
@@ -80,7 +82,7 @@ export function getNotificationProvider(url: string): string {
if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover";
if (url.startsWith("ntfy://")) return "ntfy";
if (isNtfyNotificationUrl(url)) return "ntfy";
try {
const parsed = new URL(url);
@@ -105,6 +107,7 @@ function envInt(key: string, defaultVal: number): number {
export function getDefaultSettings() {
return {
timezone: "",
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
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 {
const hostname = hostnameRaw.toLowerCase();
@@ -202,7 +232,7 @@ export function sanitizeNotificationUrl(
return { url: discordWebhookUrl, isNtfy: false };
}
const isNtfy = urlStr.startsWith("ntfy://");
const isNtfy = isNtfyNotificationUrl(urlStr);
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
@@ -245,6 +275,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
const settings = await getOrCreateUserSettings(userId);
return {
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
@@ -288,6 +319,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({
userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
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 (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
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");
});
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"]);
// 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({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
@@ -337,13 +338,14 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const data = response.json();
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].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
expect(data[medId].refills).toHaveLength(1);
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 2,
loosePillsAdded: 5,
quantityAdded: 7,
usedPrescription: true,
});
});
@@ -375,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 0,
quantityAdded: 1,
usedPrescription: false,
});
});
@@ -2442,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
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 () => {
const createResponse = await app.inject({
method: "POST",
@@ -3047,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
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 () => {
const response = await app.inject({
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 () => {
const createResponse = await app.inject({
method: "POST",
@@ -3271,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
});
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({
method: "POST",
url: "/medications",
@@ -3293,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
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.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
@@ -3306,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
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([
{
name: "liquid_container",
@@ -3322,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1,
expectedLooseAdded: 180,
expectedRemainingRefills: 1,
expectedTotalPills: 360,
expectedAmountPerPackage: 180,
},
{
name: "tube",
@@ -3337,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
expectedVisibleStockBeforeRefill: 80,
expectedPacksAdded: 2,
expectedLooseAdded: 80,
expectedRemainingRefills: 1,
expectedTotalPills: 160,
expectedAmountPerPackage: 40,
},
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
payload,
refillPayload,
expectedVisibleStockBeforeRefill,
expectedPacksAdded,
expectedLooseAdded,
expectedRemainingRefills,
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({
method: "POST",
url: "/medications",
@@ -3366,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded: expectedLooseAdded,
expectedPacksAdded,
expectedAmountPerPackage,
});
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
@@ -3381,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded,
quantityAdded: expectedLooseAdded,
usedPrescription: true,
});
});
@@ -3402,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
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.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120);
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"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default("3000"),
.default("3000")
.transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default("15"),
.default("15")
.transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default("7"),
.default("7")
.transform((v) => parseInt(v, 10)),
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
.default("false")
.transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: 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_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default("true"),
.default("true")
.transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
OIDC_PROVIDER_NAME: z.string().default("SSO"),
});
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
expect(result.PORT).toBe(3000);
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
expect(result.LOG_LEVEL).toBe("info");
expect(result.PUBLIC_APP_URL).toBeUndefined();
expect(result.AUTH_ENABLED).toBe(false);
expect(result.REGISTRATION_ENABLED).toBe(false);
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
});
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", () => {
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "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.language).toBe("de");
expect(data.settings.lowStockDays).toBe(14);
expect(data.settings.shareStockStatus).toBeUndefined();
});
it("should exclude sensitive data by default", async () => {
@@ -557,6 +558,45 @@ describe("Export/Import API", () => {
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 () => {
// Create existing medication
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 (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
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.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
expect(res.headers.location).toBe("http://localhost:5173");
} finally {
await app.close();
}
@@ -129,7 +129,7 @@ describe("OIDC routes", () => {
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
expect(res.headers.location).toBe("http://localhost:5173");
} finally {
await app.close();
}
@@ -144,7 +144,7 @@ describe("OIDC routes", () => {
});
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 {
await app.close();
}
+1
View File
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
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_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
PUBLIC_APP_URL: "https://app.example.com",
CORS_ORIGINS: "https://app.example.com",
};
return {
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 () => {
fetchMock.mockResolvedValue({ ok: true });
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
const response = await app.inject({
method: "POST",
@@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200);
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 () => {
@@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => {
});
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");
expect(result.success).toBe(true);
expect(result.providerMessageId).toBe("ntfy-message-id");
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/mytopic",
expect.objectContaining({
@@ -589,8 +630,39 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200);
const body = response.json();
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[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 () => {
@@ -621,7 +693,9 @@ describe("Real route coverage: settings/export/report", () => {
expect(body.medications).toHaveLength(1);
expect(body.doseHistory).toHaveLength(1);
expect(body.refillHistory).toHaveLength(1);
expect(body.refillHistory[0].quantityAdded).toBe(23);
expect(body.settings.language).toBe("de");
expect(body.settings.shareStockStatus).toBeUndefined();
expect(body.shareLinks).toHaveLength(1);
});
@@ -672,7 +746,15 @@ describe("Real route coverage: settings/export/report", () => {
},
],
doseHistory: [],
refillHistory: [],
refillHistory: [
{
medicationRef: "med-1",
packsAdded: 0,
quantityAdded: 4,
usedPrescription: false,
refillDate: "2026-01-02T08:00:00.000Z",
},
],
settings: {
emailEnabled: false,
notificationEmail: null,
@@ -708,10 +790,24 @@ describe("Real route coverage: settings/export/report", () => {
});
expect(valid.statusCode).toBe(200);
expect(valid.json().imported.medications).toBe(1);
expect(valid.json().imported.refillHistory).toBe(1);
const rows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = 1",
});
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();
});
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 () => {
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
async function createMedication(options: {
name: string;
genericName?: string | null;
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
@@ -80,6 +81,7 @@ async function createMedication(options: {
}) {
const {
name,
genericName = null,
packCount = 1,
blistersPerPack = 1,
pillsPerBlister = 10,
@@ -106,16 +108,17 @@ async function createMedication(options: {
const result = await testClient.execute({
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,
stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`,
args: [
1,
name,
genericName,
JSON.stringify(takenBy),
packCount,
blistersPerPack,
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
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", () => {
+1
View File
@@ -46,5 +46,6 @@ export const log = {
export type ServiceLogger = {
info: (msg: string) => void;
debug: (msg: string) => void;
warn: (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);
}
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 {
return toDateOnly(date).getTime();
}
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
const intervalDays = Math.max(1, schedule.every);
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
const lowerBoundDate = new Date(lowerBound);
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);
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
const intervalDays = Math.max(1, schedule.every);
let occurrence = new Date(startDate);
if (occurrence.getTime() < rangeStartMs) {
const rangeStartDate = new Date(rangeStartMs);
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) {
callback(occurrenceMs);
}
occurrence = addLocalCalendarDays(occurrence, intervalDays);
occurrenceMs = occurrence.getTime();
}
return;
}
@@ -348,6 +379,23 @@ export function getTimezone(): string {
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 */
export function formatInTimezone(date: Date, tz?: string): string {
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.
- 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
@@ -47,6 +46,6 @@ Scope and behavior:
|----------|---------|-------------|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
| `DEFAULT_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_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
+8
View File
@@ -4,6 +4,14 @@ Purpose: persistent agent work memory to survive context loss.
## Entries
### 2026-04-10
- Task: Investigate and fix the production blank-homepage bug (user report: both containers running, blank page, many `400 - -` log lines in frontend container).
- Root cause: `upgrade-insecure-requests` directive was present in the `Content-Security-Policy` header in `frontend/nginx.conf`. This directive instructs browsers to upgrade all same-host HTTP requests to HTTPS (preserving the port). When users access the app over plain HTTP (e.g., `http://host:4174/`), the browser receives this CSP and upgrades subsequent asset requests (`/assets/index-*.js`, `/assets/index-*.css`, favicons, API calls) to `https://host:4174/...`. The nginx container only speaks plain HTTP on port 4174, so it receives TLS Client Hello bytes which it cannot parse as an HTTP request. nginx returns `400 Bad Request` with no parseable method or URI — producing the `400 - -` log pattern. All JS/CSS bundles fail to load, React never mounts, and the page stays blank.
- Fix: Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf` (line 20). No other changes needed.
- Validation notes: The directive is safe to remove — `upgrade-insecure-requests` is designed for HTTPS-only sites and is harmful when the server runs on plain HTTP. Removing it does not weaken security for self-hosted HTTP deployments (mixed content is not a concern when the origin itself is HTTP). If a reverse proxy with TLS termination is added in front, the directive can be re-introduced at the proxy level.
- Files touched: `frontend/nginx.conf`.
### 2026-03-25
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
+11
View File
@@ -2,6 +2,17 @@
## Entries
### 2026-04-10
- Scope: Investigate and fix the production blank-homepage bug.
- Root cause: The `Content-Security-Policy` header in `frontend/nginx.conf` included the `upgrade-insecure-requests` directive. This directive instructs browsers to upgrade all HTTP resource requests to HTTPS (same port). In a plain HTTP deployment (the default Docker setup on port 4174), this causes the browser to attempt TLS connections to the nginx HTTP port. nginx cannot parse the TLS bytes as HTTP and returns `400 Bad Request` with no method/URI — the `400 - -` log pattern the user observed. All JS/CSS bundles fail to load; React never mounts; the page stays blank.
- What changed:
- Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf`.
- Validation:
- `upgrade-insecure-requests` is designed for HTTPS-only sites. Removing it from a plain HTTP server is correct and does not reduce security.
- After this fix, browsers accessing the app over HTTP will load assets normally without being redirected to a non-existent HTTPS endpoint.
- If TLS termination is added via a reverse proxy in future, the directive can be applied at the proxy layer.
- Result: The blank-homepage bug is fixed. All asset and API requests now succeed over plain HTTP as expected.
### 2026-03-25
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
- What changed:
+1 -1
View File
@@ -17,7 +17,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
# Allow larger file uploads (for medication images and data import/export)
+319 -305
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.22.1",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,30 +27,30 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"i18next": "^26.0.1",
"i18next": "^26.1.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.1",
"react-router-dom": "^7.13.2",
"zod": "^4.3.6"
"lucide-react": "^1.14.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@playwright/test": "^1.58.2",
"@biomejs/biome": "^2.4.15",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.5",
"@types/node": "^25.6.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2",
"jsdom": "^29.0.1",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.12",
"vitest": "^4.1.0"
}
}
+1 -1
View File
@@ -506,7 +506,7 @@ function AppContent() {
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<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="/medications" element={<MedicationsPage />} />
+1 -4
View File
@@ -1105,10 +1105,7 @@ export function MedDetailModal({
</span>
<span className="refill-amount">
{(() => {
const total = isAmountBasedPackageType(selectedMed.packageType)
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
const total = entry.quantityAdded;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
})()}
{entry.usedPrescription && (
+22 -12
View File
@@ -6,6 +6,7 @@ import type { Medication } from "../types";
import {
getMedDisplayName,
getMedTotal,
getStockDisplayCapacity,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
@@ -27,10 +28,16 @@ type ReportData = Record<
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
dosesSkipped: number;
firstDoseAt: 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", {
method: "POST",
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",
});
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)));
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
} 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)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -415,12 +425,12 @@ function generateTextReport(
const data = reportData[med.id];
if (data) {
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)));
if (data.automaticDosesTaken > 0) {
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.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else {
@@ -432,7 +442,7 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
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")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -572,7 +582,7 @@ function buildPrintHtml(
if (med.looseTablets > 0)
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
} 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>`;
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -616,14 +626,14 @@ function buildPrintHtml(
// Intake history
if (data) {
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 += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
if (data.automaticDosesTaken > 0) {
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
}
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.dosesSkipped > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
@@ -638,7 +648,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
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>`;
s += `<li>${entry}</li>`;
}
+234 -101
View File
@@ -39,6 +39,7 @@ export function SharedSchedule() {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [automaticTakenDoses, setAutomaticTakenDoses] = 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 [showPastDays, setShowPastDays] = 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)
const loadTakenDoses = useCallback(async () => {
if (!token) return;
if (mutationInFlightRef.current > 0) return;
try {
const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) {
if (mutationInFlightRef.current > 0) return;
const data = await res.json();
const taken = new Set<string>();
const automatic = new Set<string>();
const dismissed = new Set<string>();
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) {
if (d.dismissed) {
for (const d of data.doses as Array<{
doseId: string;
dismissed?: boolean;
skipped?: boolean;
takenSource?: string;
}>) {
if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
@@ -203,15 +212,9 @@ export function SharedSchedule() {
setTakenDoses(taken);
setAutomaticTakenDoses(automatic);
setDismissedDoses(dismissed);
} else {
setTakenDoses(new Set());
setAutomaticTakenDoses(new Set());
setDismissedDoses(new Set());
}
} catch {
setTakenDoses(new Set());
setAutomaticTakenDoses(new Set());
setDismissedDoses(new Set());
// Keep the current optimistic/shared state on transient read errors.
}
}, [token]);
@@ -232,12 +235,22 @@ export function SharedSchedule() {
}
async function markDoseTaken(doseId: string) {
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const wasAutomatic = automaticTakenDoses.has(doseId);
// Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setAutomaticTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
@@ -266,16 +279,104 @@ export function SharedSchedule() {
// Revert on error
setTakenDoses((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;
});
} 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();
}
}
async function undoDoseTaken(doseId: string) {
const wasAutomatic = automaticTakenDoses.has(doseId);
// Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
@@ -299,9 +400,100 @@ export function SharedSchedule() {
next.add(doseId);
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);
useEffect(() => {
@@ -934,6 +1126,7 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isSkipped = dismissedDoses.has(dose.id);
const doseClasses = ["dose-item", "past"];
if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
@@ -948,37 +1141,17 @@ export function SharedSchedule() {
)}
</span>
<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>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId: dose.id,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
</div>
</div>
@@ -1149,7 +1322,8 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken =
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"];
if (isOverdue) doseClasses.push("overdue");
if (isTaken) doseClasses.push("all-taken");
@@ -1166,38 +1340,16 @@ export function SharedSchedule() {
</span>
<div className="dose-checks">
<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>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId: dose.id,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
</div>
</div>
@@ -1351,6 +1503,7 @@ export function SharedSchedule() {
const isTaken = isDoseTakenForDisplay(dose.id);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
const isSkipped = dismissedDoses.has(dose.id);
const doseClasses = ["dose-item", "future"];
if (isTaken) doseClasses.push("all-taken");
if (isEmpty) doseClasses.push("med-empty");
@@ -1365,37 +1518,17 @@ export function SharedSchedule() {
)}
</span>
<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>}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId: dose.id,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty: true,
})}
</div>
</div>
</div>
@@ -50,7 +50,6 @@ export function MedicationListSection({
const renderImageAvatar = (med: Medication) => (
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() => med.imageUrl && onImagePreview(med)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
onImagePreview(med);
@@ -146,8 +145,7 @@ export function MedicationListSection({
</>
) : (
<span>
{t("medications.details.totalCapacity")}:{" "}
<strong>{med.totalPills ?? med.looseTablets}</strong>
{t("medications.details.totalCapacity")}: <strong>{stockDisplayCapacity}</strong>
</span>
)}
</div>
+11 -1
View File
@@ -11,7 +11,7 @@ import {
type ScheduleEvent,
type StockThresholds,
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
import { ShareContextProvider } from "./ShareContext";
@@ -77,12 +77,15 @@ export interface AppContextValue {
// From useDoses
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
skippedDoses: Set<string>;
dismissedDoses: Set<string>;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
@@ -299,6 +302,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
shares: number;
} | null>(null);
useEffect(() => {
setDefaultFormattingTimezone(settingsHook.settings.timezone || settingsHook.settings.serverTimezone || null);
}, [settingsHook.settings.timezone, settingsHook.settings.serverTimezone]);
// Load user-specific scheduleDays when user changes
useEffect(() => {
if (typeof window !== "undefined" && user?.id) {
@@ -848,12 +855,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// From useDoses
takenDoses: doses.takenDoses,
setTakenDoses: doses.setTakenDoses,
skippedDoses: doses.skippedDoses,
dismissedDoses: doses.dismissedDoses,
getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
markDoseSkipped: doses.markDoseSkipped,
undoDoseTaken: doses.undoDoseTaken,
undoDoseSkipped: doses.undoDoseSkipped,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
+178 -6
View File
@@ -10,13 +10,16 @@ export interface UseDosesReturn {
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
skippedDoses: Set<string>;
dismissedDoses: Set<string>;
clearDosesState: () => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
loadTakenDoses: () => Promise<void>;
}
@@ -56,7 +59,7 @@ export function useDoses(): UseDosesReturn {
const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
@@ -127,6 +130,15 @@ export function useDoses(): UseDosesReturn {
const markDoseTaken = useCallback(
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
mutationInFlightRef.current++;
setTakenDoses((prev) => {
@@ -134,6 +146,11 @@ export function useDoses(): UseDosesReturn {
next.add(doseId);
return next;
});
setDismissedDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.set(doseId, Date.now());
@@ -163,17 +180,38 @@ export function useDoses(): UseDosesReturn {
// Revert on error
setTakenDoses((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;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.delete(doseId);
if (wasTaken && typeof previousTimestamp === "number") {
next.set(doseId, previousTimestamp);
} else {
next.delete(doseId);
}
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
if (wasTaken && previousSource) {
next.set(doseId, previousSource);
} else {
next.delete(doseId);
}
return next;
});
} finally {
@@ -182,11 +220,96 @@ export function useDoses(): UseDosesReturn {
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(
async (doseId: string) => {
const previousTimestamp = takenDoseTimestamps.get(doseId);
const previousSource = takenDoseSources.get(doseId);
// Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => {
@@ -218,13 +341,59 @@ export function useDoses(): UseDosesReturn {
next.add(doseId);
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 {
mutationInFlightRef.current--;
// Re-sync with server after mutation completes
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 {
@@ -232,13 +401,16 @@ export function useDoses(): UseDosesReturn {
setTakenDoses,
takenDoseTimestamps,
takenDoseSources,
skippedDoses: dismissedDoses,
dismissedDoses,
clearDosesState,
getDoseId,
isDoseTakenAutomatically,
countTakenDoses,
markDoseTaken,
markDoseSkipped,
undoDoseTaken,
undoDoseSkipped,
loadTakenDoses,
};
}
+7 -1
View File
@@ -121,7 +121,12 @@ export function useRefill(): UseRefillReturn {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
body: JSON.stringify({
packsAdded: refillPacks,
loosePillsAdded: refillLoose,
quantityAdded: refillLoose,
usePrescription,
}),
});
if (res.ok) {
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.
patchBody.packCount = correctedLiquidBottleCount;
patchBody.totalPills = liquidStructuralMax;
patchBody.looseTablets = liquidStructuralMax;
} else if (!isAmountPackage) {
patchBody.looseTablets = finalLoosePills;
}
@@ -23,8 +23,11 @@ export function useScheduleController() {
futureDays: ctx.futureDays,
takenDoses: ctx.takenDoses,
dismissedDoses: ctx.dismissedDoses,
skippedDoses: ctx.skippedDoses,
markDoseTaken: ctx.markDoseTaken,
markDoseSkipped: ctx.markDoseSkipped,
undoDoseTaken: ctx.undoDoseTaken,
undoDoseSkipped: ctx.undoDoseSkipped,
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
manuallyExpandedDays: ctx.manuallyExpandedDays,
toggleDayCollapse: ctx.toggleDayCollapse,
+7
View File
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
import { log } from "../utils/logger";
export interface Settings {
timezone: string;
availableTimezones: string[];
serverTimezone: string;
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
@@ -58,6 +61,9 @@ export interface Settings {
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
const defaultSettings: Settings = {
timezone: "",
availableTimezones: [],
serverTimezone: "UTC",
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return {
timezone: settingsToSave.timezone,
emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore,
+31 -19
View File
@@ -110,7 +110,7 @@
"fullBlisters": "Volle Blister",
"openBlister": "Offener Blister",
"stock": "Bestand",
"dailyConsumption": "Taeglicher Verbrauch",
"dailyConsumption": "Täglicher Verbrauch",
"stockDetails": "Details",
"daysLeft": "Tage übrig",
"status": "Status",
@@ -133,7 +133,7 @@
"obsoleteTitle": "Obsolet ({{count}})",
"obsoleteSince": "Beendet",
"started": "Gestartet",
"emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu."
"emptyState": "Noch keine Medikamente. Füge dein erstes Medikament hinzu."
},
"details": {
"packs": "Packungen",
@@ -142,7 +142,7 @@
"loose": "Lose",
"total": "Gesamt",
"stock": "Bestand",
"capacityPerPackage": "Kapazitaet pro Packung",
"capacityPerPackage": "Kapazität pro Packung",
"totalCapacity": "Kapazität",
"type": "Typ"
},
@@ -174,17 +174,17 @@
"medicationForm": "Medikationsform",
"medicationFormCapsule": "Kapsel",
"medicationFormTablet": "Tablette",
"medicationFormLiquid": "Fluessigkeit",
"medicationFormLiquid": "Flüssigkeit",
"medicationFormTopical": "Topisch",
"pillForm": "Pillenform",
"lifecycleCategory": "Lebenszyklus",
"lifecycleRefillWhenEmpty": "Nachfuellen wenn leer",
"lifecycleRefillWhenEmpty": "Nachfüllen wenn leer",
"lifecycleTreatmentPeriod": "Behandlungszeitraum",
"packageType": "Verpackungsart",
"packageTypeBlister": "Blisterpackung",
"packageTypeBottle": "Pillendose",
"packageTypeTube": "Tube",
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
"packageTypeLiquidContainer": "Flüssigbehältnis",
"packs": "Packungen",
"bottles": "Flaschen",
"tubes": "Tuben",
@@ -317,17 +317,17 @@
"usageApplication": "Dosis (Anwendungen)",
"intakeUnit": "Einnahmeeinheit",
"intakeUnitMl": "Milliliter (ml)",
"intakeUnitTsp": "Teeloeffel (5 ml)",
"intakeUnitTbsp": "Essloeffel (15 ml)",
"intakeUnitTsp": "Teelöffel (5 ml)",
"intakeUnitTbsp": "Esslöffel (15 ml)",
"intakes": "Einnahmen",
"intakes_one": "Einnahme",
"intakes_other": "Einnahmen",
"teaspoons": "Teeloeffel",
"teaspoons_one": "Teeloeffel",
"teaspoons_other": "Teeloeffel",
"tablespoons": "Essloeffel",
"tablespoons_one": "Essloeffel",
"tablespoons_other": "Essloeffel",
"teaspoons": "Teelöffel",
"teaspoons_one": "Teelöffel",
"teaspoons_other": "Teelöffel",
"tablespoons": "Esslöffel",
"tablespoons_one": "Esslöffel",
"tablespoons_other": "Esslöffel",
"applications": "Anwendungen",
"applications_one": "Anwendung",
"applications_other": "Anwendungen",
@@ -337,7 +337,7 @@
"everyDays": "Alle (Tage)",
"every": "alle",
"weekdays": "Wochentage",
"weekdaysRequired": "Waehle mindestens einen Wochentag aus",
"weekdaysRequired": "Wähle mindestens einen Wochentag aus",
"weekdaysShort": {
"mon": "Mo",
"tue": "Di",
@@ -389,6 +389,14 @@
"title": "Sprache",
"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": {
"title": "API-Zugriff",
"generateTitle": "API-Key erzeugen",
@@ -540,7 +548,11 @@
"dose": {
"takenBy": "eingenommen von",
"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": {
"login": "Anmelden",
@@ -776,11 +788,11 @@
"loosePills": "Lose Tabletten",
"pillsPerBlister": "(je {{count}} Tabletten)",
"packageSize": "Packungsgröße: {{count}} Tabletten",
"packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}",
"packageSizeAmount": "Packungsgröße: {{count}} {{unit}}",
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{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.",
"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",
"increaseValue": "Wert erhöhen",
"currentTotal": "Aktueller Bestand",
@@ -862,7 +874,7 @@
"docIntakeHistory": "Einnahme-Verlauf",
"docDosesTaken": "Eingenommene Dosen",
"docDosesTakenAutomatic": "Automatisch eingenommen",
"docDosesDismissed": "Verworfene Dosen",
"docDosesSkipped": "Übersprungene Dosen",
"docFirstDose": "Erste Dosis",
"docLastDose": "Letzte Dosis",
"docRefillHistory": "Nachfüll-Verlauf",
+14 -2
View File
@@ -389,6 +389,14 @@
"title": "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": {
"title": "API Access",
"generateTitle": "Generate API key",
@@ -540,7 +548,11 @@
"dose": {
"takenBy": "taken by",
"markAsTaken": "Mark as taken",
"take": "Take"
"take": "Take",
"skip": "Skip",
"markAsSkipped": "Mark as skipped",
"undoTake": "Undo take",
"undoSkip": "Undo skip"
},
"auth": {
"login": "Login",
@@ -862,7 +874,7 @@
"docIntakeHistory": "Intake History",
"docDosesTaken": "Doses taken",
"docDosesTakenAutomatic": "Automatically taken",
"docDosesDismissed": "Doses dismissed",
"docDosesSkipped": "Doses skipped",
"docFirstDose": "First dose",
"docLastDose": "Last dose",
"docRefillHistory": "Refill History",
+344 -105
View File
@@ -1,7 +1,8 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
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 { useLocation } from "react-router-dom";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
@@ -28,9 +29,54 @@ import {
userStorageKey,
} 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() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const location = useLocation();
const {
meds,
loading,
@@ -49,9 +95,12 @@ export function DashboardPage() {
todayDay,
futureDays,
takenDoses,
skippedDoses,
dismissedDoses,
markDoseTaken,
markDoseSkipped,
undoDoseTaken,
undoDoseSkipped,
manuallyCollapsedDays,
manuallyExpandedDays,
toggleDayCollapse,
@@ -71,8 +120,158 @@ export function DashboardPage() {
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
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
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 }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
@@ -708,6 +964,7 @@ export function DashboardPage() {
return (
<div
key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
@@ -753,8 +1010,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
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="med-name">
<div
@@ -797,7 +1061,7 @@ export function DashboardPage() {
<div className="doses-col">
{item.doses.map((dose) => {
// 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) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
@@ -828,10 +1092,20 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken =
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 (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && (
<span
className="person-name clickable"
@@ -843,38 +1117,13 @@ export function DashboardPage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
);
})}
@@ -1023,6 +1272,7 @@ export function DashboardPage() {
return (
<div
key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
>
<div
@@ -1067,8 +1317,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
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="med-name">
<div
@@ -1126,8 +1383,8 @@ export function DashboardPage() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isOverdue = dose.when < Date.now();
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const isOverdue = dose.when < Date.now() && !isEmpty;
const people = getDosePeople(dose.takenBy);
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
@@ -1159,10 +1416,20 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken =
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 (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && (
<span
className="person-name clickable"
@@ -1174,38 +1441,13 @@ export function DashboardPage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
);
})}
@@ -1227,7 +1469,7 @@ export function DashboardPage() {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) =>
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 (
<div
key={day.dateStr}
data-date-key={getRouteDateKey(day.date)}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
>
<div
@@ -1340,8 +1583,15 @@ export function DashboardPage() {
const rowClasses = ["time-row"];
if (isEmpty) rowClasses.push("med-empty");
else if (isLowStock) rowClasses.push("med-low");
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
rowClasses.push("notification-focus-target-row");
}
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="med-name">
<div
@@ -1399,7 +1649,7 @@ export function DashboardPage() {
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const people = getDosePeople(dose.takenBy);
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
@@ -1430,10 +1680,20 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = effectiveSkippedDoses.has(doseId);
const isAutomaticallyTaken =
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 (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div
key={doseId}
data-dose-id={doseId}
className={personClasses.join(" ")}
>
{person && (
<span
className="person-name clickable"
@@ -1445,34 +1705,13 @@ export function DashboardPage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty: true,
})}
</div>
);
})}
+52 -23
View File
@@ -257,8 +257,10 @@ export function MedicationsPage() {
useUnsavedChangesWarning(formChanged);
// View mode: grid (default) or form (edit/new)
// If navigating in with editMedId, suppress rendering until the edit form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
);
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
@@ -269,9 +271,23 @@ export function MedicationsPage() {
useEffect(() => {
showEditModalRef.current = showEditModal;
}, [showEditModal]);
const processedEditMedIdRef = useRef<string | null>(null);
const processedMedicationLinkRef = useRef<string | null>(null);
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
const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
@@ -819,12 +835,13 @@ export function MedicationsPage() {
[t]
);
const clearEditMedIdParam = useCallback(() => {
const clearMedicationLinkParams = useCallback(() => {
setSearchParams(
(prevParams) => {
if (!prevParams.has("editMedId")) return prevParams;
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
return nextParams;
},
{ replace: true }
@@ -848,7 +865,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
clearEditMedIdParam();
clearMedicationLinkParams();
// Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true;
window.history.back();
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true;
clearEditMedIdParam();
clearMedicationLinkParams();
setShowEditModal(false);
setReadOnlyView(false);
setActiveTab("general");
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes
useEffect(() => {
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
if (showObsoleteConfirm) {
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
if (currentEditMedId) {
if (currentMedicationLinkId && currentLinkMode) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
processedEditMedIdRef.current = currentEditMedId;
clearEditMedIdParam();
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
clearMedicationLinkParams();
}
if (showEditModal) {
setShowEditModal(false);
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
if (currentEditMedId) {
if (currentMedicationLinkId && currentLinkMode) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
processedEditMedIdRef.current = currentEditMedId;
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
}
clearEditMedIdParam();
clearMedicationLinkParams();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
};
window.addEventListener("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
useEffect(() => {
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
}, [activeMeds, editingId]);
useEffect(() => {
const editMedId = searchParams.get("editMedId");
if (!editMedId) {
processedEditMedIdRef.current = null;
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
if (!linkedMedId || !linkMode) {
processedMedicationLinkRef.current = null;
return;
}
if (processedEditMedIdRef.current === editMedId) return;
const parsedMedId = Number.parseInt(editMedId, 10);
const linkKey = `${linkMode}:${linkedMedId}`;
if (processedMedicationLinkRef.current === linkKey) return;
const parsedMedId = Number.parseInt(linkedMedId, 10);
if (Number.isNaN(parsedMedId)) return;
const medicationToEdit =
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return;
processedEditMedIdRef.current = editMedId;
processedMedicationLinkRef.current = linkKey;
setShowNameValidation(false);
setReadOnlyView(false);
setReadOnlyView(linkMode === "view");
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
setSearchParams(nextParams, { replace: true });
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => {
if (!editingId) return null;
+84 -61
View File
@@ -24,8 +24,8 @@ function getStockStatus(
packageType?: string
) {
if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" };
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
// Only a real zero-or-below stock count is out of stock.
if (medsLeft <= 0) return { className: "danger", label: "status.outOfStock" };
// No schedule, but has stock = normal
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
if (isLiquidContainerPackageType(packageType)) {
@@ -85,7 +85,10 @@ export function SchedulePage() {
isDoseTakenAutomatically,
dismissedDoses,
markDoseTaken,
skippedDoses,
markDoseSkipped,
undoDoseTaken,
undoDoseSkipped,
coverageByMed,
depletionByMed,
manuallyExpandedDays,
@@ -172,6 +175,59 @@ export function SchedulePage() {
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => 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 (
<section className="grid">
<article className="card schedule-full">
@@ -320,10 +376,14 @@ export function SchedulePage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = skippedDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
const personClasses = ["dose-person"];
if (isTaken) personClasses.push("taken");
if (isSkipped) personClasses.push("skipped");
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
<div key={doseId} className={personClasses.join(" ")}>
{person && (
<span
className="person-name clickable"
@@ -335,35 +395,13 @@ export function SchedulePage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</div>
);
})}
@@ -549,14 +587,16 @@ export function SchedulePage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = isDoseTakenForDisplay(doseId);
const isSkipped = skippedDoses.has(doseId);
const isAutomaticallyTaken =
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 (
<div
key={doseId}
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
<div key={doseId} className={personClasses.join(" ")}>
{person && (
<span
className="person-name clickable"
@@ -568,30 +608,13 @@ export function SchedulePage() {
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{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>
)}
{renderDoseActionButtons({
doseId,
isTaken,
isSkipped,
isAutomaticallyTaken,
isEmpty,
})}
</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 */
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context";
import { getSystemLocale } from "../utils/formatters";
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
export function SettingsPage() {
const { t, i18n } = useTranslation();
@@ -13,8 +13,11 @@ export function SettingsPage() {
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const {
settings,
savedSettings,
setSettings,
settingsLoading,
settingsSaving,
settingsSaved,
settingsLoadError,
// Email testing
testEmail,
@@ -39,6 +42,8 @@ export function SettingsPage() {
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0;
let emailUnavailableReason: string | null = null;
@@ -117,6 +122,49 @@ export function SettingsPage() {
const automaticStockCalculationId = "settings-stock-calculation-automatic";
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 (
<section className="grid">
{settingsLoading ? (
@@ -160,6 +208,53 @@ export function SettingsPage() {
<option value="de">🇩🇪 Deutsch</option>
</select>
</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 className="card" data-testid="settings-notification-card">
@@ -642,13 +737,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
<span className="schedule-value">
{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
{new Date(settings.nextScheduledCheck).toLocaleString(
getSystemLocale(i18n.language),
withFormattingTimezone({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
)}
</span>
</div>
)}
@@ -656,13 +754,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value">
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
{new Date(settings.lastStockReminderSent).toLocaleString(
getSystemLocale(i18n.language),
withFormattingTimezone({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
)}
</span>
</div>
)}
@@ -670,13 +771,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
<span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
{new Date(settings.lastAutoEmailSent).toLocaleString(
getSystemLocale(i18n.language),
withFormattingTimezone({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
)}
</span>
</div>
)}
@@ -684,13 +788,16 @@ export function SettingsPage() {
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
<span className="schedule-value">
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(
getSystemLocale(i18n.language),
withFormattingTimezone({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
)}
</span>
</div>
)}
+19 -12
View File
@@ -1,5 +1,6 @@
import type { Coverage, Medication, PackageType } from "../types";
import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { withFormattingTimezone } from "../utils/formatters";
import { splitCurrentBlisterStock } from "../utils/stock";
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;
if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
const formattedDate = sentDate.toLocaleDateString(
locale,
withFormattingTimezone({
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
);
lastStockSent = {
date: formattedDate,
medNames: lastStockReminderMedNames,
@@ -147,12 +151,15 @@ export function getReminderStatusData(
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
const formattedDate = sentDate.toLocaleDateString(
locale,
withFormattingTimezone({
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
);
lastIntakeSent = {
date: formattedDate,
medName: lastReminderMedName,
+63 -3
View File
@@ -2112,6 +2112,20 @@ button.has-validation-error {
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
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 {
display: flex;
flex-direction: column;
@@ -2209,12 +2223,12 @@ button.has-validation-error {
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);
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);
}
@@ -2332,7 +2346,7 @@ button.has-validation-error {
}
.dose-btn .dose-btn-label {
display: none;
display: inline;
}
.dose-btn.take {
@@ -2379,6 +2393,16 @@ button.has-validation-error {
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:disabled,
.dashboard-schedules-section .dose-btn.take.out-of-stock,
@@ -2409,6 +2433,18 @@ button.has-validation-error {
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 */
.dose-checks {
display: flex;
@@ -2426,10 +2462,20 @@ button.has-validation-error {
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 {
background: var(--success-bg);
}
.dose-person.skipped {
background: color-mix(in srgb, var(--warning) 20%, var(--accent-bg));
}
.dose-person.overdue {
background: var(--warning-bg);
}
@@ -2457,6 +2503,10 @@ button.has-validation-error {
color: var(--success);
}
.dose-person.skipped .person-name {
color: color-mix(in srgb, var(--warning) 82%, var(--text-primary));
}
.dose-person .dose-btn {
margin-left: 0;
height: 24px;
@@ -2465,6 +2515,16 @@ button.has-validation-error {
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) {
.time-row {
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));
margin-bottom: 1rem;
max-width: 100%;
overflow: hidden;
overflow: visible;
}
.card {
+17 -3
View File
@@ -10,7 +10,7 @@
flex-direction: column;
gap: 1.5rem;
max-width: 100%;
overflow: hidden;
overflow: visible;
}
.setting-row {
@@ -311,7 +311,7 @@
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 1100;
z-index: 12000;
pointer-events: none;
}
@@ -329,7 +329,7 @@
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 1101;
z-index: 12001;
}
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
@@ -507,6 +507,20 @@
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 */
@media (max-width: 480px) {
.notification-matrix {
+23 -2
View File
@@ -1,5 +1,5 @@
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 App from "../App";
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
});
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>,
PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>,
@@ -265,6 +273,19 @@ describe("App", () => {
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", () => {
authMock = {
user: null,
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
oidcProviderName: "",
};
afterEach(() => {
window.history.replaceState({}, "", "/");
});
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
@@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => {
it("shows refill history when expanded", () => {
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} />);
@@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => {
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
const onRefillHistoryExpandedChange = vi.fn();
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(
@@ -42,7 +42,7 @@ describe("ReportModal", () => {
json: async () => ({
1: {
dosesTaken: 2,
dosesDismissed: 0,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
@@ -74,7 +74,7 @@ describe("ReportModal", () => {
1: {
dosesTaken: 1,
automaticDosesTaken: 0,
dosesDismissed: 0,
dosesSkipped: 0,
firstDoseAt: "2026-02-03T12:00:00.000Z",
lastDoseAt: null,
refills: [],
@@ -121,7 +121,7 @@ describe("ReportModal", () => {
1: {
dosesTaken: 0,
automaticDosesTaken: 0,
dosesDismissed: 0,
dosesSkipped: 0,
firstDoseAt: null,
lastDoseAt: null,
refills: [],
@@ -183,13 +183,14 @@ describe("ReportModal", () => {
1: {
dosesTaken: 1,
automaticDosesTaken: 0,
dosesDismissed: 0,
dosesSkipped: 0,
firstDoseAt: "2026-03-03T12:00:00.000Z",
lastDoseAt: null,
refills: [
{
packsAdded: 1,
loosePillsAdded: 0,
quantityAdded: 20,
usedPrescription: false,
refillDate: "2026-03-04",
},
@@ -251,6 +252,81 @@ describe("ReportModal", () => {
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 () => {
const onClose = vi.fn();
(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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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", () => {
beforeEach(() => {
vi.clearAllMocks();
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, "clearInterval").mockImplementation(() => {});
});
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
it("renders shared schedule shell for valid token", async () => {
(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: [] }) });
}
if (url === "/api/share/token-123") {
@@ -247,7 +295,7 @@ describe("SharedSchedule", () => {
it("renders generic error when loading share data fails", async () => {
(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: [] }) });
}
if (url === "/api/share/token-123") {
@@ -270,7 +318,7 @@ describe("SharedSchedule", () => {
const sharedData = createSharedDataWithTodayDose(referenceNow);
(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: () =>
@@ -296,7 +344,7 @@ describe("SharedSchedule", () => {
const sharedData = createSharedDataWithEmbeddedOverview();
(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: [] }) });
}
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.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();
(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: [] }) });
}
if (url === "/api/share/token-123") {
@@ -37,6 +37,7 @@ vi.mock("../../hooks", () => ({
vi.mock("../../utils/formatters", () => ({
getSystemLocale: () => "en-US",
setDefaultFormattingTimezone: vi.fn(),
}));
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 () => {
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({
ok: true,
@@ -49,7 +51,7 @@ describe("useRefill", () => {
it("handles refill history with refills wrapper", async () => {
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({
@@ -162,7 +164,7 @@ describe("useRefill", () => {
"/api/medications/1/refill",
expect.objectContaining({
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(
@@ -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 () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });

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